From a4abe30b185e9b545af69314829c74da5d922e09 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 12:13:05 +0200 Subject: [PATCH 01/25] docs(proxy): clarify flow control and HTTP parser contracts --- src/proxy/http_proxy.h | 84 ++++++++++++++++++++++---------------- src/proxy/proxy_protocol.h | 64 ++++++++++++++++++----------- src/proxy/socks_proxy.h | 76 ++++++++++++++++++---------------- 3 files changed, 131 insertions(+), 93 deletions(-) diff --git a/src/proxy/http_proxy.h b/src/proxy/http_proxy.h index cc16a199..ac5fa649 100644 --- a/src/proxy/http_proxy.h +++ b/src/proxy/http_proxy.h @@ -1,7 +1,11 @@ /* Потоковый HTTP/1.x codec локального proxy. Не владеет сокетами/DNS/ETCP. * Заголовки проверяются целиком до пересылки; тело передаётся ограниченными порциями. - * emit получает исходное число байт для возврата ETCP credit после доставки результата. - * Возврат feed: 0=нужно продолжение, 1=сообщение завершено, <0=HTTP error status. */ + * Для нового сообщения обнулить message/request, затем headers_feed → request/response_parse → body_feed. + * parse меняет headers и сохраняет в fields указатели внутрь него: message нельзя копировать после parse. + * emit вызывается синхронно, буфер заимствован только на время вызова; source_len нужен для возврата + * ETCP credit после доставки результата. Ошибку feed/parse нельзя продолжать как успешное сообщение. + * Возврат feed: 0=нужно продолжение, 1=заголовки/тело завершены, <0=отрицательный HTTP status. + * HTTP_BODY_EOF завершает владелец по FIN, отдельной функции EOF у codec нет. */ #ifndef HTTP_PROXY_H #define HTTP_PROXY_H @@ -12,54 +16,64 @@ #define HTTP_PROXY_HEADER_LIMIT 32768 #define HTTP_PROXY_FIELDS_LIMIT 512 +// Результат: >=0 = принято целиком, <0 = ошибка вывода (codec вернёт -500); частичный приём не поддержан. +// source_len может отличаться от len; len=0/data=NULL допустимы при удалении исходных байтов. typedef int (*http_proxy_emit)(void* arg, const uint8_t* data, size_t len, size_t source_len); -struct http_proxy_field { char* name; char* value; }; +struct http_proxy_field { char* name; char* value; }; // Заимствованные NUL-строки внутри message.headers. +// NONE — исходное нулевое состояние; EOF — тело до внешнего FIN; DONE — точная граница сообщения. enum http_proxy_body_state { HTTP_BODY_NONE, HTTP_BODY_LENGTH, HTTP_BODY_EOF, HTTP_BODY_CHUNK_SIZE, HTTP_BODY_CHUNK_DATA, HTTP_BODY_CHUNK_CRLF, HTTP_BODY_TRAILERS, HTTP_BODY_DONE }; struct http_proxy_message { - char headers[HTTP_PROXY_HEADER_LIMIT + 1]; - size_t header_len; - size_t first_line_len; - struct http_proxy_field fields[HTTP_PROXY_FIELDS_LIMIT]; - unsigned field_count; - uint8_t headers_done; - uint8_t minor; - uint8_t close; - uint8_t chunked; - uint8_t has_length; - uint8_t upgrade; - uint8_t decode_chunks; - unsigned status; - uint64_t length; - enum http_proxy_body_state body; - uint64_t remaining; - char line[HTTP_PROXY_LINE_LIMIT + 1]; - size_t line_len; - size_t trailer_len; - unsigned crlf_pos; + char headers[HTTP_PROXY_HEADER_LIMIT + 1]; // До parse — CRLF-блок, после — строки с NUL-разделителями. + size_t header_len; // Число исходных байтов, включая CRLFCRLF. + size_t first_line_len; // Длина стартовой строки с CRLF. + struct http_proxy_field fields[HTTP_PROXY_FIELDS_LIMIT]; // Разобранные поля собственного headers. + unsigned field_count; // Число заполненных fields. + uint8_t headers_done; // headers_feed получил полный CRLFCRLF. + uint8_t minor; // Версия исходного HTTP: 0 или 1. + uint8_t close; // Запрос: Connection close; ответ: решение закрыть frontend. + uint8_t chunked; // Единственный поддержанный Transfer-Encoding — chunked. + uint8_t has_length; // Присутствует проверенный Content-Length (включая нулевой). + uint8_t upgrade; // В Connection найден token upgrade. + uint8_t decode_chunks; // Убрать framing chunked для ответа HTTP/1.0. + unsigned status; // HTTP status ответа; у запроса 0. + uint64_t length; // Content-Length; отсутствие различает has_length. + enum http_proxy_body_state body; // Состояние потокового разбора тела. + uint64_t remaining; // Остаток Content-Length или текущего chunk. + char line[HTTP_PROXY_LINE_LIMIT + 1]; // Неполная строка размера chunk или trailer. + size_t line_len; // Сколько байт накоплено в line. + size_t trailer_len; // Суммарный размер trailers, лимит HEADER_LIMIT. + unsigned crlf_pos; // 0/1: сколько байт CRLF после chunk уже принято. }; struct http_proxy_request { - char host[256]; - char authority[264]; - char upgrade[HTTP_PROXY_LINE_LIMIT + 1]; - uint16_t port; - uint8_t connect; - uint8_t head; - uint8_t close; - uint8_t minor; + char host[256]; // IPv4 literal или DNS-имя; IPv6 отклоняется с -502. + char authority[264]; // Нормализованный host:port для upstream Host. + char upgrade[HTTP_PROXY_LINE_LIMIT + 1]; // Предложенные protocol[/version], пусто без Upgrade. + uint16_t port; // Host byte order; HTTP default=80, CONNECT требует порт. + uint8_t connect; // Метод CONNECT: после успеха используется сырой туннель. + uint8_t head; // Метод HEAD: у ответа нет тела. + uint8_t close; // Закрыть frontend: HTTP/1.0 или Connection close. + uint8_t minor; // Версия клиента, определяет сериализацию ответа. }; -// Накапливает только заголовки; хвост входа остаётся у вызывающей стороны. +// Накапливает заголовки из заимствованного data; *used = число принятых байтов, хвост остаётся вызывающему. +// m первоначально обнулён; 1 = CRLFCRLF получен, 0 = ждать продолжения, <0 = -400/-414/-431. int http_proxy_headers_feed(struct http_proxy_message* m, const uint8_t* data, size_t len, size_t* used); -// Проверяет запрос и отправляет реконструированные заголовки через emit (кроме CONNECT). +// Однократно после headers_feed==1: проверяет запрос, заполняет обнулённый r и body в m. +// Обычный запрос требует absolute http:// URL; emit получает origin-form HTTP/1.1 headers. +// CONNECT проверяет authority и не вызывает emit. 0 = успех, <0 = отрицательный HTTP status. int http_proxy_request_parse(struct http_proxy_message* m, struct http_proxy_request* r, http_proxy_emit emit, void* arg); -// Проверяет ответ, учитывает HEAD/Upgrade и формирует собственные connection headers. +// Однократно после headers_feed==1; r — исходный запрос, close_client — решение закрыть frontend. +// Проверяет HEAD/Upgrade, задаёт body, выводит headers в версии клиента. 0 = успех, <0 = -502/-500. +// Для следующего промежуточного ответа владелец обнуляет m; r сохраняется до final response. int http_proxy_response_parse(struct http_proxy_message* m, const struct http_proxy_request* r, int close_client, http_proxy_emit emit, void* arg); -// Определяет точную границу тела, фильтрует trailers; поддерживает произвольные TCP-разбиения. +// После parse: передаёт тело из data через emit, *used = принятые байты до точной границы тела. +// Фильтрует trailers; 1 = DONE, 0 = продолжить, <0 = отрицательный HTTP status. +// Для HTTP_BODY_EOF возвращает 0, завершение по FIN проверяет владелец. Вывод не откатывается при ошибке. int http_proxy_body_feed(struct http_proxy_message* m, const uint8_t* data, size_t len, size_t* used, http_proxy_emit emit, void* arg); diff --git a/src/proxy/proxy_protocol.h b/src/proxy/proxy_protocol.h index ee12d5d9..47cd0bef 100644 --- a/src/proxy/proxy_protocol.h +++ b/src/proxy/proxy_protocol.h @@ -1,6 +1,11 @@ -// Общий TCP proxy протокол. DATA ограничен окном каждого потока; WINDOW возвращает -// кредит после потребления данных локальным TCP. FIN следует за всеми DATA своего -// направления. CONNECTED подтверждает реальный connect exit → destination. +/* Общий TCP proxy протокол и inline-машина окна; все вызовы выполняются в uasync владельца. + * init задаёт окно, но ready устанавливает владелец после CONNECTED (на exit — flush). + * Перед destroy владельца обязателен proxy_flow_destroy: retry хранит указатель на flow. + * Отправка: svc(1), cmd(1), sid(4), payload; приём: router header(26), cmd(1), sid(4), payload. + * sid и WINDOW uint32_t копируются в host byte order; IPv4/порт CONNECT — network byte order. + * CONNECTED подтверждает реальный connect exit → destination. DATA ограничен окном потока. + * FIN завершает одно направление; владелец ставит fin_pending только после всех своих DATA. + * CLOSE/ERROR и CONNECT отправляет владелец напрямую: общий retry их не сохраняет. */ #ifndef PROXY_PROTOCOL_H #define PROXY_PROTOCOL_H @@ -22,30 +27,32 @@ #define TCP_PROXY_SUBCMD_WINDOW 0x07 #define TCP_PROXY_HDR_SIZE 6 #define TCP_PROXY_CONNECT_HDR_SIZE 12 -#define TCP_PROXY_RECV_HDR_SIZE (ROUTER_SVC_PAYLOAD_OFF + 5) +#define TCP_PROXY_RECV_HDR_SIZE (ROUTER_SVC_PAYLOAD_OFF + 5) // 31 байт с метаданными router, не wire header #define TCP_PROXY_CHUNK 4096 #define TCP_PROXY_WINDOW 65536u struct proxy_flow { - struct UTUN_INSTANCE* inst; - struct UASYNC* ua; - uint64_t peer; - uint32_t sid; - uint8_t svc; - uint8_t ready; - uint8_t connected_pending; - uint8_t fin_pending; - uint8_t fin_sent; - uint8_t fin_received; - uint32_t tx_credit; - uint32_t rx_credit; - uint32_t consumed; - void* retry; - void (*wake)(void* arg); - void* arg; + struct UTUN_INSTANCE* inst; // Заимствованный instance для отправки в TOPO_GROUP_UTUN, mode=0. + struct UASYNC* ua; // Заимствованный event loop; живёт дольше flow и retry. + uint64_t peer; // Конечный узел противоположной стороны потока. + uint32_t sid; // ID потока; на exit уникален только вместе с peer. + uint8_t svc; // Сервис получателя: TCP_PROXY_SERVER или TCP_PROXY_CLIENT. + uint8_t ready; // Разрешены DATA и FIN; начальное значение 0. + uint8_t connected_pending; // Exit должен поставить CONNECTED в router перед остальными control. + uint8_t fin_pending; // Владелец уже передал все DATA и просит отправить FIN. + uint8_t fin_sent; // FIN принят router; это не ACK конечного TCP. + uint8_t fin_received; // Владелец принял FIN; последующие DATA отклоняются. + uint32_t tx_credit; // Сколько DATA ещё разрешено отправить, изначально TCP_PROXY_WINDOW. + uint32_t rx_credit; // Сколько DATA разрешено принять до следующего WINDOW. + uint32_t consumed; // Потреблённые байты, кредит которых ещё не передан router. + void* retry; // Отменяемый таймер control, 5000 tb = 500 мс. + void (*wake)(void* arg); // Возобновление очередей владельца после timer flush; может освободить владельца. + void* arg; // Заимствованный аргумент wake. }; -// Сообщение целиком передаётся маршрутизатору, который освобождает entry при любом результате. +// Копирует заимствованный data (len<=4096; при len>0 не NULL), отправляет в UTUN с mode=0. +// 0 = принято router, <0 = ошибка; force обходит лимит send_q, не гарантирует доставку. +// Созданный entry целиком передаётся router, который освобождает его при любом результате. static inline int proxy_send(struct UTUN_INSTANCE* inst, uint64_t peer, uint8_t svc, uint8_t cmd, uint32_t sid, const uint8_t* data, size_t len, int force) { if (len > TCP_PROXY_CHUNK) { @@ -66,6 +73,8 @@ static inline int proxy_send(struct UTUN_INSTANCE* inst, uint64_t peer, uint8_t return etcp_route_send(inst, TOPO_GROUP_UTUN, peer, e, force, 0); } +// Отправляет pending CONNECTED → WINDOW → FIN с force=1; неудачу повторяет через 500 мс. +// Владелец сам дренирует DATA перед fin_pending; flush не проверяет его очереди. static inline void proxy_flow_flush(struct proxy_flow* f); // Повторяем только недоставленные control-сообщения; DATA остаётся у владельца потока. @@ -76,6 +85,8 @@ static inline void proxy_flow_retry(void* arg) { if (f->wake) f->wake(f->arg); } +// Обнуляет неактивный f, задаёт peer/svc/sid и оба окна по 64 KiB; указатели заимствуются. +// Перед повторным init нужно отменить прежний retry через destroy; ready остаётся 0. static inline void proxy_flow_init(struct proxy_flow* f, struct UTUN_INSTANCE* inst, struct UASYNC* ua, uint64_t peer, uint8_t svc, uint32_t sid, void (*wake)(void*), void* arg) { memset(f, 0, sizeof(*f)); @@ -84,6 +95,7 @@ static inline void proxy_flow_init(struct proxy_flow* f, struct UTUN_INSTANCE* i f->wake = wake; f->arg = arg; } +// Отменяет только control retry; память flow, очереди и router waiter освобождает владелец. static inline void proxy_flow_destroy(struct proxy_flow* f) { if (f->retry) { uasync_cancel_timeout(f->ua, f->retry); f->retry = NULL; } } @@ -114,7 +126,8 @@ retry: if (!f->retry) f->retry = uasync_set_timeout(f->ua, 5000, f, proxy_flow_retry, "proxy_control"); } -// 0 = отправлено, -2 = ждём окно/CONNECTED, -1 = router backpressure/ошибка выделения. +// Копирует DATA; 0 = принято router, -2 = ждём окно/CONNECTED, -1 = неверный DATA/ошибка router или выделения. +// При ошибке восстанавливает tx_credit; исходный буфер остаётся у владельца для повтора. static inline int proxy_flow_send(struct proxy_flow* f, const uint8_t* data, size_t len, int force) { if (!f->ready || len > f->tx_credit) return -2; if (!len || len > TCP_PROXY_CHUNK || f->fin_sent) { @@ -127,6 +140,8 @@ static inline int proxy_flow_send(struct proxy_flow* f, const uint8_t* data, siz return ret; } +// Проверяет DATA len=1..4096, доступное rx_credit и отсутствие FIN; 0 списывает кредит, -1 = ошибка. +// Проверку ready и доставку байтов локальному TCP выполняет вызывающая сторона. static inline int proxy_flow_receive(struct proxy_flow* f, size_t len) { if (!len || len > TCP_PROXY_CHUNK || len > f->rx_credit || f->fin_received) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "proxy: invalid receive sid=%08x len=%zu credit=%u fin=%u", @@ -137,6 +152,8 @@ static inline int proxy_flow_receive(struct proxy_flow* f, size_t len) { return 0; } +// WINDOW: заимствованные 4 байта host-order credit; 0 добавляет кредит, -1 = неверный размер/кредит. +// Не вызывает wake: владелец после успеха сам возобновляет отправку. static inline int proxy_flow_window(struct proxy_flow* f, const uint8_t* data, size_t len) { uint32_t credit = 0; if (len == 4) memcpy(&credit, data, 4); @@ -149,7 +166,8 @@ static inline int proxy_flow_window(struct proxy_flow* f, const uint8_t* data, s return 0; } -// Вызывать только после передачи байтов локальному TCP, не после получения ETCP DATA. +// Возвращает кредит через flush после передачи len байт локальному TCP (OS send/lwIP tcp_write). +// Владелец гарантирует, что байты ранее приняты и не учтены повторно; проверки здесь нет. static inline void proxy_flow_consume(struct proxy_flow* f, uint32_t len) { f->consumed += len; proxy_flow_flush(f); diff --git a/src/proxy/socks_proxy.h b/src/proxy/socks_proxy.h index 0f08c539..dfac84de 100644 --- a/src/proxy/socks_proxy.h +++ b/src/proxy/socks_proxy.h @@ -1,6 +1,9 @@ /* Локальные SOCKS5 CONNECT и HTTP proxy/CONNECT: DNS, handshake и передача TCP через удалённый exit. * tcp_proxy_client владеет listener и списками соединений; их callbacks выполняются в uasync. - * init_listen заимствует указатели на списки/счётчики до close_listen; закрытие listener не освобождает клиентов. */ + * init_listen заимствует inst/ua/списки/счётчики; они живут до закрытия listener И освобождения клиентов. + * Сначала закрыть listener, затем conn_free_all; закрытие listener само по себе не освобождает клиентов. + * DNS выполняется локально (первая A-запись); SOCKS поддерживает только CONNECT без аутентификации. + * HTTP keep-alive использует новый stream для каждой транзакции; CONNECT/101 переходят в сырой туннель. */ #ifndef SOCKS_PROXY_H #define SOCKS_PROXY_H @@ -25,80 +28,83 @@ struct socks_http_state; enum { SOCKS_STATE_GREETING = 0, // ждём SOCKS5 greeting (ver + methods) SOCKS_STATE_REQUEST, // ждём SOCKS5 CONNECT request (== HTTP_STATE_REQUEST) - SOCKS_STATE_CONNECTING, // DNS в процессе (== HTTP_STATE_CONNECTING) + SOCKS_STATE_CONNECTING, // ждём DNS или CONNECTED от exit (== HTTP_STATE_CONNECTING) SOCKS_STATE_RELAY // релей данных (== HTTP_STATE_RELAY) }; // DNS/connect и сырой туннель используют общие состояния; HTTP framing хранится отдельно в http. enum { HTTP_STATE_REQUEST = SOCKS_STATE_REQUEST, // ждём заголовки очередного HTTP-запроса - HTTP_STATE_CONNECTING = SOCKS_STATE_CONNECTING, // DNS в процессе + HTTP_STATE_CONNECTING = SOCKS_STATE_CONNECTING, // DNS или реальный connect на exit HTTP_STATE_RELAY = SOCKS_STATE_RELAY // релей данных }; struct socks_proxy_conn { - struct socks_proxy_conn* next; + struct socks_proxy_conn* next; // следующий клиент в списке владельца struct socks_proxy_conn** head; // указатель на голову списка (для удаления) int* count; // указатель на счётчик списка - struct tcp_conn* tc; - struct UASYNC* ua; - struct UTUN_INSTANCE* inst; - uint64_t via_node_id; - struct proxy_flow flow; - uint32_t stream_id; + struct tcp_conn* tc; // собственный OS TCP к локальному клиенту + struct UASYNC* ua; // заимствованный event loop + struct UTUN_INSTANCE* inst; // заимствованный instance + uint64_t via_node_id; // настроенный exit + struct proxy_flow flow; // окно и control retry текущего upstream + uint32_t stream_id; // текущий upstream; HTTP между транзакциями может быть 0 uint32_t* next_stream_id; // HTTP: новый поток для следующей транзакции - struct socks_http_state* http; - uint8_t dest_ip[4]; - uint16_t dest_port; - uint8_t fin_remote; - uint8_t rem_closed; - uint8_t close_sent; - uint8_t close_pending; - uint8_t state; - uint8_t freed; - uint8_t close_queued; + struct socks_http_state* http; // собственное HTTP-состояние; NULL для SOCKS + uint8_t dest_ip[4]; // IPv4 назначения, network byte order + uint16_t dest_port; // порт назначения, network byte order + uint8_t fin_remote; // FIN от exit; FIN локального клиента хранится в tc->fin_remote + uint8_t rem_closed; // прекращена обработка потока/клиента + uint8_t close_sent; // CLOSE принят router + uint8_t close_pending; // неудавшийся CLOSE; последняя попытка при free + uint8_t state; // SOCKS_STATE_* / HTTP_STATE_* + uint8_t freed; // освобождение уже началось + uint8_t close_queued; // close-маркер уже поставлен в write_queue void* free_soon_id; // handle отложенного освобождения (uasync_call_soon) - uint8_t buf[16384]; - uint16_t buf_len; + uint8_t buf[16384]; // handshake или неразобранный хвост read block + uint16_t buf_len; // число сохранённых байтов в buf uint8_t* tx_buf; // буфер при backpressure (retry в tx_waiter_cb) - uint16_t tx_len; + uint16_t tx_len; // остаток tx_buf; отправляется блоками до 4096 байт uint32_t bytes_to_client; // байт записано клиенту (curl) через write_queue uint32_t bytes_from_exit; // байт получено от exit node через ETCP DATA - struct queue_waiter_handle tx_waiter; - void* tx_retry_timer; // safety-net таймер backpressure (force=1) - uint8_t fin_deferred; // FIN отложен до сброса tx_buf + struct queue_waiter_handle tx_waiter; // ожидание router send_q, отменить до free + void* tx_retry_timer; // safety-net 500 мс, повтор с обычным force=0 + uint8_t fin_deferred; // зарезервировано, текущая реализация не использует struct adns_query* dns_q; // активный DNS-запрос (NULL когда нет) uint8_t dns_kind; // DNSK_SOCKS / DNSK_HTTP_CONNECT / DNSK_HTTP_PROXY }; struct listen_ctx; -// Создать слушающий сокет для SOCKS/HTTP proxy. addr_str = "IP:PORT". -// listen_ctx должен быть освобождён через socks_proxy_close_listen. -// Возвращает listen_ctx при успехе, NULL при ошибке. +// Создать IPv4 listener: addr_str = "IPv4:PORT", 0 разрешает выбор порта ОС. +// conns/count/next_stream_id и inst/ua заимствуются; is_http=0 — SOCKS5, иначе HTTP. +// Общий генератор sid нужен для всех списков одного tcp_proxy_client. Возврат: ctx или NULL при ошибке. +// Не регистрирует router callback: входящие ответы должен передавать tcp_proxy_client. struct listen_ctx* socks_proxy_init_listen(struct UASYNC* ua, const char* addr_str, struct socks_proxy_conn** conns, int* count, uint32_t* next_stream_id, struct UTUN_INSTANCE* inst, uint64_t via_node_id, int is_http); -// Закрыть слушающий сокет и освободить listen_ctx. +// Закрыть listener и освободить ctx в том же ua. NULL ctx допустим; активные клиенты сохраняются. // Если sock_out задан, в него записывается SOCKET_INVALID: сокет уже закрыт. void socks_proxy_close_listen(struct UASYNC* ua, struct listen_ctx* ctx, socket_t* sock_out); // Фактический порт активного listener (IP:0 выбирает ОС); 0 при ошибке. uint16_t socks_proxy_get_port(const struct listen_ctx* ctx); -// Найти соединение по stream_id +// Найти текущий stream_id в head; заимствованный результат или NULL, список не меняется. struct socks_proxy_conn* socks_proxy_find_conn(struct socks_proxy_conn* head, uint32_t stream_id); -// Обработать ответ exit: CONNECTED/DATA/WINDOW_UPDATE/CLOSE/ERROR/FIN. -// Возвращает 1 если обработано, 0 если stream_id не найден +// Передать ответ exit: CONNECTED/DATA/WINDOW/CLOSE/ERROR/FIN; data заимствован на время вызова. +// Проверку peer/group выполняет внешний dispatcher. Может закрыть/удалить соединение. +// Возвращает 1 при найденном sid (в том числе неизвестная команда), 0 если sid не найден. int socks_proxy_handle_etcp(struct socks_proxy_conn** head, int* count, uint32_t stream_id, uint8_t subcmd, const uint8_t* data, size_t data_len); -// Освободить одно соединение (удаляет из списка) +// Освободить c и его TCP/DNS/HTTP/таймеры/waiter, удалить из списка; NULL допустим. +// Активный HTTP upstream отменяется; в остальных случаях free не гарантирует уведомление exit. void socks_proxy_conn_free(struct socks_proxy_conn* c); -// Освободить все соединения в списке +// Освободить все соединения; head/count — те же живые адреса, что переданы init_listen. void socks_proxy_conn_free_all(struct socks_proxy_conn** head, int* count); From 23cec465bf71f3a4444e63b47f49a61ac60d270f Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 12:14:42 +0200 Subject: [PATCH 02/25] docs(proxy): specify TCP lifecycle and UDP ownership --- src/proxy/tcp_proxy_client.h | 69 +++++++++++++++++++++--------------- src/proxy/tcp_proxy_server.h | 62 +++++++++++++++++++++----------- src/proxy/udp_proxy.h | 61 +++++++++++++++++-------------- 3 files changed, 117 insertions(+), 75 deletions(-) diff --git a/src/proxy/tcp_proxy_client.h b/src/proxy/tcp_proxy_client.h index c4a6dd7f..79829ca7 100644 --- a/src/proxy/tcp_proxy_client.h +++ b/src/proxy/tcp_proxy_client.h @@ -1,7 +1,9 @@ /* Клиент TCP-прокси: принимает IPv4 с отдельного TUN через lwIP либо TCP от SOCKS/HTTP listener. * Передаёт потоки через UTUN-router на via_node_id; proxy_flow обеспечивает окно и backpressure. * create/destroy и callbacks — в uasync. Владеет созданными TUN/lwIP/listener/потоками, - * заимствует inst/ua/mappings до destroy. Без tun_name/tun_ip может работать только SOCKS/HTTP. */ + * заимствует inst/ua/mappings до destroy. Без пары непустых tun_name/tun_ip работает только SOCKS/HTTP. + * Владелец сам записывает результат create в inst->tcp_proxy_client и обнуляет поле после destroy. + * Клиент принимает TCP-ответы только от via_node_id в TOPO_GROUP_UTUN; UDP/ICMP требуют TUN. */ #ifndef TCP_PROXY_CLIENT_H #define TCP_PROXY_CLIENT_H @@ -32,12 +34,12 @@ struct listen_ctx; struct tcp_pcb; struct tcp_proxy_client_conn { - struct tcp_proxy_client_conn* next; - struct tcp_proxy_client* proxy; - struct tcp_pcb* pcb; + struct tcp_proxy_client_conn* next; // следующий lwIP-поток клиента + struct tcp_proxy_client* proxy; // заимствованный владелец + struct tcp_pcb* pcb; // принадлежит lwIP; NULL после detach/err callback - struct proxy_flow flow; - uint32_t stream_id; + struct proxy_flow flow; // окно и retry CONNECTED/WINDOW/FIN + uint32_t stream_id; // общий генератор с SOCKS/HTTP-потоками struct ll_queue* to_lwip; // DATA от exit → lwIP (управление потоком) @@ -47,54 +49,63 @@ struct tcp_proxy_client_conn { uint8_t rem_closed; // exit отправил CLOSE uint8_t error; // ошибка, немедленная очистка uint8_t close_sent; // отправили CLOSE/ERROR в exit - uint8_t close_pending; // CLOSE/ERROR не доставлен, ждём повтора + uint8_t close_pending; // CLOSE/ERROR не принят router, повтор в poll/free пока pc жив struct ll_queue* tx_queue; // буфер lwIP→ETCP при backpressure (аналог server read_queue) struct queue_waiter_handle tx_waiter; // waiter на send_q (etcp_router_on_send_ready) void* tx_retry_timer; // retry safety-net (force=1), аналог server retry_timer - uint32_t bytes_to_exit; // байт отправлено в exit (из lwIP/TUN) + uint32_t bytes_to_exit; // байт принято router для exit (из lwIP/TUN) uint32_t bytes_from_exit; // байт получено от exit (в lwIP/TUN) uint32_t bp_count; // счётчик backpressure (send fail) - uint8_t dest_ip[4]; - uint16_t dest_port; + uint8_t dest_ip[4]; // IPv4 назначения в сетевом порядке + uint16_t dest_port; // порт назначения в сетевом порядке }; struct tcp_proxy_client { - struct UTUN_INSTANCE* inst; - struct UASYNC* ua; - struct tun_if* tun; - struct tcp_proxy_client_conn* conns; - int conn_count; - struct memory_pool* entry_pool; - uint32_t next_stream_id; - uint64_t via_node_id; - struct lwip_tcp_ctx* lwip; + struct UTUN_INSTANCE* inst; // заимствован; NULL допустим для автономного TUN smoke test + struct UASYNC* ua; // заимствованный event loop + struct tun_if* tun; // собственный TUN или NULL для SOCKS/HTTP без TUN + struct tcp_proxy_client_conn* conns; // список собственных lwIP-потоков + int conn_count; // число lwIP-потоков, без SOCKS/HTTP + struct memory_pool* entry_pool; // собственный пул ll_entry для очередей lwIP-потоков + uint32_t next_stream_id; // общий счётчик TUN/SOCKS/HTTP, init=1, перед выдачей инкрементируется + uint64_t via_node_id; // настроенный exit для всех потоков + struct lwip_tcp_ctx* lwip; // собственный TCP-стек или NULL без TUN struct tcp_proxy_client_mapping_config* mappings; // указатель на конфиг - int mapping_count; + int mapping_count; // число записей mappings, порты конфига в host byte order // SOCKS proxy - int socks_enabled; - struct socks_proxy_conn* socks_conns; - int socks_conn_count; - struct listen_ctx* socks_listen; + int socks_enabled; // запрошен SOCKS5; не гарантирует успешный listener + struct socks_proxy_conn* socks_conns; // собственные SOCKS5-клиенты + int socks_conn_count; // число SOCKS5-клиентов + struct listen_ctx* socks_listen; // собственный listener или NULL при отключении/ошибке - // HTTP CONNECT proxy - int http_proxy_enabled; - struct socks_proxy_conn* http_conns; - int http_conn_count; - struct listen_ctx* http_listen; + // HTTP forward proxy и CONNECT + int http_proxy_enabled; // запрошен HTTP; не гарантирует успешный listener + struct socks_proxy_conn* http_conns; // собственные HTTP-клиенты + int http_conn_count; // число HTTP-клиентов + struct listen_ctx* http_listen; // собственный listener или NULL при отключении/ошибке }; +// Создать клиент; ua обязателен. Непустые tun_name И tun_ip включают TUN/lwIP (mtu<=0 → 1500). +// test_mode передаётся tun_init_nat: ненулевой использует тестовый TUN; mappings заимствуются до destroy. +// SOCKS/HTTP создаются при enabled и непустом IPv4:port; via_node_id — общий exit. +// Результат: собственный p или NULL при критической ошибке/отсутствии режима. +// Ошибки listener/router bind/UDP/ICMP не делают create атомарным: проверять listener и диагностику отдельно. struct tcp_proxy_client* tcp_proxy_client_create(struct UTUN_INSTANCE* inst, struct UASYNC* ua, const char* tun_name, const char* tun_ip, int mtu, int test_mode, struct tcp_proxy_client_mapping_config* mappings, int mapping_count, uint64_t via_node_id, int socks_enabled, const char* socks_addr, int http_proxy_enabled, const char* http_proxy_addr); +// Остановить listeners, освободить потоки/TUN/lwIP и снять router binding; NULL допустим. +// UDP/ICMP сохраняются, пока включён сервер того же inst. Не обнуляет inst->tcp_proxy_client. void tcp_proxy_client_destroy(struct tcp_proxy_client* p); +// Router callback: conn->instance задаёт владельца, entry содержит метаданные router и передаёт владение. +// Освобождает entry/dgram при любом исходе; CLOSE_ALL = пустой payload router, а не TCP-подкоманда. void tcp_proxy_client_router_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry); diff --git a/src/proxy/tcp_proxy_server.h b/src/proxy/tcp_proxy_server.h index 4c0b60c3..83f968ff 100644 --- a/src/proxy/tcp_proxy_server.h +++ b/src/proxy/tcp_proxy_server.h @@ -1,7 +1,9 @@ /* Exit TCP-прокси: принимает CONNECT/DATA через UTUN-router и открывает обычный TCP к назначению. * Поток определяется парой peer_node_id/stream_id; proxy_flow согласует окна и half-close. * init учитывает tcp_proxy_server_enabled в конфиге; destroy закрывает потоки и общий UDP/ICMP-прокси. - * Состояние принадлежит UTUN_INSTANCE, операции и callbacks выполняются в его uasync-потоке. */ + * Сначала init, затем приём через router callback, в конце destroy; повторный init живого сервера недопустим. + * Состояние принадлежит UTUN_INSTANCE, операции и callbacks выполняются в его uasync-потоке. + * CLOSE дренирует запись и прекращает чтение; ERROR немедленно уничтожает поток, FIN — half-close. */ #ifndef TCP_PROXY_SERVER_H #define TCP_PROXY_SERVER_H @@ -24,50 +26,70 @@ struct ETCP_CONN; #include "proxy_protocol.h" struct tcp_proxy_server_conn { - struct tcp_proxy_server_conn* next; - struct tcp_proxy_server* ctx; - struct tcp_conn* tc; - struct proxy_flow flow; - uint32_t stream_id; - uint64_t peer_node_id; - uint8_t dest_ip[4]; - uint16_t dest_port; + struct tcp_proxy_server_conn* next; // следующий поток exit + struct tcp_proxy_server* ctx; // заимствованный владелец в inst + struct tcp_conn* tc; // собственный OS TCP к назначению + struct proxy_flow flow; // окно, FIN и control retry + uint32_t stream_id; // ID клиента; уникален в паре с peer_node_id + uint64_t peer_node_id; // конечный ingress-узел, не транспортный сосед + uint8_t dest_ip[4]; // IPv4 назначения в сетевом порядке + uint16_t dest_port; // порт назначения в сетевом порядке - uint8_t close_queued; + uint8_t close_queued; // close-маркер уже поставлен в write_queue TCP uint8_t cli_closed; // client sent CLOSE void* diag_timer; // 1-секундный таймер диагностики - uint8_t freed; // 1 = уже освобождён, защита от double-free + uint8_t freed; // логически удалён; сама структура освобождается через call_soon - struct queue_waiter_handle pause_waiter; - void* retry_timer; // fallback timer (500ms) for force=1 retry on stall - uint32_t bytes_sent; // байт записано в destination (реальный TCP) - uint32_t bytes_relayed; // байт отправлено клиенту через ETCP + struct queue_waiter_handle pause_waiter; // ожидание свободного места в router send_q + void* retry_timer; // fallback 500 мс: возобновить read_queue, DATA отправляется с force=0 + uint32_t bytes_sent; // сумма входящих DATA, учитывается до проверки/записи в TCP + uint32_t bytes_relayed; // байт принято router для клиента uint32_t drain_count; // счётчик вызовов read_queue_drain_cb uint32_t data_count; // счётчик входящих DATA от клиента - struct UASYNC* ua; + struct UASYNC* ua; // заимствованный loop для таймеров и отложенного free }; struct tcp_proxy_server { - int enabled; - int conn_count; - struct tcp_proxy_server_conn* conns; - struct UTUN_INSTANCE* inst; + int enabled; // exit обслуживает CONNECT/UDP/ICMP REQUEST + int conn_count; // число потоков в conns + struct tcp_proxy_server_conn* conns; // собственные потоки всех пиров + struct UTUN_INSTANCE* inst; // заимствованный instance, содержащий сервер }; +// Обнулить состояние и учесть config->global.tcp_proxy_server_enabled; NULL inst → -1, иначе 0. +// Без конфига/при отключении не bind-ит сервис. При включении bind SERVER и вызывает UDP/ICMP init. +// 0 не гарантирует успешный bind/UDP/ICMP: их ошибки не пробрасываются вызывающему коду. int tcp_proxy_server_init(struct UTUN_INSTANCE* inst); +// Удалить потоки, выключить сервер, снять binding и уничтожить общие UDP/ICMP; NULL inst допустим. +// Перед этим остановить tcp_proxy_client того же inst; дать ua обработать отложенные освобождения. void tcp_proxy_server_destroy(struct UTUN_INSTANCE* inst); +// Линейный поиск в ненулевом ctx по паре peer/sid; заимствованный указатель или NULL. struct tcp_proxy_server_conn* tcp_proxy_server_find_conn(struct tcp_proxy_server* ctx, uint64_t peer, uint32_t stream_id); +// Немедленно удалить rc из списка, закрыть TCP и отменить таймеры/waiter; NULL допустим. +// Память rc освобождается отложенно в ua; сама функция не отправляет CLOSE/ERROR пиру. void tcp_proxy_server_conn_free(struct tcp_proxy_server_conn* rc); +// Router callback: conn->instance — владелец, peer берётся из ROUTER_SVC_SRC_OFF entry. +// Принимает владение entry/dgram при любом исходе. Пустой payload router удаляет потоки этого peer. void tcp_proxy_server_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry); +// entry — полный приёмный пакет CONNECT с IPv4/портом; принимает владение entry/dgram. +// inst должен быть включён, sid/src — уже разобранные поля. 0 = connect запущен/готов, -1 = ошибка. +// CONNECTED отправляется только после реального connect; таймаут — из connect_timeout_ms (0 = отключён). int tcp_proxy_server_handle_connect(struct UTUN_INSTANCE* inst, struct ll_entry* entry, uint32_t stream_id, uint64_t src_node_id); +// Полный entry DATA не короче RECV_HDR_SIZE; принимает владение entry/dgram. conn сейчас не используется. +// Peer берётся из entry; проверяет окно и ставит данные в OS write_queue. 0 = обработано, -1 = отклонено. +// Ошибка выделения в write_queue может закрыть поток и при возврате 0: это не подтверждение OS send. int tcp_proxy_server_handle_data(struct UTUN_INSTANCE* inst, struct ETCP_CONN* conn, struct ll_entry* entry, uint32_t stream_id); +// CLOSE: прекратить чтение, отбросить read_queue и закрыть TCP после записи уже принятой write_queue. void tcp_proxy_server_handle_close(struct UTUN_INSTANCE* inst, uint64_t peer, uint32_t stream_id); +// ERROR: немедленно удалить поток вместе с непрочитанными/незаписанными данными. void tcp_proxy_server_handle_error(struct UTUN_INSTANCE* inst, uint64_t peer, uint32_t stream_id); +// FIN: поставить shutdown(SHUT_WR) после уже принятых DATA; обратное направление продолжает работать. +// handle_close/error/fin допускают NULL inst и отсутствующую пару peer/sid. void tcp_proxy_server_handle_fin(struct UTUN_INSTANCE* inst, uint64_t peer, uint32_t stream_id); diff --git a/src/proxy/udp_proxy.h b/src/proxy/udp_proxy.h index 937fe5fe..e1676545 100644 --- a/src/proxy/udp_proxy.h +++ b/src/proxy/udp_proxy.h @@ -1,6 +1,9 @@ /* UDP из TUN через UTUN-router: клиент отправляет REQUEST, exit открывает сокет для кортежа адресов * и возвращает REPLY. Потоки exit удаляются по таймауту неактивности. - * init/destroy и callbacks — в uasync; контекст принадлежит inst, роль берётся из tcp_proxy_server.enabled. */ + * init/destroy и callbacks — в uasync; inst владеет контекстом в inst->udp_proxy. + * init сохраняет роль в is_exit для диагностики; приём REQUEST проверяет текущее server.enabled, + * а REPLY — наличие tcp_proxy_client и совпадение peer с via_node_id. Один inst может быть и клиентом, и exit. + * API принимает IP/порты в network byte order, payload заимствован только на время вызова. */ #ifndef UDP_PROXY_H #define UDP_PROXY_H @@ -22,55 +25,61 @@ struct ETCP_CONN; #define UDP_PROXY_SUBCMD_REQUEST 0x01 #define UDP_PROXY_SUBCMD_REPLY 0x02 -// Заголовок сообщения на отправке (включая байт svc_id; src/dst node_id добавляет роутер): +// Сообщение для etcp_route_send (не полный wire packet; метаданные приёма добавляет router): // svc_id(1) + subcmd(1) + src_ip(4) + src_port(2) + dst_ip(4) + dst_port(2) + payload #define UDP_PROXY_HDR_SIZE 14 // Полный заголовок на приёме (после router_deliver): -// svc_id(1) + src_node_id(8) + dst_node_id(8) + subcmd(1) + src_ip(4) + src_port(2) + dst_ip(4) + dst_port(2) -#define UDP_PROXY_RECV_HDR_SIZE (ROUTER_SVC_PAYLOAD_OFF + 13) // 30 +// router header(26: svc/src/dst/flags/group) + subcmd(1) + src_ip(4) + src_port(2) + dst_ip(4) + dst_port(2) +#define UDP_PROXY_RECV_HDR_SIZE (ROUTER_SVC_PAYLOAD_OFF + 13) // 39 // Один UDP поток (сторона exit узла: сопоставляет client_node_id + кортеж адресов с OS сокетом) struct udp_flow { - struct udp_flow* next; - struct udp_proxy_ctx* ctx; - uint64_t client_node_id; - uint32_t src_ip; - uint16_t src_port; - uint32_t dst_ip; - uint16_t dst_port; - socket_t sock; - void* read_id; - struct UASYNC* ua; - uint64_t created_tb; - uint64_t last_activity_tb; + struct udp_flow* next; // следующий flow exit + struct udp_proxy_ctx* ctx; // заимствованный владелец + uint64_t client_node_id; // конечный ingress-узел + uint32_t src_ip; // исходный адрес приложения, network byte order + uint16_t src_port; // исходный порт приложения, network byte order + uint32_t dst_ip; // адрес назначения, network byte order + uint16_t dst_port; // порт назначения, network byte order + socket_t sock; // собственный неблокирующий UDP, bind ANY:0 + void* read_id; // регистрация сокета в ua + struct UASYNC* ua; // заимствованный event loop + uint64_t created_tb; // время создания (tb = 0.1 мс) + uint64_t last_activity_tb; // последний REQUEST или ответ от ожидаемого dst_ip:dst_port }; struct udp_proxy_ctx { - int initialized; - int is_exit; // 1=exit узел (создаёт сокеты), 0=клиент (шлёт через etcp) - struct UTUN_INSTANCE* inst; - struct UASYNC* ua; - struct udp_flow* flows; - int flow_count; - uint64_t flow_timeout_tb; + int initialized; // init завершён, binding запрошен + int is_exit; // снимок server.enabled на момент init, не фильтр recv_cb + struct UTUN_INSTANCE* inst; // заимствованный instance-владелец + struct UASYNC* ua; // заимствованный event loop + struct udp_flow* flows; // собственные flows exit + int flow_count; // число flows + uint64_t flow_timeout_tb; // период проверки и порог неактивности, default=600000 (60 с) void* expire_timer; // периодический таймер очистки истёкших потоков }; +// Создать per-instance контекст и bind UDP service; повторный init существующего контекста даёт 0. +// inst/ua должны жить до destroy. 0 = контекст есть (результат bind не проверяется), -1 = ошибка inst/памяти. int udp_proxy_init(struct UTUN_INSTANCE* inst, struct UASYNC* ua); +// Снять binding, отменить таймер, закрыть flows и обнулить inst->udp_proxy; NULL/нет контекста допустимы. void udp_proxy_destroy(struct UTUN_INSTANCE* inst); // Exit узел: принимает REQUEST, создаёт сокет, шлёт данные -// Сторона клиента: принимает REPLY, доставляет на TUN +// Сторона клиента: принимает REPLY от настроенного exit, доставляет на TUN. +// conn->instance задаёт владельца; callback принимает владение entry/dgram при любом исходе. void udp_proxy_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry); -// Сторона клиента: принять IP/UDP пакет с TUN, отправить через etcp_route_send +// Скопировать payload (0..65507 байт) в REQUEST и отправить в UTUN, force=0/mode=0. +// Требует inst->udp_proxy; при payload_len>0 payload не NULL. 0 = принято router, <0 = ошибка. int udp_proxy_send_to_exit(struct UTUN_INSTANCE* inst, uint64_t exit_node_id, uint32_t src_ip, uint16_t src_port, uint32_t dst_ip, uint16_t dst_port, const uint8_t* payload, size_t payload_len); -// Сторона клиента: доставить ответ на TUN (вызывается из recv_cb) +// Собрать IPv4/UDP src→dst и поставить в tun->input_queue (префикс версии 4); UDP checksum=0. +// Требует inst->tcp_proxy_client->tun; payload=0..65506. 0 = поставлено в очередь, -1 = ошибка. int udp_proxy_deliver_reply(struct UTUN_INSTANCE* inst, uint32_t src_ip, uint16_t src_port, uint32_t dst_ip, uint16_t dst_port, From 27cf6ae365e272182c10c016c9c86a3265d8f796 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 12:16:47 +0200 Subject: [PATCH 03/25] docs(proxy): document per-instance datagram contexts --- src/proxy/icmp_proxy.h | 69 ++++++++++++--------- src/proxy/icmp_proxy_doc.md | 117 ++++++++++++++++-------------------- src/proxy/udp_proxy_doc.md | 101 ++++++++++++------------------- 3 files changed, 131 insertions(+), 156 deletions(-) diff --git a/src/proxy/icmp_proxy.h b/src/proxy/icmp_proxy.h index cdef2e14..504447d6 100644 --- a/src/proxy/icmp_proxy.h +++ b/src/proxy/icmp_proxy.h @@ -1,7 +1,9 @@ /* IPv4 ICMP echo через UTUN-router: exit отправляет ping через raw socket, сопоставляет reply - * по собственному wire id/seq и восстанавливает исходные id/seq клиента. Запросы ограничены таймаутом. - * init/destroy и callbacks — в uasync; контекст принадлежит inst, роль берётся из tcp_proxy_server.enabled. - * test_loopback возвращает виртуальные ответы без сетевой отправки и предназначен для тестов. */ + * по wire id/seq и IP цели, восстанавливая исходные id/seq клиента. Запросы ограничены таймаутом. + * init/destroy и callbacks — в uasync; inst владеет контекстом в inst->icmp_proxy. + * init создаёт raw socket, если server.enabled; recv_cb проверяет текущий server.enabled для REQUEST + * и via_node_id клиентского proxy для REPLY. IP/id/seq в API — network byte order. + * test_loopback возвращает виртуальные ответы только при отсутствии raw socket; флаг его не отключает. */ #ifndef ICMP_PROXY_H #define ICMP_PROXY_H @@ -23,59 +25,70 @@ struct ETCP_CONN; #define ICMP_PROXY_SUBCMD_REQUEST 0x01 // client→exit: пингани dst_ip #define ICMP_PROXY_SUBCMD_REPLY 0x02 // exit→client: echo reply -// Заголовок сообщения на отправке (включая байт svc_id; src/dst node_id добавляет роутер): +// Сообщение для etcp_route_send (не полный wire packet; метаданные приёма добавляет router): // svc_id(1) + subcmd(1) + dst_ip(4) + orig_src_ip(4) + icmp_id(2) + icmp_seq(2) + payload #define ICMP_PROXY_HDR_SIZE 14 // Полный заголовок на приёме (после router_deliver): -// svc_id(1) + src_node_id(8) + dst_node_id(8) + subcmd(1) + dst_ip(4) + orig_src_ip(4) + icmp_id(2) + icmp_seq(2) -#define ICMP_PROXY_RECV_HDR_SIZE (ROUTER_SVC_PAYLOAD_OFF + 13) // 30 +// router header(26: svc/src/dst/flags/group) + subcmd(1) + dst_ip(4) + orig_src_ip(4) + icmp_id(2) + icmp_seq(2) +#define ICMP_PROXY_RECV_HDR_SIZE (ROUTER_SVC_PAYLOAD_OFF + 13) // 39 // Активный ICMP echo запрос (сторона exit узла) struct icmp_request { - struct icmp_request* next; - uint64_t client_node_id; - uint32_t dst_ip; + struct icmp_request* next; // следующий ожидающий запрос exit + uint64_t client_node_id; // ingress-узел для обратной доставки + uint32_t dst_ip; // IP цели ping, network byte order uint32_t orig_src_ip; // IP исходного отправителя (чтобы вернуть reply правильному адресату) - uint16_t echo_id; - uint16_t echo_seq; - uint16_t wire_id; - uint16_t wire_seq; - uint8_t payload[1500]; - size_t payload_len; - uint64_t sent_tb; - socket_t sock; // raw сокет (только если per-request) + uint16_t echo_id; // исходный id приложения, network byte order + uint16_t echo_seq; // исходный seq приложения, network byte order + uint16_t wire_id; // заменённый id в raw ICMP, network byte order + uint16_t wire_seq; // заменённый seq, уникален вместе с wire_id и dst_ip среди pending + uint8_t payload[1500]; // сохранённый префикс запроса, не используется для сборки ответа + size_t payload_len; // длина сохранённого префикса, не больше sizeof(payload) + uint64_t sent_tb; // время отправки, tb = 0.1 мс + socket_t sock; // не используется; запросы идут через общий ctx->raw_sock }; struct icmp_proxy_ctx { - int initialized; - int is_exit; - struct UTUN_INSTANCE* inst; - struct UASYNC* ua; + int initialized; // init завершён, binding запрошен + int is_exit; // снимок server.enabled при init (определяет создание raw socket) + struct UTUN_INSTANCE* inst; // заимствованный instance-владелец + struct UASYNC* ua; // заимствованный event loop socket_t raw_sock; // один SOCK_RAW для всего ICMP на exit - void* raw_read_id; - struct icmp_request* pending; - uint32_t next_token; - uint64_t request_timeout_tb; - int test_loopback; // 1 = виртуальный loopback без raw сокета (только тесты) + void* raw_read_id; // регистрация raw_sock в ua + struct icmp_request* pending; // собственные запросы, ожидающие ответа + uint32_t next_token; // генератор wire id/seq со случайным начальным значением + uint64_t request_timeout_tb; // период проверки и порог ожидания, default=50000 (5 с) + int test_loopback; // fallback при raw_sock==SOCKET_INVALID, только для тестов void* expire_timer; // периодический таймер очистки истёкших запросов }; +// Создать контекст и bind ICMP service; повторный init не меняет существующий контекст и даёт 0. +// inst/ua живут до destroy. -1 = нет inst/памяти/случайного token, 0 = контекст создан. +// 0 не гарантирует raw socket на exit: ошибка socket/регистрации не проваливает init; bind не проверяется. int icmp_proxy_init(struct UTUN_INSTANCE* inst, struct UASYNC* ua); +// Снять binding, отменить таймер, закрыть raw socket, удалить pending и обнулить inst->icmp_proxy; NULL допустим. void icmp_proxy_destroy(struct UTUN_INSTANCE* inst); -// Единый etcp_router коллбэк +// Router callback: conn->instance задаёт владельца; REQUEST требует server.enabled, REPLY — настроенного exit. +// Принимает владение entry/dgram при любом исходе, в том числе при отклонении пакета. void icmp_proxy_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry); -// Сторона клиента: принять IP/ICMP echo request с TUN, отправить через etcp +// Скопировать payload (0..65507) в REQUEST к exit в UTUN, force=0/mode=0; не требует локального контекста. +// dst_ip — цель ping, orig_src_ip — адрес приложения; payload заимствован, при длине>0 не NULL. +// 0 = принято router, <0 = ошибка; это не подтверждение raw send или получения echo reply. int icmp_proxy_send_to_exit(struct UTUN_INSTANCE* inst, uint64_t exit_node_id, uint32_t dst_ip, uint32_t orig_src_ip, uint16_t echo_id, uint16_t echo_seq, const uint8_t* payload, size_t payload_len); +// Собрать Echo Reply с IPv4 src_ip→dst_ip и исходными id/seq; пересчитать обе checksum. +// Требует inst->tcp_proxy_client->tun, payload заимствован (0..65506); 0 = в tun input_queue, -1 = ошибка. int icmp_proxy_deliver_reply(struct UTUN_INSTANCE* inst, uint32_t dst_ip, uint32_t src_ip, uint16_t echo_id, uint16_t echo_seq, const uint8_t* payload, size_t payload_len); +// Изменить тестовый fallback существующего контекста; без контекста ничего не делает. +// При наличии raw socket реальные echo-запросы имеют приоритет независимо от enabled. void icmp_proxy_set_test_loopback(struct UTUN_INSTANCE* inst, int enabled); diff --git a/src/proxy/icmp_proxy_doc.md b/src/proxy/icmp_proxy_doc.md index e43f78c5..d2bbc085 100644 --- a/src/proxy/icmp_proxy_doc.md +++ b/src/proxy/icmp_proxy_doc.md @@ -1,77 +1,64 @@ -# ICMP Proxy (icmp_proxy) +# ICMP proxy -## 1. Назначение -Проксирование ICMP Echo (ping) между клиентским и exit-узлом по протоколу ETCP. Клиент -принимает ICMP Echo Request с TUN-интерфейса, упаковывает запрос и отправляет через -`etcp_route_send` на exit. Exit-узел через нативный `SOCK_RAW`-сокет отправляет ICMP Echo -во внешнюю сеть, сопоставляет ответы по `echo_id`/`echo_seq` и возвращает клиенту. Клиент -восстанавливает IP/ICMP Echo Reply и доставляет в TUN. +`icmp_proxy.c/h` передаёт IPv4 Echo Request/Reply через `ETCP_RT_ID_ICMP_PROXY` +в `TOPO_GROUP_UTUN`. Общий формат router описан в +[doc/proxy_protocol.md](../../doc/proxy_protocol.md), контракты API — в +[icmp_proxy.h](icmp_proxy.h). Другие типы ICMP модуль не проксирует. -Один экземпляр контекста на процесс (глобальный `g_icmp_ctx`). Режим определяется флагом -`inst->tcp_proxy_server.enabled`. Для тестов предусмотрен loopback-режим без raw-сокета. +## Контекст и raw socket -## 2. Как пользоваться +Каждый `UTUN_INSTANCE` владеет отдельным `inst->icmp_proxy`, глобального контекста нет. +`init(inst, ua)` задаёт таймаут 50000 tb (5 с), случайное начало `next_token` и +сохраняет `server.enabled` в `is_exit`. Для exit открывает один неблокирующий +`AF_INET/SOCK_RAW/IPPROTO_ICMP` и регистрирует его чтение в uasync. -### Инициализация -```c -icmp_proxy_init(inst, ua); // exit создаёт SOCK_RAW, клиент — нет -``` -При инициализации регистрируется коллбэк `icmp_proxy_recv_cb` в `etcp_router` для сервиса -`ETCP_RT_ID_ICMP_PROXY`. На exit создаётся один `SOCK_RAW(IPPROTO_ICMP)` на весь модуль. +Повторный init сохраняет существующий контекст и возвращает 0. Ошибка создания +контекста или случайного token возвращает -1. Ошибка raw socket/регистрации +оставляет контекст без рабочего сокета, но init возвращает 0 и запрашивает router binding; +результат bind также не пробрасывается. Поэтому успешный init сам по себе не подтверждает +возможность отправки реального ping. Raw socket требует соответствующих прав ОС. -### Клиент: отправка ping-запроса -```c -icmp_proxy_send_to_exit(inst, exit_node_id, dst_ip, orig_src_ip, echo_id, echo_seq, payload, len); -``` -Вызывается из обработчика TUN-трафика. Запрос упаковывается и отправляется через `etcp_route_send`. -Ответ приходит асинхронно в `icmp_proxy_recv_cb` (REPLY), где вызывается `icmp_proxy_deliver_reply` -для сборки IP/ICMP Echo Reply и записи в TUN. +`test_loopback` по умолчанию выключен. `icmp_proxy_set_test_loopback(inst, 1)` разрешает +виртуальный REPLY **только при отсутствии raw socket**; открытый сокет имеет приоритет, +флаг его не отключает. Без контекста setter ничего не делает. -### Тестовый loopback -```c -icmp_proxy_set_test_loopback(inst, 1); // exit отвечает эмуляцией без raw-сокета -``` -В этом режиме exit не отправляет реальный ICMP Echo, а сразу возвращает REPLY клиенту — полезно -для тестирования прохождения пакетов через ETCP без необходимости в SOCK_RAW и внешней сети. +## Запрос и сопоставление ответа -### Очистка -```c -icmp_proxy_destroy(inst); // закрывает raw-сокет, удаляет pending-запросы, unbind -``` +REQUEST принимается при текущем `server.enabled`. Exit заменяет исходные echo id/seq +32-битным возрастающим token: старшие 16 бит — wire_id, младшие — wire_seq (в сетевом порядке). +При совпадении `wire_id/wire_seq/dst_ip` с pending выбирает следующий token. +Сопоставление ответа использует эту тройку; исходные id/seq и адрес приложения хранятся +в `icmp_request`. Разные клиенты с одинаковыми исходными id/seq не смешиваются. -### Нюансы -- Запросы истекают через 5 секунд (`ICMP_TIMEOUT_TB`). Таймер `expire_timer` запускается - при добавлении первого запроса и перезапускается после каждой очистки. -- Неопознанные echo-ответы (не найденные в pending) логируются как `DEBUG_WARN` и игнорируются. -- Контрольная сумма ICMP пересчитывается на exit (при отправке) и на клиенте (при сборке ответа). -- Поле `orig_src_ip` в протоколе нужно для восстановления исходного dst_ip на клиенте. +Raw reader проверяет IPv4 IHL/протокол, Echo Reply type/code и ICMP checksum, ищет pending +по wire id/seq и адресу отправителя, удаляет найденный запрос и отправляет REPLY ingress-узлу. +Данные ответа берутся из raw пакета. Сохранённый в request префикс payload до 1500 байт +для формирования ответа не используется; он не ограничивает длину реального echo payload. -## 3. API +Таймер запускается после первого отправленного запроса, затем каждые 5 с, пока pending +не пуст. Удаляет записи с возрастом **больше** 5 с на очередном обходе; удаление не происходит +точно в момент достижения порога. `destroy` снимает binding, отменяет таймер, закрывает +raw socket/регистрацию, освобождает pending и обнуляет `inst->icmp_proxy`. +Все действия — в uasync; `inst` и `ua` заимствуются до destroy. -### Структуры -| Структура | Назначение | -|-----------|-----------| -| `struct icmp_request` | Состояние активного echo-запроса на exit: client_node_id, dst_ip, orig_src_ip, echo_id, echo_seq, payload, время отправки. Связный список. | -| `struct icmp_proxy_ctx` | Глобальный контекст: is_exit, inst, ua, raw_sock, raw_read_id, список pending, таймаут запросов, флаг test_loopback, таймер очистки. | +## Сообщения и доставка -### Формат пакета -| Смещение | Размер | Поле | -|----------|--------|------| -| 0 | 1 | `svc_id` = `ETCP_RT_ID_ICMP_PROXY` | -| 1 | 1 | `subcmd` = REQUEST (0x01) / REPLY (0x02) | -| 2 | 8 | `sender_node_id` | -| 10 | 4 | `dst_ip` (IP цели для ping) | -| 14 | 4 | `orig_src_ip` (IP исходного отправителя для обратной доставки) | -| 18 | 2 | `echo_id` (icmp_id) | -| 20 | 2 | `echo_seq` (icmp_seq) | -| 22 | N | `payload` (данные ICMP echo) | +Вход `etcp_route_send`: +`svc:1, cmd:1, dst_ip:4, orig_src_ip:4, echo_id:2, echo_seq:2, payload`. +REQUEST=1, REPLY=2, заголовок отправки — 14 байт. В REPLY `dst_ip` остаётся адресом +цели ping, `orig_src_ip` — адресом приложения. Router callback получает +26-байтовые метаданные перед cmd, итого заголовок приёма — 39 байт. +Peer находится в метаданных router; отдельного sender ID в ICMP payload нет. +IP/id/seq передаются в network byte order. -### Функции -| Функция | Назначение | -|---------|-----------| -| `icmp_proxy_init(inst, ua)` | Выделяет контекст, определяет is_exit, на exit создаёт SOCK_RAW и регистрирует его в uasync. Регистрирует коллбэк в etcp_router. | -| `icmp_proxy_destroy(inst)` | Закрывает raw-сокет, отменяет таймер, освобождает pending, отвязывает коллбэк, освобождает память. | -| `icmp_proxy_recv_cb(conn, entry)` | Единый коллбэк etcp_router. По subcmd направляет REQUEST → `exit_handle_request`, REPLY → `client_handle_reply`. | -| `icmp_proxy_send_to_exit(inst, ...)` | Клиент: упаковывает ICMP Echo Request и отправляет через etcp_route_send на exit. | -| `icmp_proxy_deliver_reply(inst, ...)` | Клиент: собирает IP/ICMP Echo Reply-пакет и помещает в TUN input_queue. | -| `icmp_proxy_set_test_loopback(inst, enabled)` | Включает/выключает тестовый loopback-режим на exit (эмуляция echo-ответа без raw-сокета). | +`send_to_exit` копирует payload 0..65507 и возвращает результат router +(0 = принято в очередь, <0 = ошибка). Локальный ICMP-контекст для этой отправки не требуется. +Используются `force=0, mode=0`, собственного повтора REQUEST/REPLY нет. + +Клиент допускает REPLY только от `tcp_proxy_client->via_node_id`. +`deliver_reply(inst, dst_ip, src_ip, ...)` требует клиентский TUN, допускает payload +0..65506, собирает IPv4 `src_ip→dst_ip` с Echo Reply, TTL 64 и обеими checksum. +Нечётный хвост checksum дополняется нулём без чтения за границей. +Результат с префиксом версии 4 ставится в `tun->input_queue`: +0 = поставлен в очередь, -1 = ошибка. Payload заимствован на время вызова. +`recv_cb` принимает владение входным entry/dgram при любом исходе. diff --git a/src/proxy/udp_proxy_doc.md b/src/proxy/udp_proxy_doc.md index 133e19a5..160aab94 100644 --- a/src/proxy/udp_proxy_doc.md +++ b/src/proxy/udp_proxy_doc.md @@ -1,73 +1,48 @@ -# UDP Proxy (udp_proxy) +# UDP proxy -## 1. Назначение -Проксирование UDP-датаграмм между клиентским и exit-узлом по протоколу ETCP. Клиентский узел -принимает UDP-пакеты с TUN-интерфейса, инкапсулирует их и отправляет через `etcp_route_send` -на exit-узел. Exit-узел создаёт нативные UDP-сокеты для каждого потока (5-tuple) и ретранслирует -датаграммы во внешнюю сеть. Ответы проходят обратный путь — от exit к клиенту и затем в TUN. +`udp_proxy.c/h` передаёт UDP между TUN клиента и обычными UDP-сокетами exit через +`ETCP_RT_ID_UDP_PROXY` в `TOPO_GROUP_UTUN`. Общие форматы и ограничения: +[doc/proxy_protocol.md](../../doc/proxy_protocol.md). Контракты API и полей — в +[udp_proxy.h](udp_proxy.h). -Один экземпляр контекста на процесс (глобальный `g_udp_ctx`), режим определяется флагом -`inst->tcp_proxy_server.enabled`: включён → `is_exit=1`, иначе `is_exit=0`. +## Контекст и жизненный цикл -## 2. Как пользоваться +Контекст принадлежит `inst->udp_proxy`, глобального контекста нет. `init(inst, ua)` +создаёт его и запрашивает router binding, повторный init возвращает 0, сохраняя контекст. +`is_exit` — снимок роли при init, но диспетчер проверяет текущее +`inst->tcp_proxy_server.enabled` для REQUEST. REPLY принимается только при наличии +`tcp_proxy_client` и совпадении peer с его `via_node_id`. Один instance может совмещать роли. -### Инициализация -```c -udp_proxy_init(inst, ua); // exit или клиент — определяется автоматически -``` -При инициализации регистрируется коллбэк `udp_proxy_recv_cb` в `etcp_router` для сервиса -`ETCP_RT_ID_UDP_PROXY`. +Exit индексирует flows линейным списком по +`client_node_id + src_ip:src_port + dst_ip:dst_port`. У каждого flow собственный +неблокирующий `AF_INET/SOCK_DGRAM`, привязанный к `INADDR_ANY:0`, и read callback в uasync. +Ответ принимается только от исходного `dst_ip:dst_port`; в REPLY адреса и порты меняются местами. +REQUEST и принятый ответ обновляют `last_activity_tb`. -### Клиент: отправка датаграммы в exit -```c -udp_proxy_send_to_exit(inst, exit_node_id, src_ip, src_port, dst_ip, dst_port, payload, len); -``` -Вызывается из обработчика TUN-трафика. Пакет упаковывается в формат ETCP и отправляется -через `etcp_route_send`. Ответ приходит асинхронно в `udp_proxy_recv_cb`, который вызывает -`udp_proxy_deliver_reply` для записи IP/UDP-пакета обратно в TUN. +Таймер запускается при первом flow и проверяет список каждые 600000 tb (60 с), +пока flows остаются. Удаляет flow при неактивности **больше** 60 с; удаление происходит +на очередном обходе, а не точно в момент достижения порога. `destroy` снимает binding, +отменяет таймер, закрывает сокеты/регистрации и обнуляет `inst->udp_proxy`. +Все операции выполняются в uasync; `inst` и `ua` заимствуются до destroy. -### Exit: приём датаграмм -Exit автоматически принимает REQUEST-ы через `etcp_router`. Для каждого нового потока -(уникальный `client_node_id + src_ip:port + dst_ip:port`) создаётся нативный UDP-сокет, -который регистрируется в `uasync` на чтение. При получении ответа от внешнего хоста -ответ упаковывается и отправляется обратно клиенту. +## Сообщения и доставка -### Очистка -```c -udp_proxy_destroy(inst); // закрывает все flows, отменяет таймер, unbind из etcp_router -``` +На вход `etcp_route_send` передаётся: +`svc:1, cmd:1, src_ip:4, src_port:2, dst_ip:4, dst_port:2, payload`. +REQUEST=1, REPLY=2. Заголовок отправки — 14 байт. Router callback получает 26 байт метаданных +router перед подкомандой, поэтому заголовок приёма — 39 байт. Отдельного `sender_node_id` +в UDP payload нет: peer берётся из метаданных router. Адреса и порты — network byte order. -### Нюансы -- Потоки истекают через 60 секунд неактивности (`UDP_FLOW_TIMEOUT_TB`). Таймер `expire_timer` - запускается при создании первого потока, перезапускается после каждой очистки. -- UDP-сокеты на exit привязываются к `INADDR_ANY:0` (bind), трафик отправляется через `sendto`. -- Ответы в TUN на клиенте доставляются через `inst->tcp_proxy_client->tun->input_queue`. +`send_to_exit` требует созданный UDP-контекст, копирует заимствованный payload +длиной 0..65507 и возвращает результат router: 0 означает принятие в очередь, +отрицательное значение — ошибку. Собственного окна/повтора UDP у модуля нет; +отправка использует `force=0, mode=0`. -## 3. API +`deliver_reply` требует `inst->tcp_proxy_client->tun`, допускает payload 0..65506, +собирает IPv4/UDP с TTL 64, пересчитывает IPv4 checksum и оставляет UDP checksum=0. +Пакет получает однобайтовый префикс версии 4 и ставится в `tun->input_queue`. +Возврат 0 означает постановку в очередь; -1 — отсутствие TUN, недопустимый размер +или ошибку выделения. Входной payload остаётся у вызывающего кода. -### Структуры -| Структура | Назначение | -|-----------|-----------| -| `struct udp_flow` | Состояние одного UDP-потока на exit: 5-tuple, нативный сокет, uasync read_id, времена создания и последней активности. Связный список. | -| `struct udp_proxy_ctx` | Глобальный контекст: флаг is_exit, inst, ua, список flows, таймаут истечения, таймер очистки. | - -### Формат пакета -| Смещение | Размер | Поле | -|----------|--------|------| -| 0 | 1 | `svc_id` = `ETCP_RT_ID_UDP_PROXY` | -| 1 | 1 | `subcmd` = `UDP_PROXY_SUBCMD_DATA` (0x01) | -| 2 | 8 | `sender_node_id` | -| 10 | 4 | `src_ip` (клиент→exit: IP отправителя; exit→клиент: dst_ip) | -| 14 | 2 | `src_port` | -| 16 | 4 | `dst_ip` (клиент→exit: IP назначения; exit→клиент: src_ip) | -| 20 | 2 | `dst_port` | -| 22 | N | `payload` (UDP-датаграмма) | - -### Функции -| Функция | Назначение | -|---------|-----------| -| `udp_proxy_init(inst, ua)` | Выделяет контекст, определяет is_exit, регистрирует коллбэк в etcp_router. Возвращает 0 при успехе. | -| `udp_proxy_destroy(inst)` | Закрывает все потоки, сокеты, отменяет таймеры, отвязывает коллбэк, освобождает память. | -| `udp_proxy_recv_cb(conn, entry)` | Единый коллбэк etcp_router. По subcmd направляет в exit_handle_data (exit) или client_handle_reply (клиент). | -| `udp_proxy_send_to_exit(inst, ...)` | Клиент: упаковывает UDP-датаграмму и отправляет через etcp_route_send на exit. | -| `udp_proxy_deliver_reply(inst, ...)` | Клиент: собирает IP/UDP-пакет и помещает в TUN input_queue для доставки приложению. | +`recv_cb` принимает владение entry/dgram при любом исходе. `init` не пробрасывает +ошибку router bind, поэтому 0 не подтверждает работоспособность регистрации. From f003aaa92d4f7383a5d2d9aa1e503d0a48e37217 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 12:36:16 +0200 Subject: [PATCH 04/25] docs(proxy): reconcile TCP client server and listener behavior --- src/proxy/socks_proxy_doc.md | 54 +++++-- src/proxy/tcp_proxy_client_doc.md | 253 ++++++++---------------------- src/proxy/tcp_proxy_server_doc.md | 158 ++++++++----------- 3 files changed, 174 insertions(+), 291 deletions(-) diff --git a/src/proxy/socks_proxy_doc.md b/src/proxy/socks_proxy_doc.md index 0ef99ccd..d1f794a4 100644 --- a/src/proxy/socks_proxy_doc.md +++ b/src/proxy/socks_proxy_doc.md @@ -22,10 +22,11 @@ struct listen_ctx* listen = socks_proxy_init_listen( `is_http=0` — SOCKS5, `is_http=1` — HTTP CONNECT/прокси. ### Приём ETCP-данных от exit node -Внешний dispatcher (`tcp_proxy_client`) вызывает `socks_proxy_handle_etcp()` при получении ETCP-пакетов с `svc_id=ETCP_RT_ID_TCP_PROXY`: +Внешний dispatcher (`tcp_proxy_client`) проверяет peer/group и вызывает `socks_proxy_handle_etcp()` +при получении пакетов `ETCP_RT_ID_TCP_PROXY_CLIENT` (0x08). Запросы уходят в `ETCP_RT_ID_TCP_PROXY_SERVER` (0x04): ```c int handled = socks_proxy_handle_etcp(&conns, &conn_count, stream_id, subcmd, data, data_len); -// handled=1 — найден и обработан, 0 — stream_id не найден +// handled=1 — stream_id найден (включая игнорирование команды), 0 — stream_id не найден ``` Поддерживаемые subcmd: `CONNECTED`, `DATA`, `WINDOW`, `CLOSE`, `ERROR`, `FIN`. @@ -37,12 +38,17 @@ struct socks_proxy_conn* c = socks_proxy_find_conn(conns, stream_id); ### Завершение ```c -socks_proxy_conn_free_all(&conns, &conn_count); // все соединения socks_proxy_close_listen(ua, listen, &sock_out); // слушатель +socks_proxy_conn_free_all(&conns, &conn_count); // все соединения ``` +`inst/ua/conns/count/next_stream_id` заимствуются и должны жить до закрытия listener и всех клиентов. +Закрытие listener сохраняет активных клиентов. Вызовы и callbacks выполняются в uasync владельца. +Адрес listener — IPv4:port; порт 0 выбирает ОС, фактический порт возвращает `socks_proxy_get_port()`. ### Ключевые нюансы -- **Бэкпрессур (backpressure):** при неудаче `etcp_route_send()` данные сохраняются в `tx_buf/tx_len`, и регистрируется waiter через `etcp_router_on_send_ready()`. Когда ETCP готов к отправке, `tx_waiter_cb` повторяет попытку и возобновляет `read_queue`. +- **Backpressure:** DATA ждёт CONNECTED и кредита `proxy_flow`. Неотправленные байты сохраняются + в `tx_buf/tx_len` и отправляются порциями до 4096. При доступном окне ставится router send-ready + waiter; safety-net 500 мс возобновляет отправку с обычным force=0. Чтение tcp_io ограничено его очередью. - **Несколько соединений:** управляются через связный список `struct socks_proxy_conn*` + счётчик. В HTTP keep-alive каждое сообщение получает новый upstream stream; клиентский TCP-сокет сохраняется. Pipelining обрабатывается последовательно, включая запросы к разным адресатам. - **stream_id** генерируется монотонно (`++next_stream_id`), передаётся в заголовке ETCP-пакетов для мультиплексирования. @@ -71,31 +77,32 @@ socks_proxy_close_listen(ua, listen, &sock_out); // слушатель | Функция | Описание | |---------|----------| -| `socks_proxy_init_listen(ua, addr, conns, count, next_sid, inst, via_node, is_http)` | Создаёт слушающий TCP-сокет на `addr` (IP:PORT). Возвращает `listen_ctx*`. | +| `socks_proxy_init_listen(ua, addr, conns, count, next_sid, inst, via_node, is_http)` | Создаёт IPv4 listener, возвращает собственный ctx или NULL. Не регистрирует router callback. | | `socks_proxy_close_listen(ua, ctx, sock_out)` | Закрывает слушающий сокет, освобождает ctx. | +| `socks_proxy_get_port(ctx)` | Фактический порт listener, 0 при ошибке. | | `socks_proxy_handle_etcp(head, count, stream_id, subcmd, data, len)` | Диспетчер ETCP-сообщений от exit node. Обрабатывает DATA (→write_queue клиенту), CLOSE/ERROR (→push_close), FIN (→push_fin). | | `socks_proxy_find_conn(head, stream_id)` | Поиск соединения по stream_id в связном списке. | -| `socks_proxy_conn_free(c)` | Освобождает соединение: отправляет отложенный CLOSE/ERROR в ETCP, удаляет из списка, освобождает `tcp_conn`, buffer, waiter. | +| `socks_proxy_conn_free(c)` | Удаляет из списка, отменяет DNS/control retry/DATA timer/waiter, освобождает TCP/HTTP/buffers. Активный HTTP upstream отменяется; pending CLOSE/ERROR получает последнюю попытку. | | `socks_proxy_conn_free_all(head, count)` | Освобождает все соединения в списке. | ### Внутренние функции | Функция | Описание | |---------|----------| -| `send_msg(inst, dst, subcmd, sid, data, len, force)` | Формирует ETCP-пакет: `[TCP_PROXY_HDR_SIZE=6] svc_id(1)+subcmd(1)+stream_id(4)` + payload, отправляет через `etcp_route_send`. | +| `send_msg(inst, group_id, dst, subcmd, sid, data, len, force)` | Через общий proxy_send копирует payload и отправляет в TOPO_GROUP_UTUN; group_id аргумент игнорируется, mode=0. | | `send_connect(c)` | Отправляет CONNECT (dest_ip[4]+dest_port[2]) на exit node. | | `send_data(c, data, len)` | Отправляет DATA (релей пользовательского трафика). | -| `send_close(c)`, `send_error(c)`, `send_fin(c)` | Отправляют управляющие subcmd. | +| `send_close(c)` | Попытка CLOSE с force=1; pending повторяется при free. ERROR отправляется через send_msg, FIN — через proxy_flow_flush. | | `write_to_client(c, data, len)` | Запись данных в `tc->write_queue` (отправка клиенту через TCP). | | `process_socks_greeting(c)` | Парсинг SOCKS5 GREETING: ver=5, выбор метода 0x00 (no auth), ответ 05 00. | -| `process_socks_request(c)` | Парсинг SOCKS5 CONNECT REQUEST (atyp=1/3/4, IPv4/IPv6/domain). DNS-резолвинг для доменов. Ответ 05 00 00 01 + dummy addr. | +| `process_socks_request(c)` | CONNECT к IPv4 или DNS-имени; IPv6 отклоняется с REP=8. Успешный ответ отправляет socks_connected только после CONNECTED. | | `http_read(c)`, `http_receive(c)` | Потоковый разбор клиентского запроса и upstream ответа через HTTP codec. | | `http_finish(c)` | Завершение/отмена upstream, ожидание доставки ответа клиенту и переход к следующему запросу. | | `http_fail(c)`, `http_deadline(c)` | Ошибки и тайм-ауты: собственный HTTP error до final headers, закрытие оборванного сообщения после них. | | `on_accept_cb(sock, arg)` | Callback accept: создаёт `socks_proxy_conn`, `tcp_conn_create`, вешает read callback. | | `on_read_cb(q, arg)` | Callback чтения из TCP: handshake-парсинг или релей данных через ETCP с бэкпрессуром. | | `tx_waiter_cb(q, arg)` | Callback бэкпрессура: повторная отправка отложенных данных при готовности ETCP. | -| `on_fin_cb`, `on_error_cb`, `on_closed_cb` | Callback'и жизненного цикла `tcp_conn`: FIN→удалённый FIN, ошибка→send_error+free, закрыт→send_close+free. | +| `on_fin_cb`, `on_error_cb`, `on_closed_cb` | FIN ждёт исходящих DATA; ошибка отправляет ERROR, cleanup из TCP callbacks откладывается через call_soon. HTTP вне туннеля использует границы транзакций. | ## 4. Протокол @@ -104,8 +111,9 @@ socks_proxy_close_listen(ua, listen, &sock_out); // слушатель Клиент → uTun: [ver=5][nmethods][methods...] uTun → Клиент: [05][00] (GREETING: no auth) Клиент → uTun: [05][01][00][atyp][addr...][port] (CONNECT REQUEST) -uTun → Клиент: [05][00][00][01][0.0.0.0][0] (ответ ок) uTun → exit (ETCP): TCP_PROXY CONNECT[dest_ip+port] +exit → uTun (ETCP): CONNECTED (реальный connect завершён) +uTun → Клиент: [05][00][00][01][0.0.0.0][0] (ответ ок) Клиент ↔ uTun ↔ exit: RELAY данных через ETCP DATA ``` @@ -121,10 +129,12 @@ uTun → Клиент: HTTP/1.1 200 Connection Established\r\n\r\n ### HTTP-прокси (GET/POST, is_http=1) ``` Клиент → uTun: GET http://host:port/path HTTP/1.1\r\nHost: ...\r\n\r\n -uTun → exit (ETCP): TCP_PROXY CONNECT[dest_ip+port] + TCP_PROXY DATA[GET /path HTTP/1.1\r\n...] +uTun → exit (ETCP): TCP_PROXY CONNECT[dest_ip+port] +exit → uTun (ETCP): CONNECTED +uTun → exit (ETCP): TCP_PROXY DATA[GET /path HTTP/1.1\r\n...] exit → uTun: HTTP headers/body через ETCP DATA, затем FIN uTun → Клиент: проверенный и реконструированный HTTP response -uTun → exit (ETCP): CLOSE завершённого stream +uTun → exit (ETCP): CLOSE завершённого stream (после полного ответа, FIN нужен для тела до EOF) Клиент → uTun: следующий запрос → новый CONNECT с новым stream_id ``` URL преобразуется в origin-form без потери query; Host строится из absolute URL. Заголовки текущего соединения, включая поля из Connection, и Proxy-Authorization удаляются в обоих направлениях. Framing и Connection формируются заново, добавляется Via. Upstream обычного запроса получает Connection: close; frontend HTTP/1.1 сохраняет keep-alive для ответов с определённой длиной. @@ -139,12 +149,26 @@ ETCP credit возвращается за исходные принятые ба ERROR на exit немедленно уничтожает сокет и отложенный upload; CLOSE сохраняет обычное завершение очереди. +SOCKS greeting принимает только предложенный метод 0 (no auth); иначе отправляется 05 FF. +Реализован только CONNECT, без BIND/UDP ASSOCIATE. DNS выполняется на ingress через +асинхронный A-resolver, выбирается первый адрес. Хвост совмещённых greeting/request/data сохраняется. +Ошибка DNS/connect до установления потока даёт SOCKS REP=4 либо HTTP 502. + +В сыром туннеле FIN локального клиента ждёт tx_buf и read_queue, но не write_queue +противоположного направления. FIN от exit ставится в write_queue клиента после предыдущих DATA. +После обоих FIN и фактического локального shutdown ставится close-маркер. WINDOW возвращает +кредит после опустошения write_queue и write_buf. Общий retry повторяет CONNECTED/WINDOW/FIN, +отдельной гарантированной очереди CLOSE/ERROR у listener нет. + ## 5. Зависимости -- `tcp_proxy_server.h` — константы subcmd (`TCP_PROXY_SUBCMD_*`) и размер заголовка (`TCP_PROXY_HDR_SIZE=6`) -- `etcp.h` / `etcp_api.h` — `etcp_route_send()`, `ETCP_RT_ID_TCP_PROXY` +- `proxy_protocol.h` — subcmd, размеры заголовков, proxy_flow и proxy_send +- `etcp_api.h` — `ETCP_RT_ID_TCP_PROXY_SERVER/CLIENT` - `etcp_router.h` — `etcp_router_on_send_ready()`, `etcp_router_cancel_send_ready()` - `tcp_io.h` / `tcp_conn` — TCP-соединения (`tcp_conn_create`, `tcp_conn_push_close`, `tcp_conn_push_fin`, write/read очереди) - `u_async.h` — event loop, `uasync_add_socket_t` - `ll_queue.h` — очереди (write_queue, read_queue), waiter механизм - `memory_pool.h` — аллокация из пулов (`entry_pool`, `data_pool`) - `utun_instance.h` — `struct UTUN_INSTANCE` + +Нормативные размеры сообщений и порядок байтов — в [proxy_protocol.md](../../doc/proxy_protocol.md). +Контракты чистого HTTP codec (обнуление состояния, emit/source_len, used и EOF) — в [http_proxy.h](http_proxy.h). diff --git a/src/proxy/tcp_proxy_client_doc.md b/src/proxy/tcp_proxy_client_doc.md index caef21de..90f5805d 100644 --- a/src/proxy/tcp_proxy_client_doc.md +++ b/src/proxy/tcp_proxy_client_doc.md @@ -1,204 +1,87 @@ -# tcp_proxy_client +# TCP proxy client -## 1. Назначение +`tcp_proxy_client.c/h` объединяет TUN/lwIP, SOCKS5 CONNECT и HTTP forward proxy/CONNECT, +передавая потоки к общему exit `via_node_id`. Форматы и общие правила — +[doc/proxy_protocol.md](../../doc/proxy_protocol.md), контракт API и полей — +[tcp_proxy_client.h](tcp_proxy_client.h), локальные listeners — +[socks_proxy_doc.md](socks_proxy_doc.md). -Клиентский оркестратор TCP прокси. Создаёт TUN-интерфейс, запускает встроенный lwIP TCP стек, перехватывает исходящие TCP-соединения со стороны клиента и туннелирует их через ETCP к удалённому exit-узлу (tcp_proxy_server). Поддерживает три режима перехвата трафика одновременно: +## Создание и владение -1. **Прозрачное проксирование через TUN** — весь TCP-трафик с TUN-интерфейса попадает в lwIP, который управляет реальными TCP-сессиями. Исходящие соединения прозрачно проксируются на exit-узел. UDP (через `udp_proxy`) и ICMP Echo (через `icmp_proxy`) также пробрасываются. -2. **SOCKS5 proxy listener** — встроенный SOCKS5-сервер, принимающий локальные подключения и туннелирующий их через ETCP. -3. **HTTP CONNECT proxy listener** — встроенный HTTP CONNECT-прокси (работает через тот же механизм SOCKS, что и SOCKS5). +`tcp_proxy_client_create` вызывается в uasync с живым `ua`. TUN/lwIP создаются только +при **обоих** непустых `tun_name` и `tun_ip`. В этом случае `tun_init_nat` создаёт +отдельный proxy TUN (test_mode выбирает тестовый интерфейс); MTU<=0 заменяется на 1500, +MTU TUN передаётся в lwIP. Имя tun_init_nat не означает включение eim_nat на ingress. +Без TUN необходим хотя бы флаг SOCKS или HTTP; эти listeners могут работать без lwIP. -Модуль полностью оркестрирует весь TCP-прокси-пайплайн на стороне клиента: захват трафика → lwIP TCP стек → ETCP-туннелирование → приём ответных данных → отдача в lwIP → запись IP-пакетов обратно в TUN. +`inst/ua/mappings` заимствуются до destroy; результат принадлежит вызывающему коду. +Создание само не заполняет `inst->tcp_proxy_client`: это делает владелец, например +`utun_instance`. Для автономного TUN smoke test допускается inst=NULL, но для +обмена через router нужен instance. Mappings содержат порты в host byte order, +адрес назначения — IPv4-строку. -## 2. Как пользоваться +Критическая ошибка TUN/lwIP/пула возвращает NULL. Ошибка отдельного listener или +router bind не отменяет создание всего клиента; проверять конкретные listener handles +и диагностику. UDP/ICMP инициализируются только для TUN после успешного TCP bind. -### Типовой сценарий +Клиент привязывается к `ETCP_RT_ID_TCP_PROXY_CLIENT` (0x08), сервер — к +`ETCP_RT_ID_TCP_PROXY_SERVER` (0x04). Генератор stream_id общий для TUN/SOCKS/HTTP, +изначально равен 1, перед выдачей инкрементируется. На exit ключ потока — пара peer/sid. -``` -tcp_proxy_client_create(inst, ua, tun_name, tun_ip, mtu, test_mode, - mappings, mapping_count, via_node_id, - socks_enabled, socks_addr, http_proxy_enabled, http_proxy_addr) -→ TUN init (tun_init_nat) — создание TUN-интерфейса с NAT -→ lwIP init (lwip_tcp_init) — запуск встроенного TCP стека -→ Настройка port mappings — прослушивание локальных портов в lwIP -→ SOCKS5/HTTP listen — запуск локальных прокси-слушателей -→ ETCP router bind — регистрация коллбэка tcp_proxy_client_router_recv_cb -→ UDP/ICMP proxy init -``` +## TUN → exit -### Ключевые концепции +Output queue TUN содержит однобайтовый префикс перед IP-пакетом. Обработчик проверяет +IPv4 version/IHL/total length и отвергает фрагменты (MF/offset) с WARN. +Для UDP и ICMP Echo type 8 учитывается IHL, затем вызывается соответствующий proxy. +TCP без IP-заголовка передаётся в `lwip_tcp_input`; перед этим по порту назначения +при необходимости создаётся динамический listen PCB. -**TUN interface** через `tun_init_nat()`. IP-пакеты из output_queue TUN направляются в `tcp_proxy_client_tun_input()` — главную точку входа inbound-трафика. +При accept выбирается первый mapping с совпадающим локальным портом; если его нет — +исходные `pcb->local_ip/local_port`. Создаются `tcp_proxy_client_conn`, `proxy_flow` +и очереди `tx_queue` (lwIP→exit) и `to_lwip` (exit→lwIP), отправляется CONNECT. +Локальный TCP handshake завершает lwIP; его успех не подтверждает connect назначения. +DATA ожидает CONNECTED от exit. -**Встроенный lwIP TCP стек** (`lwip_tcp_init()`) с output-коллбэком `tcp_proxy_client_output_cb`, который пишет готовые IP-пакеты обратно в TUN (в сторону ОС клиента). +Recv callback копирует pbuf в блоки до 4096 байт в tx_queue. Drain отправляет через +`proxy_flow_send`; `tcp_recved` возвращает окно локальному клиенту только после +успешного принятия DATA router. При -2 ожидаются CONNECTED/WINDOW; при другой ошибке +регистрируется router send-ready waiter. Safety-net каждые 500 мс повторяет отправку +с force=1, сохраняя ограничение окна proxy. Неотправленный entry возвращается в начало очереди. -**Inbound TUN трафик** (`tcp_proxy_client_tun_input()`): парсинг IP-пакета → по протоколу: -- TCP → вызов `lwip_tcp_input()` — пакет попадает в lwIP, где срабатывает `accept_cb` для новых соединений или `recv_cb` для существующих -- UDP → `udp_proxy_send_to_exit()` — проброс UDP в exit -- ICMP (type 8/Echo) → `icmp_proxy_send_to_exit()` — проброс ping в exit +## Exit → TUN -**Connection accept** (`tcp_proxy_client_accept_cb`): при новом входящем TCP-соединении в lwIP: -1. Выделяется `struct tcp_proxy_client_conn` -2. Определяется destination: поиск по port mapping config `mappings[]` (local_port → remote_ip:remote_port), иначе `local_ip:local_port` самого pcb -3. Назначается `stream_id` (инкремент `next_stream_id`) -4. Регистрируются lwIP коллбэки: `recv`, `sent`, `err`, `poll` -5. Создаётся очередь `to_lwip` для данных от exit -6. Отправляется ETCP CONNECT с dest_ip+dest_port через `etcp_route_send(force=1)` +Router callback проверяет peer==via_node_id и group==TOPO_GROUP_UTUN, включая пустые +уведомления router. Сначала предлагает stream SOCKS/HTTP спискам, затем ищет lwIP-поток. -**Client-side lwIP** — чтение данных (`tcp_proxy_client_recv_cb`): -- При получении данных от lwIP (клиент что-то отправляет): копируем pbuf → `tcp_proxy_client_send_data()` → ETCP DATA -- При backpressure (router send_q переполнен): сохраняем в `tx_buf`, регистрируем waiter через `etcp_router_on_send_ready()`. При освобождении очереди `tcp_proxy_client_tx_waiter_cb` досылает данные и вызывает `tcp_recved()` для window update -- При p==NULL (FIN от lwIP): выставляется `fin_local`, отправляется ETCP FIN. Если обе стороны закрылись — ETCP CLOSE +CONNECTED открывает `flow.ready`; WINDOW проверяет и увеличивает кредит, возобновляя +очереди. DATA после проверки окна помещается в to_lwip. `feed_from_transport` пишет +часть entry по доступному `tcp_sndbuf` с `TCP_WRITE_FLAG_COPY`, оставляя хвост в очереди. +Кредит возвращается после успешного `tcp_write`, затем вызывается `tcp_output`. +Дальнейший feed запускают DATA, WINDOW/wake, sent и poll callbacks. +Выходные IPv4-пакеты lwIP записываются через `tun_write`. -**Exit-side data** — приём ответных данных (`tcp_proxy_client_handle_data()`): -- Данные от exit помещаются в очередь `to_lwip` -- `tcp_proxy_client_feed_from_transport()`: пока есть место в `tcp_sndbuf(pcb)`, читает из `to_lwip` и пишет через `tcp_write()` в lwIP. После записи вызывает `tcp_output()` для отправки -- Trigger на feed: при получении DATA, при `sent_cb` (lwIP освободил send buffer) +## Завершение -**Full lifecycle:** -1. CONNECT — установка соединения с exit -2. DATA ↔ DATA — двунаправленный обмен данными -3. FIN (от клиента или exit) — half-close: `tcp_shutdown(pcb, 0, 1)` (закрытие только send-стороны) -4. CLOSE — полное закрытие после обоих FIN, очистка `tcp_proxy_client_conn_free()` -5. ERROR — аварийное закрытие (ошибка lwIP или exit), немедленная очистка +Локальный FIN устанавливает fin_local. FIN к exit ставится только после опустошения +tx_queue; общий control retry ждёт ready и повторяет неудавшуюся отправку. +Удалённый FIN устанавливает fin_remote/flow.fin_received и fin_deferred. +После дренажа to_lwip выполняется `tcp_shutdown(pcb, 0, 1)`; при неудаче попытка повторяется. -**Per-port mapping конфигурация** (`tcp_proxy_client_mapping_config`): -```c -struct tcp_proxy_client_mapping_config { - uint16_t local_port; // локальный порт для прослушивания - char remote_ip[64]; // IP назначения на exit-узле - uint16_t remote_port; // порт назначения на exit-узле -}; -``` -При старте для каждого mapping создаётся listen pcb в lwIP. Также работает динамический listen для любых исходящих портов (`tcp_proxy_client_ensure_outbound_listen()`). +Когда обе половины завершены, PCB отсоединяется от callbacks: lwIP может самостоятельно +освободить его по TIME_WAIT без err callback. Поток удаляется после отправленного FIN, +полученного FIN, снятого fin_deferred и пустых tx_queue/to_lwip. Штатное завершение +не требует дополнительного обмена CLOSE. -**SOCKS5/HTTP proxy listeners** — используют `socks_proxy_init_listen()` (общий код с SOCKS, флаг `is_http` различает режимы). SOCKS/HTTP обрабатываются приоритетно в `router_recv_cb` — им не нужен TUN/lwIP. +Входящий CLOSE прекращает поток через `tcp_close` и очистку, ERROR вызывает abort. +Ошибка lwIP обнуляет PCB, который уже освобождает стек; остальная очистка отменяет +control retry, DATA timer, router waiter и освобождает обе очереди. +CLOSE/ERROR при неудаче отправки могут повторяться через close_pending в poll/free +пока поток жив; отдельной сохраняемой очереди этих команд нет. -### Протокол обмена с exit-узлом +Пустое уведомление router (`ROUTER_SVC_HDR_SIZE=26`, без TCP command/sid) удаляет +все lwIP/SOCKS/HTTP потоки клиента к настроенному exit. Это не 9-байтовый wire CLOSE_ALL. -Формат сообщения: `svc_id(1) + subcmd(1) + stream_id(4) + [data]` (заголовок `TCP_PROXY_HDR_SIZE = 6` байт). - -| Subcmd | Код | Направление | Описание | -|--------|-----|-------------|----------| -| CONNECT | 0x01 | client→exit | Установка TCP-соединения с destination | -| DATA | 0x03 | ↔ | Данные TCP-потока | -| CLOSE | 0x04 | ↔ | Полное закрытие потока | -| ERROR | 0x05 | ↔ | Ошибка, немедленная очистка | -| FIN | 0x06 | ↔ | Half-close (shutdown write) | - -Специальное сообщение: `svc_id(1) + peer_id(8)` (9 байт) — CLOSE_ALL: очистка всех соединений к указанному peer. - -### Механизмы надёжности - -- **Backpressure на router send_q**: `etcp_router_on_send_ready()` — если `etcp_route_send()` не может отправить (inflight полон), данные буферизуются в `tx_buf` и досылаются при освобождении -- **Повтор CLOSE/ERROR**: если отправка CLOSE/ERROR не удалась, выставляется `close_pending=1`. В `poll_cb` повторяется попытка -- **Очередь to_lwip**: буферизует данные от exit до готовности lwIP send buffer, обеспечивая window-based flow control - -## 3. API - -### Структуры - -#### `struct tcp_proxy_client_conn` — одно проксируемое TCP-соединение -| Поле | Тип | Назначение | -|------|-----|------------| -| `next` | `*` | Связный список всех соединений | -| `proxy` | `*tcp_proxy_client` | Обратный указатель на родительский прокси | -| `pcb` | `*tcp_pcb` | lwIP protocol control block для этого соединения | -| `stream_id` | `uint32_t` | Уникальный идентификатор потока в протоколе ETCP | -| `to_lwip` | `*ll_queue` | Очередь данных от exit → lwIP (flow control) | -| `fin_local` | `uint8_t` | Локальная сторона (lwIP) отправила FIN | -| `fin_remote` | `uint8_t` | Удалённая сторона (exit) отправила FIN | -| `rem_closed` | `uint8_t` | Exit прислал CLOSE — прекращаем обработку | -| `error` | `uint8_t` | Ошибка на любом конце — немедленная очистка | -| `close_sent` | `uint8_t` | CLOSE/ERROR отправлен на exit | -| `close_pending` | `uint8_t` | CLOSE/ERROR не доставлен, ждём повтора в poll | -| `tx_buf` | `*uint8_t` | Буфер при backpressure (lwIP→ETCP send fail) | -| `tx_len` | `uint16_t` | Длина данных в tx_buf | -| `tx_waiter` | `queue_waiter_handle` | Дескриптор waiter для backpressure | -| `dest_ip` | `uint8_t[4]` | IP назначения на exit-узле | -| `dest_port` | `uint16_t` | Порт назначения на exit-узле (network byte order) | - -#### `struct tcp_proxy_client` — состояние прокси-клиента -| Поле | Тип | Назначение | -|------|-----|------------| -| `inst` | `*UTUN_INSTANCE` | Родительский инстанс uTun | -| `ua` | `*UASYNC` | Асинхронный event loop | -| `tun` | `*tun_if` | TUN-интерфейс для захвата/инжекции трафика | -| `conns` | `*tcp_proxy_client_conn` | Голова связного списка lwIP-соединений | -| `conn_count` | `int` | Количество активных lwIP-соединений | -| `entry_pool` | `*memory_pool` | Пул для `struct ll_entry` (данные to_lwip) | -| `next_stream_id` | `uint32_t` | Счётчик stream_id (начинается с 1) | -| `via_node_id` | `uint64_t` | Node ID exit-узла для маршрутизации ETCP | -| `lwip` | `*lwip_tcp_ctx` | Контекст встроенного lwIP TCP стека | -| `mappings` | `*mapping_config` | Указатель на массив port mapping конфигурации | -| `mapping_count` | `int` | Количество mapping записей | -| `socks_enabled` | `int` | Флаг включения SOCKS5 прокси | -| `socks_conns` | `*socks_proxy_conn` | Список активных SOCKS5-соединений | -| `socks_conn_count` | `int` | Количество SOCKS5-соединений | -| `socks_listen` | `*listen_ctx` | Слушающий сокет SOCKS5 | -| `http_proxy_enabled` | `int` | Флаг включения HTTP CONNECT прокси | -| `http_conns` | `*socks_proxy_conn` | Список активных HTTP CONNECT соединений | -| `http_conn_count` | `int` | Количество HTTP CONNECT соединений | -| `http_listen` | `*listen_ctx` | Слушающий сокет HTTP CONNECT | - -### Функции - -#### `tcp_proxy_client_create()` -Создаёт и инициализирует весь прокси-клиент. Параметры: инстанс uTun, uasync, имя/IP/MTU TUN-интерфейса, test_mode, массив port mapping, via_node_id, флаги/адреса SOCKS и HTTP прокси. Возвращает NULL при ошибке. - -#### `tcp_proxy_client_destroy()` -Полная очистка: отвязка от ETCP router, остановка UDP/ICMP прокси, закрытие SOCKS/HTTP listeners, освобождение всех соединений (lwIP abort + очистка очередей to_lwip + tx_buf + waiter cancel), уничтожение lwIP стека, закрытие TUN, освобождение memory pool. - -#### `tcp_proxy_client_router_recv_cb()` -Главный ETCP-коллбэк (зарегистрирован через `etcp_router_bind` на `ETCP_RT_ID_TCP_PROXY=0x04`). Обрабатывает входящие сообщения от exit-узла: -- **DATA** → `tcp_proxy_client_handle_data()` — данные в очередь to_lwip и feed в lwIP -- **CLOSE** → `tcp_proxy_client_handle_close()` — пометка rem_closed, закрытие lwIP pcb, освобождение соединения -- **ERROR** → `tcp_proxy_client_handle_error()` — аварийное закрытие lwIP pcb, освобождение соединения -- **FIN** → `tcp_proxy_client_handle_fin()` — half-close через tcp_shutdown(pcb, 0, 1) -- **CLOSE_ALL** (9-байтовый пакет без conn) → очистка всех соединений к peer -- Сначала пробует SOCKS/HTTP обработчики (приоритет — могут работать без TUN) - -### Внутренние функции - -#### lwIP коллбэки -- **`tcp_proxy_client_output_cb()`** — output lwIP: запись IP-пакетов в TUN (в сторону ОС клиента) -- **`tcp_proxy_client_accept_cb()`** — новый входящий TCP: создание `tcp_proxy_client_conn`, определение destination из mapping конфига, отправка ETCP CONNECT -- **`tcp_proxy_client_recv_cb()`** — данные от клиента (lwIP→ETCP): копирование pbuf → ETCP DATA с backpressure -- **`tcp_proxy_client_sent_cb()`** — lwIP освободил send buffer: вызов feed_from_transport для передачи накопленных данных -- **`tcp_proxy_client_err_cb()`** — ошибка lwIP: pcb уже уничтожен стеком, отправка ETCP ERROR -- **`tcp_proxy_client_poll_cb()`** — периодический poll (2 интервала): очистка при error, повтор close_pending - -#### Отправка сообщений в ETCP -- **`tcp_proxy_client_send_msg()`** — базовая функция: сборка пакета `svc_id+subcmd+stream_id+data`, отправка через `etcp_route_send()` -- **`tcp_proxy_client_send_connect()`** — CONNECT с dest_ip+dest_port (6 байт данных), force=1 -- **`tcp_proxy_client_send_data()`** — DATA с payload, без force (backpressure через waiter) -- **`tcp_proxy_client_send_close()`** — CLOSE, force=1, при ошибке выставляет close_pending -- **`tcp_proxy_client_send_error()`** — ERROR, force=1, при ошибке выставляет close_pending -- **`tcp_proxy_client_send_fin()`** — FIN, force=1 - -#### Обработка трафика -- **`tcp_proxy_client_tun_input()`** — коллбэк очереди output_queue TUN. Парсит IP-пакеты: TCP → lwip_tcp_input (с динамическим listen на целевой порт), UDP/ICMP → соответствующие прокси -- **`tcp_proxy_client_handle_non_tcp()`** — перенаправление UDP в `udp_proxy_send_to_exit()` и ICMP Echo в `icmp_proxy_send_to_exit()` -- **`tcp_proxy_client_feed_from_transport()`** — чтение данных из очереди `to_lwip` и запись в lwIP через `tcp_write()` пока есть место в send buffer. После feed вызывает `tcp_output()` -- **`tcp_proxy_client_tx_waiter_cb()`** — коллбэк backpressure waiter: досылка `tx_buf` при освобождении router send_q, вызов `tcp_recved()` для window update -- **`tcp_proxy_client_ensure_outbound_listen()`** — динамическое создание listen pcb в lwIP для заданного порта (если ещё не создан), нужно для прозрачного проксирования любых исходящих TCP-соединений - -#### Управление памятью и соединениями -- **`tcp_proxy_client_entry_from_data()`** — создание `ll_entry` из пула `entry_pool` с копированием данных -- **`tcp_proxy_client_find_conn()`** — линейный поиск соединения по `stream_id` -- **`tcp_proxy_client_conn_free()`** — полная очистка одного соединения: досылка pending CLOSE/ERROR, удаление из списка, очистка очереди to_lwip, освобождение tx_buf, cancel waiter, free памяти - -### Зависимости -- `tun_if.h` — TUN-интерфейс (инициализация, запись, закрытие) -- `lwip_tcp/lwip_tcp.h` — встроенный lwIP TCP стек (инициализация, input, управление pcb) -- `etcp.h` / `etcp_api.h` / `etcp_router.h` — ETCP-маршрутизация и отправка данных -- `tcp_proxy_server.h` — общие константы протокола (TCP_PROXY_SUBCMD_*, TCP_PROXY_HDR_SIZE) -- `socks_proxy.h` — SOCKS5/HTTP CONNECT прокси (init_listen, handle_etcp, conn_free_all) -- `udp_proxy.h` / `icmp_proxy.h` — проброс UDP и ICMP -- `config_parser.h` — `tcp_proxy_client_mapping_config` (local_port → remote_ip:remote_port) -- `utun_instance.h` — `struct UTUN_INSTANCE` с полем `tcp_proxy_client` -- `lib/u_async.h` — асинхронный event loop -- `lib/ll_queue.h` — lock-free очереди (to_lwip, output_queue TUN) -- `lib/memory_pool.h` — пул для `struct ll_entry` -- `lib/mem.h` — обёртки malloc/free с учётом утечек -- `lib/debug_config.h` — логирование +`destroy` снимает TCP binding, закрывает listeners/клиентов, освобождает lwIP/TUN/пул. +Общие UDP/ICMP уничтожаются только при выключенном сервере того же instance. +Владелец затем сам обнуляет `inst->tcp_proxy_client`. Все callbacks и очистка выполняются +в uasync; уничтожать клиента следует вне его активного callback. diff --git a/src/proxy/tcp_proxy_server_doc.md b/src/proxy/tcp_proxy_server_doc.md index 435ee9a4..6c15fa94 100644 --- a/src/proxy/tcp_proxy_server_doc.md +++ b/src/proxy/tcp_proxy_server_doc.md @@ -1,109 +1,85 @@ -# tcp_proxy_server +# TCP proxy server (exit) -## 1. Назначение +`tcp_proxy_server.c/h` открывает обычный TCP к IPv4-назначению по CONNECT ingress-узла +и передаёт данные через UTUN-router. Общий протокол — +[doc/proxy_protocol.md](../../doc/proxy_protocol.md), контракт API — +[tcp_proxy_server.h](tcp_proxy_server.h). -TCP прокси-сервер на exit node. Принимает CONNECT-запросы от клиента через ETCP, создаёт OS TCP сокет к реальному адресату, релеит данные в обе стороны: клиент↔ETCP↔сервер↔OS TCP↔destination. Поддерживает backpressure (пауза чтения из OS сокета при заполнении ETCP очереди), graceful CLOSE, экспоненциальный ретрай управляющих сообщений, диагностические таймеры. +## Инициализация и ресурсы -Работает в паре с `tcp_proxy_client` (на ingress node) и `lwip_tcp` (lwIP-стек для обработки TCP-сессий). +Контекст встроен в `inst->tcp_proxy_server`. `init(inst)` обнуляет его, читает +`config->global.tcp_proxy_server_enabled`, при включении регистрирует сервис +`ETCP_RT_ID_TCP_PROXY_SERVER` (0x04) и вызывает UDP/ICMP init независимо от наличия клиента. +NULL inst даёт -1; отсутствие config или отключённый сервер дают 0 без binding. +Результаты bind/UDP/ICMP init не пробрасываются: 0 не гарантирует готовность всех сервисов. +Повторный init при живых потоках недопустим. -При старте сервера автоматически инициализируются `udp_proxy` и `icmp_proxy`, если клиентский TCP прокси не активен (чтобы exit node обслуживал все виды трафика). +INI-парсер включает сервер при обработке параметра секции `[tcp_proxy_server]`. +Поддержанные ключи: `tcp_recv_buf`, `fib`, `so_mark`, `netif`, `bind_ip`, +`connect_timeout`. Ключ `enabled` в этой секции текущим парсером не поддерживается; +для включения достаточно, например, `connect_timeout=2000`. +Пустая секция без параметров сама по себе флаг не выставляет. -## 2. Как пользоваться +Каждый поток принадлежит серверу, содержит собственный `tcp_conn` и `proxy_flow`. +Поиск выполняется по паре `peer_node_id/stream_id`; одинаковые sid разных пиров допустимы. +Повторный CONNECT существующей пары отклоняется с ERROR. -### Включение -В конфиге: `tcp_proxy_server_enabled = yes` — сервер регистрирует обработчик `ETCP_RT_ID_TCP_PROXY` через `etcp_router_bind()` и готов принимать команды от клиентов. +## Connect и обмен данными -### Типовой сценарий -1. **CONNECT (0x01):** клиент шлёт `dest_ip(4) + dest_port(2)`, сервер создаёт `tcp_proxy_server_conn`, открывает OS TCP сокет, делает `connect()` к destination. -2. **DATA (0x03):** клиент шлёт данные → сервер пишет в OS write_queue через pool-аллокацию. -3. **Чтение из destination:** данные с OS сокета попадают в `read_queue`, оттуда `read_queue_drain_cb` релеит их клиенту через ETCP. -4. **CLOSE (0x04):** клиент закрыл соединение → сервер паузит чтение, сливает read_queue, шлёт shutdown(SHUT_WR) в OS сокет. -5. **FIN (0x06):** клиент подтверждает получение FIN от destination → сервер push-ит FIN в OS сокет (завершение полного закрытия). -6. **ERROR (0x05):** ошибка на клиенте → сервер форсированно закрывает соединение. -7. **Очистка:** по `on_closed_cb` (OS сокет закрыт полностью) → `tcp_proxy_server_conn_free()` освобождает все ресурсы. +CONNECT содержит IPv4 и порт в сетевом порядке. Создаётся неблокирующий OS-сокет, +применяются bind_ip, SO_MARK, FreeBSD FIB и привязка к интерфейсу там, где поддержаны, +затем создаётся tcp_conn и вызывается connect. Эти настройки относятся к TCP-сокетам, +UDP/ICMP их не наследуют. Таймаут connect — `connect_timeout` мс, +по умолчанию 2000; 0 отключает таймаут. Асинхронная ошибка/таймаут приводит к ERROR и очистке. -### Ключевые нюансы -- **Backpressure:** если `etcp_route_send()` вернула ошибку — данные буферизуются в `tx_buf`, регистрируется `pause_waiter` (ожидание освобождения ETCP канала), запускается fallback `retry_timer` (500ms, force=1). При возобновлении — сначала делается повтор tx_buf, потом продолжается дрейн очереди. -- **FIN handling:** FIN от destination не отправляется клиенту пока есть pending writes в OS сокет (ждём `on_flushed_cb`) или pending reads (tx_buf / read_queue не пусты — флаг `dst_fin_deferred`). При опустошении очередей — FIN отправляется немедленно. -- **Graceful CLOSE:** при получении CLOSE от клиента чтение из OS сокета паузится через `tcp_conn_pause_read()`, read_queue сливается (данные отбрасываются), push-ится close в OS сокет. -- **Ретрай CLOSE/ERROR:** при неудачной отправке `send_msg()` — экспоненциальный backoff (50→100→200→…→5000 tb = 5ms→500ms) через `close_timer` + `close_retry_cb`. -- **Диагностический таймер (1s):** логирует TCP буферы ОС, размеры read/write очередей, free counts entry/data pool-ов. -- **Connect-таймаут:** `connect()` к destination асинхронный; если за `connect_timeout` мс (конфиг `[tcp_proxy_server]`, по умолчанию 2000, `0` = без таймаута) соединение не установилось — срабатывает `tcp_conn_handle_error(ETIMEDOUT)` → клиенту уходит ERROR, соединение освобождается (защита от «чёрной дыры» и утечки fd). -- **CLOSE_ALL:** специальный 9-байтовый пакет без валидного conn удаляет все соединения для заданного peer. +Реальный успех connect ставит pending CONNECTED. Только после принятия CONNECTED router +flow становится ready. Сервер отправляет ответы в `ETCP_RT_ID_TCP_PROXY_CLIENT` (0x08). +Все proxy send используют `TOPO_GROUP_UTUN` и mode=0. -## 3. API +DATA клиента проверяется через proxy_flow_receive, копируется в pool-backed write_queue TCP. +Кредит WINDOW возвращается после опустошения **и write_queue, и write_buf**, +то есть после передачи принятой порции в OS TCP, а не при получении ETCP-пакета. +Счётчик bytes_sent увеличивается до проверки/постановки в очередь и не является +числом реально доставленных destination байтов. -### Структуры +Данные destination берутся из read_queue и отправляются через proxy_flow_send (force=0). +При блокировке entry возвращается в начало read_queue. Для ошибки router ставится +send-ready waiter; отсутствие ready/кредита (-2) ждёт CONNECTED/WINDOW. +Safety-net 500 мс возобновляет drain с обычным force=0, не обходит окно/очередь router. +Чтение tcp_io ограничивается его очередью. Отдельных tx_buf/pause_buf у сервера нет. -| Структура | Назначение | -|-----------|-----------| -| `tcp_proxy_server` | Корневой контекст: enabled, связный список conns, conn_count, ссылка на UTUN_INSTANCE | -| `tcp_proxy_server_conn` | Состояние одного прокси-соединения: stream_id, peer_node_id, dest_ip:port, tcp_conn, буферы/счётчики/таймеры для backpressure и закрытия | +## FIN, CLOSE и ERROR -### Функции инициализации/завершения +FIN destination отправляется клиенту только после опустошения read_queue. +Ожидание write_queue противоположного направления для этого не требуется. +FIN клиента отмечает flow.fin_received и ставит FIN-маркер в OS write_queue: +shutdown(SHUT_WR) следует за всеми ранее принятыми DATA. Дальнейшие DATA после FIN отклоняются. +Когда FIN отправлен, принят встречный FIN и OS TCP отправил локальный FIN, +в write_queue ставится close-маркер. -| Функция | Описание | -|---------|----------| -| `tcp_proxy_server_init(inst)` | Регистрирует ETCP биндинг на `ETCP_RT_ID_TCP_PROXY`, авто-инициализирует UDP/ICMP прокси если tcp_proxy_client не активен | -| `tcp_proxy_server_destroy(inst)` | Освобождает все соединения, уничтожает UDP/ICMP прокси | +CLOSE клиента устанавливает cli_closed, паузит OS-чтение и отбрасывает read_queue. +При установленном TCP close-маркер ждёт ранее принятые записи write_queue; +ещё не подключённый поток освобождается сразу. ERROR немедленно уничтожает поток, +не ожидая дренажа потенциально зависшего upload. -### Обработчики команд (вызываются из `tcp_proxy_server_recv_cb`) +Общий proxy_flow повторяет только pending CONNECTED/WINDOW/FIN через 500 мс. +CLOSE/ERROR не имеют экспоненциального backoff или собственной очереди server retry. +OS error callback удаляет поток и делает отдельную попытку отправки ERROR; +conn_free сам по себе не отправляет control-сообщение. -| Функция | Описание | -|---------|----------| -| `tcp_proxy_server_recv_cb(conn, entry)` | Главный ETCP-коллбэк: диспетчеризует subcmd → handle_connect/data/close/error/fin, обрабатывает CLOSE_ALL | -| `tcp_proxy_server_handle_connect(inst,entry,sid,src)` | Создаёт OS TCP сокет, `connect()` к destination, аллоцирует `tcp_proxy_server_conn`, регистрирует diag_timer и коллбэки tcp_conn | -| `tcp_proxy_server_handle_data(inst,conn,entry,sid)` | Копирует данные клиента в write_queue OS сокета через pool-аллокацию | -| `tcp_proxy_server_handle_close(inst, sid)` | Паузит чтение, сливает read_queue, отправляет shutdown в OS сокет, освобождает соединение если не connected | -| `tcp_proxy_server_handle_error(inst, sid)` | Делегирует в `handle_close` (обрабатывается идентично CLOSE) | -| `tcp_proxy_server_handle_fin(inst, sid)` | Push-ит FIN в write_queue OS сокета (клиент подтвердил получение FIN от destination) | +## Диспетчеризация и очистка -### Управление соединениями +recv_cb принимает владение entry/dgram, CONNECT/DATA handlers также освобождают их. +Peer берётся из метаданных router, а не из физического conn. +Уведомление router без payload (26 байт) удаляет только потоки данного peer. +Это локальное CLOSE_ALL/restart/close-событие канала, не отдельная 9-байтовая команда proxy. -| Функция | Описание | -|---------|----------| -| `tcp_proxy_server_find_conn(ctx, sid)` | Линейный поиск соединения по stream_id в связном списке | -| `tcp_proxy_server_conn_free(rc)` | Полное освобождение: защита от double-free, отмена таймеров, отправка pending close/error если надо, удаление из списка, `tcp_conn_destroy()`, u_free | +conn_free сразу помечает freed, удаляет поток из списка, отменяет waiter/control retry, +DATA retry и диагностический таймер, уничтожает TCP. Память структуры освобождается +через uasync_call_soon, чтобы текущая синхронная доставка могла проверить freed. +Указатель нельзя использовать как живой поток после conn_free. -### Внутренние callback-и (статические) - -| Функция | Описание | -|---------|----------| -| `send_msg(inst, dst, subcmd, sid, data, len, force)` | Формирует ETCP-пакет (svc_id + subcmd + sid + data) и отправляет через `etcp_route_send()` | -| `send_close(rc)` | Отправляет CLOSE клиенту, при неудаче запускает экспоненциальный ретрай через `close_timer` | -| `send_error(rc)` | Аналогично `send_close`, но шлёт ERROR | -| `send_fin(rc)` | Отправляет FIN клиенту (без ретрая) | -| `on_fin_cb(tc, arg)` | OS сокет получил FIN → машина состояний: если локальный FIN тоже → close; если write pending → ждать flush; если read pending → отложить FIN | -| `on_flushed_cb(tc, arg)` | Write очередь OS сокета опустела → отправить FIN клиенту или закрыть если оба FIN получены | -| `on_error_cb(tc, err, arg)` | Ошибка OS сокета → отправить ERROR клиенту, освободить соединение | -| `on_closed_cb(tc, arg)` | OS сокет полностью закрыт → освободить соединение | -| `read_queue_drain_cb(q, arg)` | Дрейн read_queue OS сокета → отправка данных клиенту через ETCP; backpressure: буферизация в tx_buf + pause_waiter + retry_timer | -| `pause_resume_cb(q, arg)` | ETCP канал освободился → возобновить дрейн read_queue | -| `retry_timer_cb(arg)` | Fallback-таймер 500ms: повтор tx_buf с force=1, при успехе возобновляет read_queue | -| `close_retry_cb(arg)` | Экспоненциальный backoff для повторной отправки CLOSE/ERROR (50→5000 tb) | -| `diag_timer_cb(arg)` | 1-секундный таймер: логирует TCP буферы ОС, размеры очередей, free-counts pool-ов | - -### Константы - -| Макрос | Значение | Описание | -|--------|----------|----------| -| `TCP_PROXY_HDR_SIZE` | 6 | svc_id(1) + subcmd(1) + stream_id(4) | -| `TCP_PROXY_CONNECT_HDR_SIZE` | 12 | HDR_SIZE + dest_ip(4) + dest_port(2) | -| `TCP_PROXY_SUBCMD_CONNECT` | 0x01 | Запрос на создание TCP соединения к destination | -| `TCP_PROXY_SUBCMD_DATA` | 0x03 | Данные для отправки в destination | -| `TCP_PROXY_SUBCMD_CLOSE` | 0x04 | Клиент закрывает прокси-соединение | -| `TCP_PROXY_SUBCMD_ERROR` | 0x05 | Ошибка на стороне клиента | -| `TCP_PROXY_SUBCMD_FIN` | 0x06 | Клиент подтвердил FIN от destination | - -### Зависимости - -| Модуль | Использование | -|--------|--------------| -| `tcp_io.h` | `tcp_conn` — абстракция OS TCP сокета с read/write очередями и memory pool-ами | -| `ll_queue.h` | Очереди read_queue/write_queue OS сокета, pause_waiter для backpressure | -| `etcp_router.h` | `etcp_router_bind()`, `etcp_route_send()`, `etcp_router_on_send_ready()`, `etcp_router_cancel_send_ready()` | -| `u_async.h` | Таймеры (`uasync_set_timeout`, `uasync_cancel_timeout`) | -| `udp_proxy.h` | Авто-инициализация при старте сервера | -| `icmp_proxy.h` | Авто-инициализация при старте сервера | -| `config_parser.h` | Флаг `tcp_proxy_server_enabled` | -| `mem.h` | `u_malloc`/`u_free`/`u_calloc` для tx_buf и структур | +destroy выключает сервер, снимает binding и уничтожает общие UDP/ICMP-контексты. +Клиент того же instance должен быть остановлен раньше; ua должен обработать отложенные free. +Все операции выполняются в uasync. Каждую секунду диагностический таймер пишет +размеры TCP-очередей, наличие write_buf, кеши пулов и OS socket buffers на уровне proxy=trace. From 5e80a9cc6c41dab4b504650c0ae936b710bd474f Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 12:38:27 +0200 Subject: [PATCH 05/25] docs(proxy): consolidate current protocol and audit coverage --- doc/proxy_protocol.md | 208 ++++++++++++++---- src/proxy/PROTOCOL.md | 486 ++---------------------------------------- 2 files changed, 175 insertions(+), 519 deletions(-) diff --git a/doc/proxy_protocol.md b/doc/proxy_protocol.md index 7332da77..ae9cb74c 100644 --- a/doc/proxy_protocol.md +++ b/doc/proxy_protocol.md @@ -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` сверены с реализацией. diff --git a/src/proxy/PROTOCOL.md b/src/proxy/PROTOCOL.md index d1958458..4daa6c80 100644 --- a/src/proxy/PROTOCOL.md +++ b/src/proxy/PROTOCOL.md @@ -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). From ec1fc52da15e3811a613ebb51c0e2d5d2d2f2249 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 12:39:09 +0200 Subject: [PATCH 06/25] docs(proxy): record teardown findings and refine counters --- doc/tasks.md | 11 +++++++++++ src/proxy/tcp_proxy_client.h | 2 +- src/proxy/tcp_proxy_server.h | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/doc/tasks.md b/doc/tasks.md index be8d3ef0..f63bcbd0 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -4,6 +4,17 @@ ## Текущие задачи +[ ] **Proxy: проверить ошибки teardown и убрать неверный ключ в тестовых конфигах** — обнаружено при аудите документации 24.10.2026. + Девять профильных тестов PASS, но в test_socks_http_proxy после ответов встречаются EPIPE (32) + в tcp_io error_cb и ENOTCONN (107) при shutdown write; причина и корректность cleanup отдельно не установлены. + Побайтовая проверка всех 16 загрузок проходит. Начать с информативного лога FIN/close/socket events + (proxy/socket), сопоставить обе половины и очереди; поведение по одному PASS не считать проверенным. + test_udp_proxy, test_icmp_proxy и test_socks_http_proxy также используют [tcp_proxy_server] enabled=yes: + парсер логирует Unknown option, но включает сервер при обработке любого параметра этой секции. + В тестах использовать поддержанный параметр, например connect_timeout=2000, либо отдельно согласовать API конфига. + ICMP здесь проверен через fallback без raw socket (нет прав); реальный raw ping не подтверждён. + Логи: /tmp/proxy-doc-audit-network-logs/test_socks_http_proxy.log, test_udp_proxy.log и test_icmp_proxy.log в том же каталоге. + [ ] **ETCP: полезная утилизация нескольких каналов и доставка при зависании части линков** — общая задача открыта. 24.10.2026: тесты отдельно проверяют обработку RTO (опоздание<=20мс), ожидание готового повтора (<=100мс) и доставку сверх заданной задержки канала (<=100мс). TX учитывается после принятия send. diff --git a/src/proxy/tcp_proxy_client.h b/src/proxy/tcp_proxy_client.h index 79829ca7..2a145c59 100644 --- a/src/proxy/tcp_proxy_client.h +++ b/src/proxy/tcp_proxy_client.h @@ -53,7 +53,7 @@ struct tcp_proxy_client_conn { struct ll_queue* tx_queue; // буфер lwIP→ETCP при backpressure (аналог server read_queue) struct queue_waiter_handle tx_waiter; // waiter на send_q (etcp_router_on_send_ready) - void* tx_retry_timer; // retry safety-net (force=1), аналог server retry_timer + void* tx_retry_timer; // safety-net 500 мс, повтор DATA с force=1 (окно proxy сохраняется) uint32_t bytes_to_exit; // байт принято router для exit (из lwIP/TUN) uint32_t bytes_from_exit; // байт получено от exit (в lwIP/TUN) diff --git a/src/proxy/tcp_proxy_server.h b/src/proxy/tcp_proxy_server.h index 83f968ff..df7ee63d 100644 --- a/src/proxy/tcp_proxy_server.h +++ b/src/proxy/tcp_proxy_server.h @@ -45,7 +45,7 @@ struct tcp_proxy_server_conn { void* retry_timer; // fallback 500 мс: возобновить read_queue, DATA отправляется с force=0 uint32_t bytes_sent; // сумма входящих DATA, учитывается до проверки/записи в TCP uint32_t bytes_relayed; // байт принято router для клиента - uint32_t drain_count; // счётчик вызовов read_queue_drain_cb + uint32_t drain_count; // число успешно отправленных DATA из read_queue uint32_t data_count; // счётчик входящих DATA от клиента struct UASYNC* ua; // заимствованный loop для таймеров и отложенного free }; From 0688ffc26dd36092f5a8da8df9f859e6ec6a4f68 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 12:39:54 +0200 Subject: [PATCH 07/25] docs(proxy): qualify integration audit results --- doc/proxy_protocol.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/proxy_protocol.md b/doc/proxy_protocol.md index ae9cb74c..bf2fd43e 100644 --- a/doc/proxy_protocol.md +++ b/doc/proxy_protocol.md @@ -171,6 +171,9 @@ trace — трафик и секундная диагностика exit TCP-о | test_tcp_io_flush | Callback после последнего синхронного send | Аудит 2026-10-24: перечисленные девять тестовых программ пересобраны и прошли на Linux. +ICMP прошёл через test_loopback: raw socket недоступен без соответствующих прав. +В успешном SOCKS/HTTP-тесте остались EPIPE/ENOTCONN при teardown, а тестовые конфиги +логируют неподдержанный enabled в секции сервера; наблюдения записаны в [tasks.md](tasks.md). Полный check.sh, реальные TUN/load/burst и Windows/FreeBSD в этом аудите не запускались. Создание proxy из конфигурации в utun_instance.c исключено под _WIN32. IPv4-фрагменты на входе proxy TUN отклоняются; сборки фрагментов нет. From 04ace670ed02eed0bee3edca3ecd2b801374f056 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:17:33 +0200 Subject: [PATCH 08/25] docs(proxy): design error handling and lifecycle fixes --- doc/proxy_fix_plan.txt | 137 +++++++++++++++++++++++++++++++++++++++++ doc/tasks.md | 9 +++ 2 files changed, 146 insertions(+) create mode 100644 doc/proxy_fix_plan.txt diff --git a/doc/proxy_fix_plan.txt b/doc/proxy_fix_plan.txt new file mode 100644 index 00000000..3b632837 --- /dev/null +++ b/doc/proxy_fix_plan.txt @@ -0,0 +1,137 @@ +Proxy: исправления и проверка архитектуры +Дата: 2026-10-24. Статус: проект изменений; исходники не изменены. + +Подтверждённые факты + +1. udp_proxy.c не проверяет bind/sendto, результат socket_set_nonblocking, + часть выделений/регистраций/таймеров и отправку ответа через router. + Неудачный запуск flow может оставлять ресурс без рабочего таймера очистки. +2. server_init и client_create оба запускают общие UDP/ICMP-контексты. + client_destroy сохраняет их по server.enabled, server_destroy уничтожает всегда. + Простой rollback через destroy одного модуля не выражает владение общими ресурсами. +3. ICMP init публикует контекст и сообщает успех без raw socket. При этом тот же + контекст нужен для клиентских REPLY: отсутствие возможности exit не означает, + что клиентская сторона ICMP не может работать через другой exit. +4. В диагностическом прогоне tcp_io error_cb при HUP отменяет read_waiter, + включает чтение и напрямую вызывает read_cb вопреки read_paused. + Для fd=15 порог чтения 4; журнал фиксирует read_paused=1 и рост rq до 53. + Это нарушение ограничения очереди, а не только избыточная диагностика. +5. EPIPE/ENOTCONN в HTTPS CONNECT воспроизведены. Системная трасса четырёх TLS-потоков: + recv(destination)=0 -> поздняя TLS-запись 24 байта от клиента -> send(destination)=24 + -> SO_ERROR=EPIPE либо shutdown(SHUT_WR)=ENOTCONN. Повторный shutdown не обнаружен. + Определение содержимого зашифрованной TLS-записи по трассе невозможно. + Сам по себе этот сценарий не доказывает дефект half-close в proxy. +6. После удаления exit-потока приходят поздние WINDOW: диспетчер пишет unhandled, + хотя это известная команда уже завершённого потока. Создавать поток по WINDOW нельзя. +7. enabled=yes в серверных тестовых конфигах не поддержан парсером. Сейчас сервер + включается при обработке любого параметра секции, включая неизвестный. + +Предлагаемая архитектура + +Владелец общих UDP/ICMP-контекстов — UTUN_INSTANCE. Их жизненный цикл координируется +в utun_instance.c после router_init и до router_destroy. TCP client/server управляют +только своими listeners, TCP-потоками и bindings. Общих счётчиков ссылок не требуется: +при существующем lifecycle они живут до остановки instance. + +Контексты нужны при включённом exit или клиентском TUN; SOCKS/HTTP без TUN их не требуют. +server.enabled обозначает действующий TCP exit. Роль при запуске ICMP exit должна +передаваться явно, а не определяться случайным порядком вызовов init. +При отказе создания клиента rollback освобождает только его ресурсы и собственный binding. +Повторное создание клиента/сервера поверх активного состояния отклоняется. +Результат client_create публикуется в inst->tcp_proxy_client только после успеха. + +Порядок остановки: клиент и exit-потоки -> общие UDP/ICMP -> router -> drain deferred free. +Ресурсы закрываются в одном uasync-потоке; stop/destroy не вызывается из активного +callback уничтожаемого владельца. Таймеры/waiters отменяются до освобождения памяти. + +Инициализация и ICMP + +Разделить создание общего ICMP-контекста/регистрацию REPLY и запуск raw exit socket. +Это две разные операции: при отказе raw нельзя уничтожать работающую клиентскую сторону. +Например, icmp_proxy_init создаёт общий контекст, отдельный enable_exit открывает +raw socket и регистрирует чтение. Новый raw socket публикуется только после полного успеха. +Обе операции возвращают ошибки непосредственно своему вызывающему коду. + +Рекомендуемая политика: EPERM/EACCES/отсутствие поддержки raw явно отключают только ICMP exit, +с WARN о причине и итогом запуска tcp=on/udp=on/icmp_exit=off. TCP/UDP продолжают работать. +Ошибки выделения/регистрации обязательных ресурсов прерывают запуск proxy с полной очисткой. +Если продукт требует обязательного ICMP, отсутствие raw должно быть фатальным на уровне +конфигурации; это отдельная политика, а не скрытый fallback внутри init. +В production виртуальные echo-ответы недопустимы. Тестовый loopback выбирается явно +и должен работать одинаково с правами raw и без них. + +Заданные SOCKS/HTTP listeners обязательны: enabled с пустым/неверным адресом либо +неудачный bind/listen проваливают create. Нельзя вернуть готовый клиент без выбранного входа. +Проверки router bind оставляем, но при живом inst нынешний bind не выделяет память +и возвращает 0; не нужно изображать реальные OOM-пути там, где их нет. + +UDP и ограниченные ресурсы + +Новый flow сначала полностью создаётся локально: socket -> nonblocking -> bind -> +read registration -> обеспечение expire timer. Только затем добавляется в список. +Любой отказ снимает уже созданные регистрации/таймеры и закрывает именно новый сокет. +Если общий таймер не удаётся продлить, оставшиеся flows закрываются с диагностикой: +состояние без гарантированной очистки не сохраняется. + +sendto проверяется по точному размеру. Для нулевой датаграммы n=0 означает успех. +EINTR допускает повтор операции; EAGAIN/ENOBUFS означает явное отбрасывание датаграммы +с диагностикой. Не добавляем неограниченную очередь и TCP-подобные повторы UDP. +Остальные ошибки также логируются с peer, tuple, длиной и errno; packet-specific ошибка +не должна автоматически уничтожать рабочий flow. Невалидный/сломанный сокет удаляется. +Проверяются ошибки router send, выделения ответов и доставки в TUN. +ICMP request тоже не публикуется без рабочего механизма истечения срока ожидания. + +TCP HUP, FIN и границы ответственности + +FIN относится только к одному направлению и следует после его DATA. Ошибка реальной +записи остаётся ошибкой даже после FIN противоположной стороны; ENOTCONN не превращается +в успешный on_fin_sent. Существующая регрессия test_fin_shutdown_error сохраняет этот контракт. + +HUP — повод обработать завершение сокета, но не разрешение обойти high_water или явную pause_read. +При достижении high_water чтение останавливается до low_water. Если level-triggered HUP +не даёт event loop уснуть, регистрацию сокета нужно временно снять и восстановить по +queue waiter; простой return с оставленным HUP в epoll/poll создаёт busy loop. +Нужен один механизм восстановления регистрации в tcp_io, учитывающий connected, +fin_remote, write_buf, write_queue и уничтожение владельца. Состояние fd/пулы сохраняются +до окончательного destroy; запланированная запись не должна зависеть от паузы чтения. +Повторное добавление использует новую generation uasync, старые события игнорируются. +Явную паузу владельца следует отличать от паузы по high_water; HUP не снимает ни одну. + +Это наиболее чувствительный этап: перед реализацией проверить переходы регистрации +при HUP с ожидающей записью, low_water, обеих FIN и destroy из callback. +Не менять глобальную семантику всех регистраций uasync ради одного tcp_io-сценария. + +Поздние WINDOW/FIN/CLOSE/ERROR для отсутствующего потока игнорируются как события +завершённого sid с debug-диагностикой. DATA не воскрешает поток, неверное окно живого +потока по-прежнему является ошибкой. HTTP не обязан ждать FIN для ответа с известной длиной. +Проверить синхронный loopback: состояние перед отправкой control резервируется заранее; +после callback с возможным destroy нельзя обращаться к владельцу без проверки его жизни. + +HTTPS-тест разделить на нормальное двустороннее TLS shutdown и явный abrupt close. +В первом случае ожидать отсутствие EPIPE/ENOTCONN; во втором — корректную ошибку, +сохранность уже доставленного ответа и освобождение ресурсов. Proxy не разбирает TLS. +Сначала проверить эту гипотезу управляемым тестовым сервером, затем менять fixture. + +Проверка после реализации + +- Fault injection: socket/nonblocking/bind/read registration/expire timer/sendto/OOM. + Проверять отсутствие fd/памяти/таймеров/handlers после каждого отказа и успешный повторный запуск. +- client only, exit only, обе роли на одном instance, два независимых instance; + отказ raw сохраняет ICMP REPLY клиента, остановка одного TCP-модуля не ломает общие контексты. +- UDP payload=0, краткая/ошибочная отправка, transient error и следующий успешный пакет. +- HUP при полном proxy window и остановленном consumer: очередь остаётся ограниченной, + event loop не крутится, после снятия блокировки все байты и FIN доходят в правильном порядке. +- pending write при HUP, manual pause, low_water/re-register, destroy в callback и stale events. +- FIN в обе стороны при backpressure, RST до/после ответа, нормальный/оборванный TLS, + поздний WINDOW, синхронный loopback и HTTP keep-alive со сменой sid. +- Текущие proxy9, test_tcp_io, тесты uasync/queue, затем ASAN/UBSan и полный check.sh. + Windows/FreeBSD требуют отдельной проверки poll/select; Linux PASS её не заменяет. + +Доказательства текущего анализа + +Диагностический test_socks_http_proxy под gdb: PASS, 16 byte-exact загрузок. +Включены существующие proxy/socket debug через gdb, проектные файлы не менялись. +Полный журнал: /tmp/proxy-design-debug.log; события lifecycle: /tmp/proxy-design-lifecycle.log. +Системные вызовы TLS: /tmp/proxy-design-close.744427, +сводка четырёх соединений: /tmp/proxy-design-close-summary.txt. +Это проверка существующей реализации и проекта изменений; исправленный код ещё не протестирован. diff --git a/doc/tasks.md b/doc/tasks.md index f63bcbd0..6346d550 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -5,6 +5,15 @@ ## Текущие задачи [ ] **Proxy: проверить ошибки teardown и убрать неверный ключ в тестовых конфигах** — обнаружено при аудите документации 24.10.2026. + Проект исправлений и проверка владения/rollback/half-close: [doc/proxy_fix_plan.txt](proxy_fix_plan.txt). + Уточнение 24.10.2026: существующие proxy/socket debug включены через gdb без правок исходников. + Подтверждён обход backpressure при HUP: tcp_io error_cb снимает read_waiter и принудительно читает, + очередь выросла до53 блоков при high_water=4. Нужны ограниченное чтение и ожидание low_water без HUP busy loop. + strace четырёх HTTPS-потоков: после EOF назначения приходит TLS-запись24 байта от клиента; + send принимает её, затем SO_ERROR=EPIPE или shutdown=ENOTCONN. Повторный shutdown не обнаружен. + Не подавлять эти ошибки; отдельно проверить корректный TLS shutdown тестового сервера и abrupt close. + Доказательства: /tmp/proxy-design-debug.log, /tmp/proxy-design-lifecycle.log, + /tmp/proxy-design-close.744427 и /tmp/proxy-design-close-summary.txt. Исходники пока не изменены. Девять профильных тестов PASS, но в test_socks_http_proxy после ответов встречаются EPIPE (32) в tcp_io error_cb и ENOTCONN (107) при shutdown write; причина и корректность cleanup отдельно не установлены. Побайтовая проверка всех 16 загрузок проходит. Начать с информативного лога FIN/close/socket events From 051b13e161c8e0aa31be36883a5f03469daabac1 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:18:52 +0200 Subject: [PATCH 09/25] docs(proxy): align planned teardown with instance ownership --- doc/proxy_fix_plan.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/proxy_fix_plan.txt b/doc/proxy_fix_plan.txt index 3b632837..b30fedb7 100644 --- a/doc/proxy_fix_plan.txt +++ b/doc/proxy_fix_plan.txt @@ -41,6 +41,12 @@ server.enabled обозначает действующий TCP exit. Роль п Результат client_create публикуется в inst->tcp_proxy_client только после успеха. Порядок остановки: клиент и exit-потоки -> общие UDP/ICMP -> router -> drain deferred free. +При destroy всего instance остановку proxy перенести перед остановкой UTUN-группы, +разборкой topo_groups и ETCP-сокетов/соединений. Сейчас proxy уничтожается в phase M, +после topo_groups и транспорта: для нового rollback нельзя сохранять этот поздний порядок. +Общий instance_init_common уже вызывает instance_destroy при отказе создания; +нужно использовать этот путь, не добавляя параллельную систему частичной очистки. +При create_from_config сохранить существующее правило: отказ возвращает config вызывающему коду. Ресурсы закрываются в одном uasync-потоке; stop/destroy не вызывается из активного callback уничтожаемого владельца. Таймеры/waiters отменяются до освобождения памяти. From 99be040488df7befb50fc5f5292a55191b85747a Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:23:58 +0200 Subject: [PATCH 10/25] Separate ICMP exit startup from shared context ownership --- src/proxy/icmp_proxy.c | 112 ++++++++++++++++++++++++++++------------- src/proxy/icmp_proxy.h | 19 +++---- src/utun_instance.c | 36 ++++++++++--- 3 files changed, 117 insertions(+), 50 deletions(-) diff --git a/src/proxy/icmp_proxy.c b/src/proxy/icmp_proxy.c index 57b1001f..2a2e7ee2 100644 --- a/src/proxy/icmp_proxy.c +++ b/src/proxy/icmp_proxy.c @@ -10,16 +10,15 @@ #include "../lib/debug_config.h" #include "../lib/ll_queue.h" #include "../lib/mem.h" +#include "../lib/platform_compat.h" #include #include #include #include #ifndef _WIN32 -#include #include #include #include -#include #include #else #define ICMP_MINLEN 8 @@ -98,9 +97,22 @@ static int exit_send_echo(struct UTUN_INSTANCE* inst, uint64_t client_node_id, DEBUG_INFO(DEBUG_CATEGORY_PROXY, "icmp_proxy: sendto dst=0x%08x id=0x%04x seq=%u len=%zu", dst_ip, echo_id, echo_seq, icmp_len); struct sockaddr_in addr = {.sin_family = AF_INET, .sin_addr = {.s_addr = dst_ip}}; - ssize_t n = sendto(ctx->raw_sock, buf, icmp_len, 0, (struct sockaddr*)&addr, sizeof(addr)); + if (!ctx->expire_timer) { + ctx->expire_timer = uasync_set_timeout(ctx->ua, ctx->request_timeout_tb, ctx, req_expire_timer_cb, "icmp_expire"); + if (!ctx->expire_timer) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: cannot reserve expiry timer peer=%016llx", (unsigned long long)client_node_id); + u_free(buf); u_free(r); return -1; + } + } + ssize_t n; + do { n = sendto(ctx->raw_sock, buf, icmp_len, 0, (struct sockaddr*)&addr, sizeof(addr)); } while (n < 0 && errno == EINTR); u_free(buf); - if (n < 0) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: sendto failed: %s", strerror(errno)); u_free(r); return -1; } + if (n != (ssize_t)icmp_len) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: sendto failed dst=%08x sent=%zd expected=%zu error=%d", + dst_ip, n, icmp_len, n < 0 ? socket_get_error() : 0); + if (!ctx->pending) { uasync_cancel_timeout(ctx->ua, ctx->expire_timer); ctx->expire_timer = NULL; } + u_free(r); return -1; + } DEBUG_INFO(DEBUG_CATEGORY_PROXY, "icmp_proxy: sendto sent %zd bytes", n); r->client_node_id = client_node_id; r->dst_ip = dst_ip; r->orig_src_ip = orig_src_ip; @@ -109,8 +121,6 @@ static int exit_send_echo(struct UTUN_INSTANCE* inst, uint64_t client_node_id, if (payload_len > 0) memcpy(r->payload, payload, r->payload_len); r->sent_tb = get_time_tb(); r->next = ctx->pending; ctx->pending = r; - if (!ctx->expire_timer) - ctx->expire_timer = uasync_set_timeout(ctx->ua, ctx->request_timeout_tb, ctx, req_expire_timer_cb, "icmp_expire"); return 0; } @@ -181,9 +191,17 @@ static void exit_handle_request(struct ETCP_CONN* conn, struct ll_entry* entry) uint8_t* payload = entry->dgram + ICMP_PROXY_RECV_HDR_SIZE; size_t payload_len = entry->len - ICMP_PROXY_RECV_HDR_SIZE; - if (ctx->raw_sock != SOCKET_INVALID) { + if (ctx->test_loopback) { + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "icmp_proxy: explicit test echo peer=%016llx", (unsigned long long)client_node_id); + } else if (ctx->raw_sock != SOCKET_INVALID) { exit_send_echo(inst, client_node_id, dst_ip, orig_src_ip, echo_id, echo_seq, payload, payload_len); - } else if (ctx->test_loopback) { + goto drop; + } else { + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "icmp_proxy: exit unavailable peer=%016llx dst=%08x", + (unsigned long long)client_node_id, dst_ip); + goto drop; + } + { DEBUG_INFO(DEBUG_CATEGORY_PROXY, "icmp_proxy: test loopback reply to 0x%08x", dst_ip); struct ll_entry* e = queue_entry_new(0); if (e) { @@ -197,11 +215,10 @@ static void exit_handle_request(struct ETCP_CONN* conn, struct ll_entry* entry) memcpy(e->dgram + 12, &echo_seq, 2); if (payload_len > 0) memcpy(e->dgram + ICMP_PROXY_HDR_SIZE, payload, payload_len); e->len = ICMP_PROXY_HDR_SIZE + payload_len; - etcp_route_send(inst, TOPO_GROUP_UTUN, client_node_id, e, 0, 0); - } else queue_entry_free(e); - } - } else { - DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: no raw socket, dropping echo request to 0x%08x", dst_ip); + if (etcp_route_send(inst, TOPO_GROUP_UTUN, client_node_id, e, 0, 0) < 0) + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "icmp_proxy: test reply router send failed"); + } else { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: test reply allocation failed"); queue_entry_free(e); } + } else DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: test reply entry allocation failed"); } drop: @@ -321,15 +338,27 @@ int icmp_proxy_deliver_reply(struct UTUN_INSTANCE* inst, void icmp_proxy_set_test_loopback(struct UTUN_INSTANCE* inst, int enabled) { struct icmp_proxy_ctx* ctx = inst ? inst->icmp_proxy : NULL; - if (ctx) ctx->test_loopback = enabled; + if (!ctx) { DEBUG_WARN(DEBUG_CATEGORY_PROXY, "icmp_proxy: test mode requires initialized context"); return; } + if (ctx->pending) { DEBUG_WARN(DEBUG_CATEGORY_PROXY, "icmp_proxy: cannot change test mode with pending requests"); return; } + if (enabled && ctx->raw_sock != SOCKET_INVALID) { + uasync_remove_socket_t(ctx->ua, ctx->raw_sock); ctx->raw_read_id = NULL; + socket_close_wrapper(ctx->raw_sock); ctx->raw_sock = SOCKET_INVALID; + } + ctx->test_loopback = enabled != 0; + DEBUG_INFO(DEBUG_CATEGORY_PROXY, "icmp_proxy: explicit test loopback=%d", ctx->test_loopback); } static void req_expire_timer_cb(void* arg) { struct icmp_proxy_ctx* ctx = (struct icmp_proxy_ctx*)arg; req_expire(ctx); ctx->expire_timer = NULL; - if (ctx->pending) + if (ctx->pending) { ctx->expire_timer = uasync_set_timeout(ctx->ua, ctx->request_timeout_tb, ctx, req_expire_timer_cb, "icmp_expire"); + if (!ctx->expire_timer) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: expiry timer failed, dropping all pending requests"); + while (ctx->pending) { struct icmp_request* r = ctx->pending; ctx->pending = r->next; u_free(r); } + } + } } static void req_expire(struct icmp_proxy_ctx* ctx) { @@ -345,35 +374,50 @@ static void req_expire(struct icmp_proxy_ctx* ctx) { // Публичные init/destroy // ==================================================================== int icmp_proxy_init(struct UTUN_INSTANCE* inst, struct UASYNC* ua) { - if (!inst) return -1; - if (inst->icmp_proxy) return 0; + if (!inst || !ua) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: invalid init inst=%p ua=%p", (void*)inst, (void*)ua); return -EINVAL; } + if (inst->icmp_proxy) { + if (inst->icmp_proxy->ua == ua) return 0; + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: init attempted with another event loop"); return -EINVAL; + } struct icmp_proxy_ctx* ctx = u_calloc(1, sizeof(struct icmp_proxy_ctx)); - if (!ctx) return -1; + if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: context allocation failed"); return -ENOMEM; } ctx->inst = inst; ctx->ua = ua; ctx->raw_sock = SOCKET_INVALID; ctx->request_timeout_tb = ICMP_TIMEOUT_TB; - ctx->is_exit = inst->tcp_proxy_server.enabled; ctx->test_loopback = 0; ctx->expire_timer = NULL; if (RAND_bytes((unsigned char*)&ctx->next_token, sizeof(ctx->next_token)) != 1) { - DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: token initialization failed"); u_free(ctx); return -1; + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: token initialization failed"); u_free(ctx); return -EIO; + } + if (etcp_router_bind(inst, ETCP_RT_ID_ICMP_PROXY, icmp_proxy_recv_cb) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: router binding failed"); u_free(ctx); return -EIO; } inst->icmp_proxy = ctx; + DEBUG_INFO(DEBUG_CATEGORY_PROXY, "icmp_proxy: shared context ready node=%016llx", (unsigned long long)inst->node_id); + return 0; +} - if (ctx->is_exit) { - ctx->raw_sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); - if (ctx->raw_sock == SOCKET_INVALID) - DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: raw socket(SOCK_RAW) failed: %s", strerror(errno)); - else { - socket_set_nonblocking(ctx->raw_sock); - DEBUG_INFO(DEBUG_CATEGORY_PROXY, "icmp_proxy: raw socket created fd=%d", ctx->raw_sock); - ctx->raw_read_id = uasync_add_socket_t(ua, ctx->raw_sock, raw_read_cb, NULL, NULL, "icmp_raw", ctx); - if (!ctx->raw_read_id) { socket_close_wrapper(ctx->raw_sock); ctx->raw_sock = SOCKET_INVALID; } - } +// Raw exit публикуется только после регистрации; отказ не затрагивает клиентский binding. +int icmp_proxy_enable_exit(struct UTUN_INSTANCE* inst) { + struct icmp_proxy_ctx* ctx = inst ? inst->icmp_proxy : NULL; + if (!ctx || !inst->tcp_proxy_server.enabled) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: exit requires context/server"); return -EINVAL; } + if (ctx->test_loopback || ctx->raw_sock != SOCKET_INVALID) return 0; + socket_t sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); + if (sock == SOCKET_INVALID) { + int err = socket_get_error(); + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "icmp_proxy: raw socket unavailable error=%d (%s)", err, socket_strerror(err)); + return -(err ? err : EIO); } - - etcp_router_bind(inst, ETCP_RT_ID_ICMP_PROXY, icmp_proxy_recv_cb); - ctx->initialized = 1; - DEBUG_INFO(DEBUG_CATEGORY_PROXY, "icmp_proxy initialized (exit=%d raw_sock=%d)", ctx->is_exit, (int)ctx->raw_sock); + if (socket_set_nonblocking(sock) < 0) { + int err = socket_get_error(); + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: raw nonblocking failed error=%d", err); + socket_close_wrapper(sock); return -(err ? err : EIO); + } + void* read_id = uasync_add_socket_t(ctx->ua, sock, raw_read_cb, NULL, NULL, "icmp_raw", ctx); + if (!read_id) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: raw read registration failed"); socket_close_wrapper(sock); return -ENOMEM; + } + ctx->raw_sock = sock; ctx->raw_read_id = read_id; + DEBUG_INFO(DEBUG_CATEGORY_PROXY, "icmp_proxy: raw exit ready fd=%d", (int)sock); return 0; } diff --git a/src/proxy/icmp_proxy.h b/src/proxy/icmp_proxy.h index 504447d6..5a1c4f76 100644 --- a/src/proxy/icmp_proxy.h +++ b/src/proxy/icmp_proxy.h @@ -1,9 +1,9 @@ /* IPv4 ICMP echo через UTUN-router: exit отправляет ping через raw socket, сопоставляет reply * по wire id/seq и IP цели, восстанавливая исходные id/seq клиента. Запросы ограничены таймаутом. * init/destroy и callbacks — в uasync; inst владеет контекстом в inst->icmp_proxy. - * init создаёт raw socket, если server.enabled; recv_cb проверяет текущий server.enabled для REQUEST + * init регистрирует общий контекст; enable_exit отдельно открывает raw socket. recv_cb проверяет server.enabled для REQUEST * и via_node_id клиентского proxy для REPLY. IP/id/seq в API — network byte order. - * test_loopback возвращает виртуальные ответы только при отсутствии raw socket; флаг его не отключает. */ + * test_loopback явно переключает тестовый exit на виртуальные ответы, закрывая raw socket без pending-запросов. */ #ifndef ICMP_PROXY_H #define ICMP_PROXY_H @@ -49,8 +49,6 @@ struct icmp_request { }; struct icmp_proxy_ctx { - int initialized; // init завершён, binding запрошен - int is_exit; // снимок server.enabled при init (определяет создание raw socket) struct UTUN_INSTANCE* inst; // заимствованный instance-владелец struct UASYNC* ua; // заимствованный event loop socket_t raw_sock; // один SOCK_RAW для всего ICMP на exit @@ -58,15 +56,18 @@ struct icmp_proxy_ctx { struct icmp_request* pending; // собственные запросы, ожидающие ответа uint32_t next_token; // генератор wire id/seq со случайным начальным значением uint64_t request_timeout_tb; // период проверки и порог ожидания, default=50000 (5 с) - int test_loopback; // fallback при raw_sock==SOCKET_INVALID, только для тестов + int test_loopback; // явный виртуальный exit, только для тестов void* expire_timer; // периодический таймер очистки истёкших запросов }; // Создать контекст и bind ICMP service; повторный init не меняет существующий контекст и даёт 0. -// inst/ua живут до destroy. -1 = нет inst/памяти/случайного token, 0 = контекст создан. -// 0 не гарантирует raw socket на exit: ошибка socket/регистрации не проваливает init; bind не проверяется. +// inst/ua живут до destroy. 0 = контекст и binding готовы, <0 = ошибка без публикации нового контекста. +// Контекстом владеет instance; TCP client/server его не уничтожают. Raw socket здесь не создаётся. int icmp_proxy_init(struct UTUN_INSTANCE* inst, struct UASYNC* ua); +// Запустить raw exit существующего контекста. 0 = готов (включая явный test_loopback), <0 = -errno. +// Отказ сохраняет клиентскую сторону и прежнее состояние; политику отсутствия прав выбирает instance. +int icmp_proxy_enable_exit(struct UTUN_INSTANCE* inst); // Снять binding, отменить таймер, закрыть raw socket, удалить pending и обнулить inst->icmp_proxy; NULL допустим. void icmp_proxy_destroy(struct UTUN_INSTANCE* inst); @@ -87,8 +88,8 @@ int icmp_proxy_deliver_reply(struct UTUN_INSTANCE* inst, uint32_t dst_ip, uint32_t src_ip, uint16_t echo_id, uint16_t echo_seq, const uint8_t* payload, size_t payload_len); -// Изменить тестовый fallback существующего контекста; без контекста ничего не делает. -// При наличии raw socket реальные echo-запросы имеют приоритет независимо от enabled. +// Явно включить тестовый loopback, закрыв raw socket; при pending-запросах изменение отклоняется с WARN. +// Без контекста ничего не делает; выключение loopback само по себе raw socket не открывает. void icmp_proxy_set_test_loopback(struct UTUN_INSTANCE* inst, int enabled); diff --git a/src/utun_instance.c b/src/utun_instance.c index 2f51947e..f54c3ef0 100644 --- a/src/utun_instance.c +++ b/src/utun_instance.c @@ -39,6 +39,8 @@ #include "transport_layer/node_conn_direct.h" #include "transport_layer/socket_monitor.h" #include "transport_layer/auto_socket.h" +#include "proxy/udp_proxy.h" +#include "proxy/icmp_proxy.h" #ifdef UTUN_HAVE_STANDBY #include "standby.h" #endif @@ -266,11 +268,10 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u if (g_topo_group_enabled && instance->topo_groups) etcp_router_bind(instance, ETCP_RT_ID_CONN_MGR, conn_mgr_router_recv_handler); - // TCP proxy server (exit node, optional) — must be before tcp_proxy_client so - // tcp_proxy_client_create can overwrite the handler if it has remote mappings + // TCP client/server имеют отдельные bindings; общими датаграммными контекстами владеет instance. #ifndef _WIN32 if (tcp_proxy_server_init(instance) != 0) { - DEBUG_WARN(DEBUG_CATEGORY_SOCKET, "Failed to initialize tcp_proxy_server (non-fatal)"); + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "Failed to initialize TCP proxy exit"); return -1; } #endif @@ -298,6 +299,22 @@ static int instance_init_common(struct UTUN_INSTANCE* instance, struct UASYNC* u } else { instance->tcp_proxy_client = NULL; } + if (instance->tcp_proxy_server.enabled || (instance->tcp_proxy_client && instance->tcp_proxy_client->tun)) { + if (udp_proxy_init(instance, ua) < 0 || icmp_proxy_init(instance, ua) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "Failed to initialize shared proxy datagram contexts"); return -1; + } + if (instance->tcp_proxy_server.enabled) { + int ret = icmp_proxy_enable_exit(instance); + if (ret < 0) { + if (ret != -EPERM && ret != -EACCES && ret != -EPROTONOSUPPORT && ret != -EAFNOSUPPORT) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "Failed to initialize ICMP exit: error=%d", -ret); return -1; + } + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "ICMP exit disabled: %s; TCP/UDP and ICMP client remain available", strerror(-ret)); + } + } + DEBUG_INFO(DEBUG_CATEGORY_PROXY, "Proxy ready: tcp_exit=%d udp=1 icmp_client=1 icmp_exit=%d", + instance->tcp_proxy_server.enabled, instance->icmp_proxy->raw_sock != SOCKET_INVALID); + } #endif /* _WIN32 */ return 0; @@ -439,6 +456,15 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { } DEBUG_INFO(DEBUG_CATEGORY_SYS, "[DESTROY] C done — control_srv"); +#ifndef _WIN32 + // Остановить producers/callbacks proxy, пока UTUN-группа, router и транспорт ещё живы. + if (instance->tcp_proxy_client) { tcp_proxy_client_destroy(instance->tcp_proxy_client); instance->tcp_proxy_client = NULL; } + tcp_proxy_server_destroy(instance); + udp_proxy_destroy(instance); + icmp_proxy_destroy(instance); + if (instance->ua) uasync_drain_immediate(instance->ua); +#endif + chat_service_stop(instance); utun_service_stop(instance); @@ -543,10 +569,6 @@ static void instance_destroy(struct UTUN_INSTANCE* instance, int owns_config) { tun_close(instance->tun); instance->tun = NULL; } -#ifndef _WIN32 - if (instance->tcp_proxy_client) { tcp_proxy_client_destroy(instance->tcp_proxy_client); instance->tcp_proxy_client = NULL; } - tcp_proxy_server_destroy(instance); -#endif routing_destroy(instance); if (instance->md.initialized) media_delivery_destroy(instance); media_async_destroy(instance->media_async); instance->media_async = NULL; From 50da7fe4578d0faf99b637ab85e7e0d989ea8f84 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:24:30 +0200 Subject: [PATCH 11/25] Make TCP proxy construction atomic and retain instance datagrams --- src/proxy/tcp_proxy_client.c | 29 +++++++++++++++++++++-------- src/proxy/tcp_proxy_client.h | 10 ++++++---- src/proxy/tcp_proxy_server.c | 27 ++++++++++++++------------- 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/src/proxy/tcp_proxy_client.c b/src/proxy/tcp_proxy_client.c index caef252c..917f129f 100644 --- a/src/proxy/tcp_proxy_client.c +++ b/src/proxy/tcp_proxy_client.c @@ -723,7 +723,8 @@ void tcp_proxy_client_router_recv_cb(struct ETCP_CONN* conn, struct ll_entry* en if (subcmd == TCP_PROXY_SUBCMD_ERROR) { tcp_proxy_client_handle_error(proxy, stream_id); queue_dgram_free(entry); queue_entry_free(entry); return; } if (subcmd == TCP_PROXY_SUBCMD_FIN) { tcp_proxy_client_handle_fin(proxy, stream_id); queue_dgram_free(entry); queue_entry_free(entry); return; } } - if (subcmd == TCP_PROXY_SUBCMD_DATA || subcmd == TCP_PROXY_SUBCMD_CLOSE || subcmd == TCP_PROXY_SUBCMD_FIN || subcmd == TCP_PROXY_SUBCMD_ERROR) { + if (subcmd == TCP_PROXY_SUBCMD_DATA || subcmd == TCP_PROXY_SUBCMD_CLOSE || subcmd == TCP_PROXY_SUBCMD_FIN || + subcmd == TCP_PROXY_SUBCMD_ERROR || subcmd == TCP_PROXY_SUBCMD_WINDOW) { DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "PROXY subcmd=%02x sid=%08x — client conn not found, silent drop", subcmd, stream_id); queue_dgram_free(entry); queue_entry_free(entry); return; } @@ -742,6 +743,13 @@ struct tcp_proxy_client* tcp_proxy_client_create(struct UTUN_INSTANCE* inst, str int http_proxy_enabled, const char* http_proxy_addr) { if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "ua is NULL"); return NULL; } + if (inst && (inst->tcp_proxy_client || inst->router_bindings.callbacks[ETCP_RT_ID_TCP_PROXY_CLIENT])) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: duplicate instance binding"); return NULL; + } + if ((socks_enabled && (!socks_addr || !socks_addr[0])) || + (http_proxy_enabled && (!http_proxy_addr || !http_proxy_addr[0])) || mapping_count < 0 || (mapping_count && !mappings)) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: missing listener address or invalid mappings"); return NULL; + } int need_tun = tun_name && tun_name[0] && tun_ip && tun_ip[0]; if (!need_tun && !socks_enabled && !http_proxy_enabled) { @@ -787,21 +795,28 @@ struct tcp_proxy_client* tcp_proxy_client_create(struct UTUN_INSTANCE* inst, str if (socks_enabled && socks_addr && socks_addr[0]) { p->socks_listen = socks_proxy_init_listen(ua, socks_addr, &p->socks_conns, &p->socks_conn_count, &p->next_stream_id, inst, via_node_id, 0); - if (!p->socks_listen) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "tcp_proxy_client: SOCKS init failed on %s", socks_addr); } + if (!p->socks_listen) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "tcp_proxy_client: SOCKS init failed on %s", socks_addr); + tcp_proxy_client_destroy(p); return NULL; + } } if (http_proxy_enabled && http_proxy_addr && http_proxy_addr[0]) { p->http_listen = socks_proxy_init_listen(ua, http_proxy_addr, &p->http_conns, &p->http_conn_count, &p->next_stream_id, inst, via_node_id, 1); - if (!p->http_listen) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "tcp_proxy_client: HTTP proxy init failed on %s", http_proxy_addr); } + if (!p->http_listen) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "tcp_proxy_client: HTTP proxy init failed on %s", http_proxy_addr); + tcp_proxy_client_destroy(p); return NULL; + } } if (inst) { if (etcp_router_bind(inst, ETCP_RT_ID_TCP_PROXY_CLIENT, tcp_proxy_client_router_recv_cb) != 0) { - DEBUG_WARN(DEBUG_CATEGORY_PROXY, "TCP proxy client: etcp_router_bind failed"); + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: etcp_router_bind failed"); + tcp_proxy_client_destroy(p); return NULL; } else { DEBUG_INFO(DEBUG_CATEGORY_PROXY, "TCP proxy client: etcp_router bind registered for ID=0x%02x", ETCP_RT_ID_TCP_PROXY_CLIENT); - if (need_tun) { udp_proxy_init(inst, ua); icmp_proxy_init(inst, ua); } + p->router_bound = 1; } } @@ -813,11 +828,9 @@ struct tcp_proxy_client* tcp_proxy_client_create(struct UTUN_INSTANCE* inst, str void tcp_proxy_client_destroy(struct tcp_proxy_client* p) { if (!p) return; - int total = p->conn_count + p->socks_conn_count + p->http_conn_count; DEBUG_INFO(DEBUG_CATEGORY_PROXY, "TCP proxy client destroying: lwip=%d socks=%d http=%d", p->conn_count, p->socks_conn_count, p->http_conn_count); - if (p->inst) etcp_router_unbind(p->inst, ETCP_RT_ID_TCP_PROXY_CLIENT); - if (p->inst && !p->inst->tcp_proxy_server.enabled) { udp_proxy_destroy(p->inst); icmp_proxy_destroy(p->inst); } + if (p->router_bound) { etcp_router_unbind(p->inst, ETCP_RT_ID_TCP_PROXY_CLIENT); p->router_bound = 0; } if (p->socks_listen) socks_proxy_close_listen(p->ua, p->socks_listen, NULL); socks_proxy_conn_free_all(&p->socks_conns, &p->socks_conn_count); diff --git a/src/proxy/tcp_proxy_client.h b/src/proxy/tcp_proxy_client.h index 2a145c59..174dbfac 100644 --- a/src/proxy/tcp_proxy_client.h +++ b/src/proxy/tcp_proxy_client.h @@ -65,6 +65,7 @@ struct tcp_proxy_client_conn { struct tcp_proxy_client { struct UTUN_INSTANCE* inst; // заимствован; NULL допустим для автономного TUN smoke test + uint8_t router_bound; // собственный CLIENT binding установлен; rollback не снимает чужую регистрацию struct UASYNC* ua; // заимствованный event loop struct tun_if* tun; // собственный TUN или NULL для SOCKS/HTTP без TUN struct tcp_proxy_client_conn* conns; // список собственных lwIP-потоков @@ -78,13 +79,13 @@ struct tcp_proxy_client { int mapping_count; // число записей mappings, порты конфига в host byte order // SOCKS proxy - int socks_enabled; // запрошен SOCKS5; не гарантирует успешный listener + int socks_enabled; // SOCKS5 listener обязателен при создании struct socks_proxy_conn* socks_conns; // собственные SOCKS5-клиенты int socks_conn_count; // число SOCKS5-клиентов struct listen_ctx* socks_listen; // собственный listener или NULL при отключении/ошибке // HTTP forward proxy и CONNECT - int http_proxy_enabled; // запрошен HTTP; не гарантирует успешный listener + int http_proxy_enabled; // HTTP listener обязателен при создании struct socks_proxy_conn* http_conns; // собственные HTTP-клиенты int http_conn_count; // число HTTP-клиентов struct listen_ctx* http_listen; // собственный listener или NULL при отключении/ошибке @@ -94,7 +95,8 @@ struct tcp_proxy_client { // test_mode передаётся tun_init_nat: ненулевой использует тестовый TUN; mappings заимствуются до destroy. // SOCKS/HTTP создаются при enabled и непустом IPv4:port; via_node_id — общий exit. // Результат: собственный p или NULL при критической ошибке/отсутствии режима. -// Ошибки listener/router bind/UDP/ICMP не делают create атомарным: проверять listener и диагностику отдельно. +// Все выбранные входы и CLIENT binding обязательны; отказ полностью освобождает новые ресурсы. +// Общие UDP/ICMP создаёт и уничтожает instance отдельно; create их не затрагивает. struct tcp_proxy_client* tcp_proxy_client_create(struct UTUN_INSTANCE* inst, struct UASYNC* ua, const char* tun_name, const char* tun_ip, int mtu, int test_mode, struct tcp_proxy_client_mapping_config* mappings, int mapping_count, @@ -102,7 +104,7 @@ struct tcp_proxy_client* tcp_proxy_client_create(struct UTUN_INSTANCE* inst, str int socks_enabled, const char* socks_addr, int http_proxy_enabled, const char* http_proxy_addr); // Остановить listeners, освободить потоки/TUN/lwIP и снять router binding; NULL допустим. -// UDP/ICMP сохраняются, пока включён сервер того же inst. Не обнуляет inst->tcp_proxy_client. +// Не затрагивает общие UDP/ICMP и не обнуляет inst->tcp_proxy_client. void tcp_proxy_client_destroy(struct tcp_proxy_client* p); // Router callback: conn->instance задаёт владельца, entry содержит метаданные router и передаёт владение. // Освобождает entry/dgram при любом исходе; CLOSE_ALL = пустой payload router, а не TCP-подкоманда. diff --git a/src/proxy/tcp_proxy_server.c b/src/proxy/tcp_proxy_server.c index 47e69d1c..a2ba0341 100644 --- a/src/proxy/tcp_proxy_server.c +++ b/src/proxy/tcp_proxy_server.c @@ -1,7 +1,5 @@ // tcp_proxy_server.c — TCP прокси-сервер (exit node) #include "tcp_proxy_server.h" -#include "udp_proxy.h" -#include "icmp_proxy.h" #include "etcp.h" #include "etcp_api.h" #include "etcp_router.h" @@ -494,7 +492,8 @@ void tcp_proxy_server_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) { if (subcmd == TCP_PROXY_SUBCMD_FIN) { tcp_proxy_server_handle_fin(inst, peer, stream_id); queue_dgram_free(entry); queue_entry_free(entry); return; } } } - if (subcmd == TCP_PROXY_SUBCMD_DATA || subcmd == TCP_PROXY_SUBCMD_CLOSE || subcmd == TCP_PROXY_SUBCMD_FIN || subcmd == TCP_PROXY_SUBCMD_ERROR) { + if (subcmd == TCP_PROXY_SUBCMD_DATA || subcmd == TCP_PROXY_SUBCMD_CLOSE || subcmd == TCP_PROXY_SUBCMD_FIN || + subcmd == TCP_PROXY_SUBCMD_ERROR || subcmd == TCP_PROXY_SUBCMD_WINDOW) { DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "PROXY subcmd=%02x sid=%08x — server conn not found, silent drop", subcmd, stream_id); queue_dgram_free(entry); queue_entry_free(entry); return; } @@ -507,16 +506,18 @@ void tcp_proxy_server_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) { // ==================================================================== int tcp_proxy_server_init(struct UTUN_INSTANCE* inst) { - if (!inst) return -1; + if (!inst) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy server: init requires instance"); return -1; } struct tcp_proxy_server* ctx = &inst->tcp_proxy_server; + if (ctx->inst || ctx->conns) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy server: duplicate initialization"); return -1; } memset(ctx, 0, sizeof(*ctx)); if (!inst->config) return 0; - ctx->enabled = inst->config->global.tcp_proxy_server_enabled; ctx->inst = inst; - if (!ctx->enabled) return 0; - etcp_router_bind(inst, ETCP_RT_ID_TCP_PROXY_SERVER, tcp_proxy_server_recv_cb); - udp_proxy_init(inst, inst->ua); - icmp_proxy_init(inst, inst->ua); + if (!inst->config->global.tcp_proxy_server_enabled) return 0; + if (inst->router_bindings.callbacks[ETCP_RT_ID_TCP_PROXY_SERVER] || + etcp_router_bind(inst, ETCP_RT_ID_TCP_PROXY_SERVER, tcp_proxy_server_recv_cb) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy server: service binding unavailable"); ctx->inst = NULL; return -1; + } + ctx->enabled = 1; DEBUG_INFO(DEBUG_CATEGORY_PROXY, "TCP proxy server initialized node=%016llx", (unsigned long long)inst->node_id); return 0; } @@ -524,12 +525,12 @@ int tcp_proxy_server_init(struct UTUN_INSTANCE* inst) { void tcp_proxy_server_destroy(struct UTUN_INSTANCE* inst) { if (!inst) return; struct tcp_proxy_server* ctx = &inst->tcp_proxy_server; + if (!ctx->inst) return; DEBUG_INFO(DEBUG_CATEGORY_PROXY, "TCP proxy server destroying: conn_count=%d", ctx->conn_count); struct tcp_proxy_server_conn* rc = ctx->conns; while (rc) { struct tcp_proxy_server_conn* next = rc->next; tcp_proxy_server_conn_free(rc); rc = next; } - ctx->conns = NULL; ctx->enabled = 0; - etcp_router_unbind(inst, ETCP_RT_ID_TCP_PROXY_SERVER); - udp_proxy_destroy(inst); - icmp_proxy_destroy(inst); + ctx->conns = NULL; + if (ctx->enabled) etcp_router_unbind(inst, ETCP_RT_ID_TCP_PROXY_SERVER); + ctx->enabled = 0; ctx->inst = NULL; DEBUG_INFO(DEBUG_CATEGORY_PROXY, "TCP proxy server destroyed"); } From d5444fb9bca88b064d00f0360b1e5c7e76923a5e Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:25:43 +0200 Subject: [PATCH 12/25] Check UDP socket operations and roll back incomplete flows --- src/proxy/tcp_proxy_server.h | 10 +-- src/proxy/udp_proxy.c | 134 ++++++++++++++++++++++++----------- src/proxy/udp_proxy.h | 7 +- 3 files changed, 102 insertions(+), 49 deletions(-) diff --git a/src/proxy/tcp_proxy_server.h b/src/proxy/tcp_proxy_server.h index df7ee63d..8dba6be7 100644 --- a/src/proxy/tcp_proxy_server.h +++ b/src/proxy/tcp_proxy_server.h @@ -1,6 +1,6 @@ /* Exit TCP-прокси: принимает CONNECT/DATA через UTUN-router и открывает обычный TCP к назначению. * Поток определяется парой peer_node_id/stream_id; proxy_flow согласует окна и half-close. - * init учитывает tcp_proxy_server_enabled в конфиге; destroy закрывает потоки и общий UDP/ICMP-прокси. + * init учитывает tcp_proxy_server_enabled в конфиге; destroy закрывает только TCP-потоки и свой binding. * Сначала init, затем приём через router callback, в конце destroy; повторный init живого сервера недопустим. * Состояние принадлежит UTUN_INSTANCE, операции и callbacks выполняются в его uasync-потоке. * CLOSE дренирует запись и прекращает чтение; ERROR немедленно уничтожает поток, FIN — half-close. */ @@ -58,11 +58,11 @@ struct tcp_proxy_server { }; // Обнулить состояние и учесть config->global.tcp_proxy_server_enabled; NULL inst → -1, иначе 0. -// Без конфига/при отключении не bind-ит сервис. При включении bind SERVER и вызывает UDP/ICMP init. -// 0 не гарантирует успешный bind/UDP/ICMP: их ошибки не пробрасываются вызывающему коду. +// Без конфига/при отключении не bind-ит сервис. При включении bind SERVER обязателен; отказ возвращает -1. +// Общими UDP/ICMP владеет instance; init/destroy TCP-сервера их не затрагивают. int tcp_proxy_server_init(struct UTUN_INSTANCE* inst); -// Удалить потоки, выключить сервер, снять binding и уничтожить общие UDP/ICMP; NULL inst допустим. -// Перед этим остановить tcp_proxy_client того же inst; дать ua обработать отложенные освобождения. +// Удалить потоки, выключить сервер и снять свой binding; NULL inst и повторный destroy допустимы. +// Дать ua обработать отложенные освобождения до уничтожения instance. void tcp_proxy_server_destroy(struct UTUN_INSTANCE* inst); // Линейный поиск в ненулевом ctx по паре peer/sid; заимствованный указатель или NULL. diff --git a/src/proxy/udp_proxy.c b/src/proxy/udp_proxy.c index fae04a9d..2247166b 100644 --- a/src/proxy/udp_proxy.c +++ b/src/proxy/udp_proxy.c @@ -10,14 +10,13 @@ #include "../lib/debug_config.h" #include "../lib/ll_queue.h" #include "../lib/mem.h" +#include "../lib/platform_compat.h" #include #include #include #ifndef _WIN32 -#include #include #include -#include #include #endif @@ -27,6 +26,19 @@ static void flow_expire_timer_cb(void* arg); static void flow_expire(struct udp_proxy_ctx* ctx); +// Удаляет опубликованный или ещё создаваемый flow, закрывая все его ресурсы. +static void flow_free(struct udp_flow* f) { + struct udp_proxy_ctx* ctx = f->ctx; + struct udp_flow** prev = &ctx->flows; + while (*prev && *prev != f) prev = &(*prev)->next; + if (*prev) { *prev = f->next; ctx->flow_count--; } + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "udp_proxy: free flow peer=%016llx fd=%d remaining=%d", + (unsigned long long)f->client_node_id, (int)f->sock, ctx->flow_count); + if (f->read_id) uasync_remove_socket_t(ctx->ua, f->sock); + if (f->sock != SOCKET_INVALID) socket_close_wrapper(f->sock); + u_free(f); +} + static struct udp_flow* flow_find(struct udp_flow* head, uint64_t client_node_id, uint32_t src_ip, uint16_t src_port, uint32_t dst_ip, uint16_t dst_port) { @@ -44,7 +56,16 @@ static void flow_read_cb(socket_t sock, void* arg) { uint8_t buf[65507]; struct sockaddr_in from; socklen_t flen = sizeof(from); ssize_t n = recvfrom(f->sock, buf, sizeof(buf), 0, (struct sockaddr*)&from, &flen); - if (n < 0) { DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: recv failed errno=%d", errno); return; } + if (n < 0) { + int err = socket_get_error(); + if (err == EINTR || err == EAGAIN || err == EWOULDBLOCK) { + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "udp_proxy: read not ready fd=%d error=%d", (int)f->sock, err); return; + } + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: recv failed peer=%016llx fd=%d error=%d (%s)", + (unsigned long long)f->client_node_id, (int)f->sock, err, socket_strerror(err)); + if (err == EBADF || err == ENOTSOCK) flow_free(f); + return; + } if (from.sin_addr.s_addr != f->dst_ip || from.sin_port != f->dst_port) { DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: unexpected reply source"); return; } @@ -53,9 +74,9 @@ static void flow_read_cb(socket_t sock, void* arg) { // Обернуть ответ: меняем src/dst для восстановления на стороне клиента struct ll_entry* e = queue_entry_new(0); - if (!e) return; + if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: reply entry allocation failed"); return; } e->dgram = u_malloc(UDP_PROXY_HDR_SIZE + n); - if (!e->dgram) { queue_entry_free(e); return; } + if (!e->dgram) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: reply allocation failed bytes=%zd", n); queue_entry_free(e); return; } e->dgram[0] = ETCP_RT_ID_UDP_PROXY; e->dgram[1] = UDP_PROXY_SUBCMD_REPLY; // Ответ src = оригинальный dst_ip:dest_port @@ -67,7 +88,8 @@ static void flow_read_cb(socket_t sock, void* arg) { memcpy(e->dgram + UDP_PROXY_HDR_SIZE, buf, n); e->len = UDP_PROXY_HDR_SIZE + n; - etcp_route_send(ctx->inst, TOPO_GROUP_UTUN, f->client_node_id, e, 0, 0); + if (etcp_route_send(ctx->inst, TOPO_GROUP_UTUN, f->client_node_id, e, 0, 0) < 0) + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: reply router send failed bytes=%zd", n); } // ==================================================================== @@ -76,7 +98,9 @@ static void flow_read_cb(socket_t sock, void* arg) { static void exit_handle_data(struct ETCP_CONN* conn, struct ll_entry* entry) { struct UTUN_INSTANCE* inst = conn ? conn->instance : NULL; struct udp_proxy_ctx* ctx = inst ? inst->udp_proxy : NULL; - if (!inst || !ctx || !inst->tcp_proxy_server.enabled || entry->len < UDP_PROXY_RECV_HDR_SIZE) goto drop; + if (!inst || !ctx || !inst->tcp_proxy_server.enabled || entry->len < UDP_PROXY_RECV_HDR_SIZE) { + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: invalid request len=%u context=%p", entry->len, (void*)ctx); goto drop; + } uint64_t client_node_id; memcpy(&client_node_id, entry->dgram + ROUTER_SVC_SRC_OFF, 8); uint32_t src_ip; memcpy(&src_ip, entry->dgram + ROUTER_SVC_PAYLOAD_OFF + 1, 4); @@ -85,31 +109,54 @@ static void exit_handle_data(struct ETCP_CONN* conn, struct ll_entry* entry) { uint16_t dst_port; memcpy(&dst_port, entry->dgram + ROUTER_SVC_PAYLOAD_OFF + 11, 2); uint8_t* payload = entry->dgram + UDP_PROXY_RECV_HDR_SIZE; size_t payload_len = entry->len - UDP_PROXY_RECV_HDR_SIZE; + if (payload_len > 65507) { DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: oversized request len=%zu", payload_len); goto drop; } struct udp_flow* f = flow_find(ctx->flows, client_node_id, src_ip, src_port, dst_ip, dst_port); if (!f) { f = u_calloc(1, sizeof(struct udp_flow)); - if (!f) goto drop; + if (!f) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: flow allocation failed"); goto drop; } f->ctx = ctx; + f->sock = SOCKET_INVALID; f->client_node_id = client_node_id; f->src_ip = src_ip; f->src_port = src_port; f->dst_ip = dst_ip; f->dst_port = dst_port; f->ua = ctx->ua; f->created_tb = get_time_tb(); f->last_activity_tb = f->created_tb; f->sock = socket(AF_INET, SOCK_DGRAM, 0); - if (f->sock == SOCKET_INVALID) { u_free(f); DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: socket failed"); goto drop; } - socket_set_nonblocking(f->sock); + if (f->sock == SOCKET_INVALID) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: socket failed error=%d", socket_get_error()); flow_free(f); goto drop; + } + if (socket_set_nonblocking(f->sock) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: nonblocking failed fd=%d error=%d", (int)f->sock, socket_get_error()); + flow_free(f); goto drop; + } struct sockaddr_in bind_addr = {.sin_family = AF_INET, .sin_addr = {.s_addr = INADDR_ANY}, .sin_port = 0}; - bind(f->sock, (struct sockaddr*)&bind_addr, sizeof(bind_addr)); + if (bind(f->sock, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: bind ANY:0 failed fd=%d error=%d", (int)f->sock, socket_get_error()); + flow_free(f); goto drop; + } f->read_id = uasync_add_socket_t(ctx->ua, f->sock, flow_read_cb, NULL, NULL, "udp_flow", f); - if (!f->read_id) { socket_close_wrapper(f->sock); u_free(f); goto drop; } - f->next = ctx->flows; ctx->flows = f; ctx->flow_count++; - if (!ctx->expire_timer) + if (!f->read_id) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: read registration failed"); flow_free(f); goto drop; } + if (!ctx->expire_timer) { ctx->expire_timer = uasync_set_timeout(ctx->ua, ctx->flow_timeout_tb, ctx, flow_expire_timer_cb, "udp_expire"); + if (!ctx->expire_timer) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: expiry timer failed"); flow_free(f); goto drop; } + } + f->next = ctx->flows; ctx->flows = f; ctx->flow_count++; + DEBUG_INFO(DEBUG_CATEGORY_PROXY, "udp_proxy: flow ready peer=%016llx src=%08x:%u dst=%08x:%u fd=%d flows=%d", + (unsigned long long)client_node_id, ntohl(src_ip), ntohs(src_port), ntohl(dst_ip), ntohs(dst_port), + (int)f->sock, ctx->flow_count); } f->last_activity_tb = get_time_tb(); struct sockaddr_in addr = {.sin_family = AF_INET, .sin_addr = {.s_addr = dst_ip}, .sin_port = dst_port}; - sendto(f->sock, payload, payload_len, 0, (struct sockaddr*)&addr, sizeof(addr)); + ssize_t sent; + do { sent = sendto(f->sock, payload, payload_len, 0, (struct sockaddr*)&addr, sizeof(addr)); } while (sent < 0 && errno == EINTR); + if (sent != (ssize_t)payload_len) { + int err = sent < 0 ? socket_get_error() : 0; + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: dropped datagram peer=%016llx src=%08x:%u dst=%08x:%u len=%zu sent=%zd error=%d", + (unsigned long long)client_node_id, ntohl(src_ip), ntohs(src_port), ntohl(dst_ip), ntohs(dst_port), + payload_len, sent, err); + if (err == EBADF || err == ENOTSOCK) flow_free(f); + } else DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "udp_proxy: sent peer=%016llx bytes=%zu", (unsigned long long)client_node_id, payload_len); drop: queue_dgram_free(entry); queue_entry_free(entry); @@ -120,7 +167,7 @@ drop: // ==================================================================== static void client_handle_reply(struct ETCP_CONN* conn, struct ll_entry* entry) { if (entry->len < UDP_PROXY_RECV_HDR_SIZE) { queue_dgram_free(entry); queue_entry_free(entry); return; } - const uint8_t* d = entry->dgram; + uint8_t* d = entry->dgram; uint32_t src_ip, dst_ip; uint16_t src_port, dst_port; // В сообщении: src_ip:src_port = оригинальный dst, dst_ip:dst_port = оригинальный src @@ -132,7 +179,8 @@ static void client_handle_reply(struct ETCP_CONN* conn, struct ll_entry* entry) size_t payload_len = entry->len - UDP_PROXY_RECV_HDR_SIZE; struct UTUN_INSTANCE* inst = conn ? conn->instance : NULL; - udp_proxy_deliver_reply(inst, src_ip, src_port, dst_ip, dst_port, payload, payload_len); + if (udp_proxy_deliver_reply(inst, src_ip, src_port, dst_ip, dst_port, payload, payload_len) < 0) + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: TUN delivery failed bytes=%zu", payload_len); queue_dgram_free(entry); queue_entry_free(entry); } @@ -164,11 +212,13 @@ int udp_proxy_send_to_exit(struct UTUN_INSTANCE* inst, uint64_t exit_node_id, uint32_t src_ip, uint16_t src_port, uint32_t dst_ip, uint16_t dst_port, const uint8_t* payload, size_t payload_len) { - if (!inst || !inst->udp_proxy || payload_len > 65507) { DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: invalid send"); return -1; } + if (!inst || !inst->udp_proxy || payload_len > 65507 || (payload_len && !payload)) { + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: invalid send len=%zu", payload_len); return -1; + } struct ll_entry* e = queue_entry_new(0); - if (!e) return -1; + if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: request entry allocation failed"); return -1; } e->dgram = u_malloc(UDP_PROXY_HDR_SIZE + payload_len); - if (!e->dgram) { queue_entry_free(e); return -1; } + if (!e->dgram) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: request allocation failed"); queue_entry_free(e); return -1; } e->dgram[0] = ETCP_RT_ID_UDP_PROXY; e->dgram[1] = UDP_PROXY_SUBCMD_REQUEST; memcpy(e->dgram + 2, &src_ip, 4); @@ -187,12 +237,14 @@ int udp_proxy_deliver_reply(struct UTUN_INSTANCE* inst, uint32_t src_ip, uint16_t src_port, uint32_t dst_ip, uint16_t dst_port, const uint8_t* payload, size_t payload_len) { - if (!inst || !inst->tcp_proxy_client || !inst->tcp_proxy_client->tun || payload_len > 65506) return -1; + if (!inst || !inst->tcp_proxy_client || !inst->tcp_proxy_client->tun || payload_len > 65506 || (payload_len && !payload)) { + DEBUG_WARN(DEBUG_CATEGORY_PROXY, "udp_proxy: invalid TUN delivery len=%zu", payload_len); return -1; + } // Собрать IP/UDP ответный пакет size_t ip_len = 20 + 8 + payload_len; uint8_t* pkt = u_malloc(ip_len); - if (!pkt) return -1; + if (!pkt) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: IP packet allocation failed"); return -1; } memset(pkt, 0, 28); // IPv4 UDP checksum=0: проверка отключена явно pkt[0] = 0x45; pkt[8] = 64; pkt[9] = IPPROTO_UDP; @@ -212,7 +264,7 @@ int udp_proxy_deliver_reply(struct UTUN_INSTANCE* inst, if (payload_len > 0) memcpy(pkt + 28, payload, payload_len); struct ll_entry* e = queue_entry_new(0); - if (!e) { u_free(pkt); return -1; } + if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: TUN entry allocation failed"); u_free(pkt); return -1; } e->dgram = u_malloc(1 + ip_len); if (!e->dgram) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: reply allocation failed"); u_free(pkt); queue_entry_free(e); return -1; } e->dgram[0] = 4; memcpy(e->dgram + 1, pkt, ip_len); e->len = 1 + ip_len; @@ -223,10 +275,15 @@ int udp_proxy_deliver_reply(struct UTUN_INSTANCE* inst, static void flow_expire_timer_cb(void* arg) { struct udp_proxy_ctx* ctx = (struct udp_proxy_ctx*)arg; - flow_expire(ctx); ctx->expire_timer = NULL; - if (ctx->flows) + flow_expire(ctx); + if (ctx->flows) { ctx->expire_timer = uasync_set_timeout(ctx->ua, ctx->flow_timeout_tb, ctx, flow_expire_timer_cb, "udp_expire"); + if (!ctx->expire_timer) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: cannot renew expiry timer, closing %d flows", ctx->flow_count); + while (ctx->flows) flow_free(ctx->flows); + } + } } static void flow_expire(struct udp_proxy_ctx* ctx) { @@ -234,9 +291,7 @@ static void flow_expire(struct udp_proxy_ctx* ctx) { while (*prev) { struct udp_flow* f = *prev; if (now - f->last_activity_tb > ctx->flow_timeout_tb) { - *prev = f->next; ctx->flow_count--; - if (f->read_id) { uasync_remove_socket_t(f->ua, f->sock); f->read_id = NULL; } - socket_close_wrapper(f->sock); u_free(f); + flow_free(f); } else prev = &f->next; } } @@ -245,19 +300,21 @@ static void flow_expire(struct udp_proxy_ctx* ctx) { // Публичные init/destroy // ==================================================================== int udp_proxy_init(struct UTUN_INSTANCE* inst, struct UASYNC* ua) { - if (!inst) return -1; - if (inst->udp_proxy) return 0; + if (!inst || !ua) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: invalid init"); return -1; } + if (inst->udp_proxy) { + if (inst->udp_proxy->ua == ua) return 0; + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: init attempted with another event loop"); return -1; + } struct udp_proxy_ctx* ctx = u_calloc(1, sizeof(struct udp_proxy_ctx)); - if (!ctx) return -1; + if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: context allocation failed"); return -1; } ctx->inst = inst; ctx->ua = ua; ctx->flow_timeout_tb = UDP_FLOW_TIMEOUT_TB; ctx->expire_timer = NULL; - ctx->is_exit = inst->tcp_proxy_server.enabled; + if (etcp_router_bind(inst, ETCP_RT_ID_UDP_PROXY, udp_proxy_recv_cb) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: router binding failed"); u_free(ctx); return -1; + } inst->udp_proxy = ctx; - - etcp_router_bind(inst, ETCP_RT_ID_UDP_PROXY, udp_proxy_recv_cb); - ctx->initialized = 1; - DEBUG_INFO(DEBUG_CATEGORY_PROXY, "udp_proxy initialized (exit=%d)", ctx->is_exit); + DEBUG_INFO(DEBUG_CATEGORY_PROXY, "udp_proxy: shared context ready node=%016llx", (unsigned long long)inst->node_id); return 0; } @@ -266,10 +323,7 @@ void udp_proxy_destroy(struct UTUN_INSTANCE* inst) { if (!ctx) return; etcp_router_unbind(inst, ETCP_RT_ID_UDP_PROXY); if (ctx->expire_timer) { uasync_cancel_timeout(ctx->ua, ctx->expire_timer); ctx->expire_timer = NULL; } - struct udp_flow* f = ctx->flows; - while (f) { struct udp_flow* n = f->next; - if (f->read_id) { uasync_remove_socket_t(ctx->ua, f->sock); f->read_id = NULL; } - socket_close_wrapper(f->sock); u_free(f); f = n; } + while (ctx->flows) flow_free(ctx->flows); u_free(ctx); inst->udp_proxy = NULL; DEBUG_INFO(DEBUG_CATEGORY_PROXY, "udp_proxy destroyed"); } diff --git a/src/proxy/udp_proxy.h b/src/proxy/udp_proxy.h index e1676545..4ff86bca 100644 --- a/src/proxy/udp_proxy.h +++ b/src/proxy/udp_proxy.h @@ -1,7 +1,7 @@ /* UDP из TUN через UTUN-router: клиент отправляет REQUEST, exit открывает сокет для кортежа адресов * и возвращает REPLY. Потоки exit удаляются по таймауту неактивности. * init/destroy и callbacks — в uasync; inst владеет контекстом в inst->udp_proxy. - * init сохраняет роль в is_exit для диагностики; приём REQUEST проверяет текущее server.enabled, + * Приём REQUEST проверяет текущее server.enabled, * а REPLY — наличие tcp_proxy_client и совпадение peer с via_node_id. Один inst может быть и клиентом, и exit. * API принимает IP/порты в network byte order, payload заимствован только на время вызова. */ #ifndef UDP_PROXY_H @@ -49,8 +49,6 @@ struct udp_flow { }; struct udp_proxy_ctx { - int initialized; // init завершён, binding запрошен - int is_exit; // снимок server.enabled на момент init, не фильтр recv_cb struct UTUN_INSTANCE* inst; // заимствованный instance-владелец struct UASYNC* ua; // заимствованный event loop struct udp_flow* flows; // собственные flows exit @@ -61,7 +59,8 @@ struct udp_proxy_ctx { // Создать per-instance контекст и bind UDP service; повторный init существующего контекста даёт 0. -// inst/ua должны жить до destroy. 0 = контекст есть (результат bind не проверяется), -1 = ошибка inst/памяти. +// inst/ua должны жить до destroy. 0 = контекст и binding готовы, -1 = ошибка без публикации нового контекста. +// TCP client/server этот контекст не создают/уничтожают: им владеет instance. int udp_proxy_init(struct UTUN_INSTANCE* inst, struct UASYNC* ua); // Снять binding, отменить таймер, закрыть flows и обнулить inst->udp_proxy; NULL/нет контекста допустимы. void udp_proxy_destroy(struct UTUN_INSTANCE* inst); From 4da9d855451884c81db1c6fcf0e2307f3fa639d5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:28:44 +0200 Subject: [PATCH 13/25] Honor TCP read backpressure across HUP and restore polling --- lib/tcp_io.c | 109 +++++++++++++++++++++++++------------------- lib/tcp_io.h | 2 + tests/test_tcp_io.c | 59 ++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 47 deletions(-) diff --git a/lib/tcp_io.c b/lib/tcp_io.c index 2ea83f23..a0a204a9 100644 --- a/lib/tcp_io.c +++ b/lib/tcp_io.c @@ -24,6 +24,38 @@ static void tcp_conn_handle_error(struct tcp_conn* tc, int err); static void connect_timeout_cb(void* arg); static const uint8_t tcp_fin_sentinel; +// Сохраняем fd при HUP, но не оставляем level-triggered событие в poll без работы. +// Очередь восстанавливает регистрацию с новым generation; write_buf обслуживается независимо от чтения. +static int sync_socket_monitoring(struct tcp_conn* tc) { + if (tc->destroyed || tc->error || tc->closed || tc->sock == SOCKET_INVALID) return -1; + int read = !tc->read_paused && !tc->read_stopped && !tc->fin_remote; + int write = !tc->connected || tc->write_buf != NULL; + if (tc->hup_received && !read && !write) { + if (tc->socket_id) { + uasync_remove_socket_t(tc->ua, tc->sock); tc->socket_id = NULL; + DEBUG_DEBUG(DEBUG_CATEGORY_SOCKET, "tcp_io: park HUP fd=%d rq=%d paused=%u stopped=%u fin=%u", + (int)tc->sock, tc->read_queue->count, tc->read_paused, tc->read_stopped, tc->fin_remote); + } + tc->write_monitor = 0; + return 0; + } + if (!tc->socket_id) { + tc->socket_id = uasync_add_socket_t(tc->ua, tc->sock, read_cb, write_cb, error_cb, "tcp_io", tc); + if (!tc->socket_id) { + DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: cannot restore socket registration fd=%d", (int)tc->sock); + tcp_conn_handle_error(tc, ENOMEM); return -1; + } + DEBUG_DEBUG(DEBUG_CATEGORY_SOCKET, "tcp_io: restore HUP fd=%d read=%d write=%d", (int)tc->sock, read, write); + } + if (uasync_set_socket_read(tc->ua, tc->socket_id, read) != ERR_OK || + uasync_set_socket_write(tc->ua, tc->socket_id, write) != ERR_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: monitoring update failed fd=%d read=%d write=%d", (int)tc->sock, read, write); + tcp_conn_handle_error(tc, EIO); return -1; + } + tc->write_monitor = write; + return 0; +} + struct tcp_conn* tcp_conn_create( struct UASYNC* ua, socket_t sock, size_t entry_data_size, size_t write_chunk_size, @@ -201,7 +233,7 @@ static void tcp_conn_handle_error(struct tcp_conn* tc, int err) static void read_cb(socket_t sock, void* arg) { (void)sock; struct tcp_conn* tc = (struct tcp_conn*)arg; - if (!tc || tc->sock == SOCKET_INVALID || !tc->read_queue) return; + if (!tc || tc->sock == SOCKET_INVALID || !tc->read_queue || tc->read_paused || tc->read_stopped || tc->fin_remote) return; struct ll_entry* e = queue_entry_new_from_pool(tc->entry_pool); uint8_t* buf = memory_pool_alloc(tc->data_pool); @@ -209,8 +241,7 @@ static void read_cb(socket_t sock, void* arg) { DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: read_cb alloc failed fd=%d", (int)tc->sock); if (e) queue_entry_free(e); if (buf) memory_pool_free(tc->data_pool, buf); - uasync_set_socket_read(tc->ua, tc->socket_id, 0); - tc->read_paused = 1; + tcp_conn_handle_error(tc, ENOMEM); return; } @@ -222,9 +253,12 @@ static void read_cb(socket_t sock, void* arg) { queue_data_put(rq, e); // tc мог быть уничтожен через callback chain — read_queue обнулён if (rq && tc->read_queue && tc->read_queue->count >= tc->read_high_water && !tc->read_paused) { - uasync_set_socket_read(tc->ua, tc->socket_id, 0); tc->read_paused = 1; - queue_waiter_wait(tc->read_queue, &tc->read_waiter, resume_read_cb, tc); + if (sync_socket_monitoring(tc) < 0) return; + if (queue_waiter_wait(tc->read_queue, &tc->read_waiter, resume_read_cb, tc) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: cannot wait for read queue fd=%d", (int)tc->sock); + tcp_conn_handle_error(tc, ENOMEM); + } } } else if (n == 0) { memory_pool_free(tc->data_pool, buf); @@ -232,8 +266,8 @@ static void read_cb(socket_t sock, void* arg) { if (tc->fin_remote) return; tc->fin_remote = 1; DEBUG_DEBUG(DEBUG_CATEGORY_SOCKET, "tcp_io: FIN fd=%d", (int)tc->sock); - uasync_set_socket_read(tc->ua, tc->socket_id, 0); queue_waiter_cancel(tc->read_queue, &tc->read_waiter); + if (sync_socket_monitoring(tc) < 0) return; if (tc->read_queue->count == 0) { if (tc->on_fin) tc->on_fin(tc, tc->arg); } else { @@ -251,9 +285,16 @@ static void read_cb(socket_t sock, void* arg) { static void resume_read_cb(struct ll_queue* q, void* arg) { (void)q; struct tcp_conn* tc = (struct tcp_conn*)arg; - if (!tc || tc->sock == SOCKET_INVALID) return; - uasync_set_socket_read(tc->ua, tc->socket_id, 1); + if (!tc || tc->sock == SOCKET_INVALID || tc->destroyed || tc->read_stopped || tc->fin_remote) return; + if (tc->read_queue->count > tc->read_low_water) { + if (queue_waiter_wait(tc->read_queue, &tc->read_waiter, resume_read_cb, tc) < 0) { + DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: cannot renew read waiter fd=%d", (int)tc->sock); + tcp_conn_handle_error(tc, ENOMEM); + } + return; + } tc->read_paused = 0; + sync_socket_monitoring(tc); } static void fin_deferred_cb(struct ll_queue* q, void* arg) { @@ -313,8 +354,7 @@ static void write_queue_fetch_cb(struct ll_queue* q, void* arg) { struct ll_entry* e = queue_data_get(q); if (!e) { - uasync_set_socket_write(tc->ua, tc->socket_id, 0); - tc->write_monitor = 0; + if (sync_socket_monitoring(tc) < 0) return; DEBUG_TRACE(DEBUG_CATEGORY_SOCKET, "tcp_io: wq_fetch POLLOUT OFF fd=%d (queue empty)", (int)tc->sock); queue_resume_callback(q); notify_flushed(tc); @@ -370,8 +410,7 @@ static void write_queue_fetch_cb(struct ll_queue* q, void* arg) { tc->write_offset = 0; memory_pool_free(tc->data_pool, e->dgram); queue_entry_free(e); - uasync_set_socket_write(tc->ua, tc->socket_id, 1); - tc->write_monitor = 1; + if (sync_socket_monitoring(tc) < 0) return; DEBUG_TRACE(DEBUG_CATEGORY_SOCKET, "tcp_io: wq_fetch POLLOUT ON fd=%d (partial send, wbuf=%zu bytes)", (int)tc->sock, tc->write_len); } else if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) { tc->write_buf = memory_pool_alloc(tc->data_pool); @@ -387,8 +426,7 @@ static void write_queue_fetch_cb(struct ll_queue* q, void* arg) { tc->write_offset = 0; memory_pool_free(tc->data_pool, e->dgram); queue_entry_free(e); - uasync_set_socket_write(tc->ua, tc->socket_id, 1); - tc->write_monitor = 1; + if (sync_socket_monitoring(tc) < 0) return; DEBUG_TRACE(DEBUG_CATEGORY_SOCKET, "tcp_io: wq_fetch POLLOUT ON fd=%d (EAGAIN, wbuf=%zu bytes)", (int)tc->sock, tc->write_len); } else { DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: send error fd=%d errno=%d", (int)tc->sock, errno); @@ -427,10 +465,7 @@ static void write_cb(socket_t sock, void* arg) { if (flush_write_buf(tc)) return; if (!tc->write_buf) { DEBUG_TRACE(DEBUG_CATEGORY_SOCKET, "tcp_io: write_cb flushed fd=%d wmon=%d (resume WQ cb)", (int)tc->sock, tc->write_monitor); - if (tc->write_queue->waiter_defer) { - uasync_set_socket_write(tc->ua, tc->socket_id, 0); - tc->write_monitor = 0; - } + if (sync_socket_monitoring(tc) < 0) return; queue_resume_callback(tc->write_queue); notify_flushed(tc); } @@ -446,33 +481,13 @@ static void error_cb(socket_t sock, void* arg) { if (!tc || tc->sock == SOCKET_INVALID) return; int so_err = 0; socklen_t so_len = sizeof(so_err); if (getsockopt(tc->sock, SOL_SOCKET, SO_ERROR, (char*)&so_err, &so_len) == 0 && so_err == 0) { - // Peer полностью закрыл свой сокет (EPOLLHUP) после того, как FIN уже прочитан (fin_remote=1). - // EPOLLHUP — level-triggered и «always reported»: если оставить сокет в epoll, каждая итерация - // epoll_wait мгновенно вернёт HUP и error_cb зациклится на 100% CPU. Закрываем сокет. - if (tc->fin_remote && tc->fin_local) { - // Обе TCP-половины закрыты, но владелец ещё может передавать ранее прочитанные данные. - // Убираем level-triggered HUP из poll; CLOSE остаётся решением владельца. - DEBUG_DEBUG(DEBUG_CATEGORY_SOCKET, "tcp_io: both FINs fd=%d — detach poll, await owner drain", (int)tc->sock); - if (tc->socket_id) uasync_remove_socket_t(tc->ua, tc->sock); - tc->socket_id = NULL; tc->write_monitor = 0; - return; - } - if (tc->fin_remote) { - DEBUG_DEBUG(DEBUG_CATEGORY_SOCKET, "tcp_io: async HUP (graceful) fd=%d — peer fully closed after FIN, closing", - (int)tc->sock); - tcp_conn_push_close(tc); - return; - } - // Грациозное закрытие peer (FIN, EPOLLHUP) при приостановленном чтении: FIN пришёл не как - // EPOLLIN (чтение отключено backpressure-ом), а как HUP. Возобновляем чтение, чтобы - // read_cb дослил остаток и прочитал FIN (recv()==0 → fin_remote → отложенный on_fin). - DEBUG_DEBUG(DEBUG_CATEGORY_SOCKET, "tcp_io: async HUP (graceful) fd=%d fin_r=%d fin_l=%d read_paused=%d rq=%d — resume read", - (int)tc->sock, tc->fin_remote, tc->fin_local, tc->read_paused, - tc->read_queue ? tc->read_queue->count : 0); - queue_waiter_cancel(tc->read_queue, &tc->read_waiter); - uasync_set_socket_read(tc->ua, tc->socket_id, 1); - tc->read_paused = 0; - read_cb(tc->sock, tc); + if (!tc->hup_received) + DEBUG_DEBUG(DEBUG_CATEGORY_SOCKET, "tcp_io: HUP fd=%d fin_r=%u fin_l=%u paused=%u stopped=%u rq=%d", + (int)tc->sock, tc->fin_remote, tc->fin_local, tc->read_paused, tc->read_stopped, tc->read_queue->count); + tc->hup_received = 1; + if (tc->write_buf && flush_write_buf(tc)) return; + if (!tc->fin_remote && !tc->read_paused && !tc->read_stopped) read_cb(tc->sock, tc); + if (!tc->destroyed && !tc->error && !tc->closed) sync_socket_monitoring(tc); return; } DEBUG_ERROR(DEBUG_CATEGORY_SOCKET, "tcp_io: async error fd=%d connected=%d err=%d so_err=%d fin_r=%d fin_l=%d closed=%d wmon=%d", @@ -516,9 +531,9 @@ void tcp_conn_set_flushed(struct tcp_conn* tc, void (*on_flushed)(struct tcp_con void tcp_conn_pause_read(struct tcp_conn* tc) { if (!tc || tc->sock == SOCKET_INVALID) return; - uasync_set_socket_read(tc->ua, tc->socket_id, 0); queue_waiter_cancel(tc->read_queue, &tc->read_waiter); - tc->read_paused = 1; + tc->read_stopped = 1; tc->read_paused = 1; + sync_socket_monitoring(tc); } // ==================================================================== diff --git a/lib/tcp_io.h b/lib/tcp_io.h index 4142d082..658e8638 100644 --- a/lib/tcp_io.h +++ b/lib/tcp_io.h @@ -53,6 +53,8 @@ struct tcp_conn { int read_low_water; // Порог очереди для возобновления чтения int rcvbuf_size; // Запрошенный SO_RCVBUF; <=0 — не настраивать буферы uint8_t read_paused; // Чтение отключено backpressure или pause_read + uint8_t read_stopped; // Владелец запретил чтение; low_water/HUP не снимает запрет + uint8_t hup_received; // Получен HUP с SO_ERROR=0; idle registration снимается до готовности очереди uint8_t write_monitor; // 1 = EPOLLOUT активен uint8_t connected; diff --git a/tests/test_tcp_io.c b/tests/test_tcp_io.c index e6ea34d1..90547220 100644 --- a/tests/test_tcp_io.c +++ b/tests/test_tcp_io.c @@ -832,6 +832,64 @@ static void test_fin_before_close(void) { TEST_PASS(); } +// Закрытый peer держит HUP даже при выключенном IN: consumer не должен терять ограничение очереди. +static void test_hup_backpressure(void) { + for (int backend = 0; backend < 2; backend++) { + TEST_START("HUP respects high_water, restores registration and delivers DATA before FIN"); + size_t allocated = u_get_allocated_count(); + struct UASYNC* ua = uasync_create(); ASSERT_TRUE(ua != NULL, "create ua"); + ua->use_epoll = backend; + reset_counters(); + int sv[2]; ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0, "socketpair"); + for (int i = 0; i < 2; i++) ASSERT_EQ(socket_set_nonblocking(sv[i]), 0, "nonblocking"); + struct tcp_conn* tc = tcp_conn_create(ua, sv[0], 64, 64, 4, 1, 0, on_fin, on_error, NULL); + ASSERT_TRUE(tc != NULL, "create tcp"); + uint8_t bytes[1024]; for (size_t i = 0; i < sizeof(bytes); i++) bytes[i] = (uint8_t)i; + ASSERT_EQ(send(sv[1], bytes, sizeof(bytes), MSG_NOSIGNAL), sizeof(bytes), "send peer data"); + close(sv[1]); + for (int i = 0; i < 30; i++) uasync_poll(ua, 0); + ASSERT_EQ(tc->read_queue->count, 4, "HUP bypassed high water"); + ASSERT_TRUE(tc->read_paused && tc->hup_received && !tc->socket_id, "HUP not parked"); + ASSERT_EQ(g_fin_count, 0, "FIN before buffered DATA consumed"); + size_t received = 0; + for (int i = 0; i < 200 && !g_fin_count; i++) { + struct ll_entry* e = queue_data_get(tc->read_queue); + if (e) { + ASSERT_TRUE(received + e->len <= sizeof(bytes), "extra data"); + ASSERT_EQ(memcmp(e->dgram, bytes + received, e->len), 0, "corrupted data after HUP"); + received += e->len; memory_pool_free(tc->data_pool, e->dgram); queue_entry_free(e); + queue_resume_callback(tc->read_queue); + } + uasync_poll(ua, 0); + ASSERT_TRUE(tc->read_queue->count <= 4, "restored HUP bypassed high water"); + } + ASSERT_EQ(received, sizeof(bytes), "missing HUP data"); + ASSERT_EQ(g_fin_count, 1, "FIN missing/duplicated"); + ASSERT_EQ(g_error_count, 0, "unexpected socket error"); + tcp_conn_destroy(tc); uasync_drain_immediate(ua); + ASSERT_EQ(ua->socket_alloc_count, ua->socket_free_count, "socket registration leaked"); + uasync_destroy(ua, 0); ASSERT_EQ(u_get_allocated_count(), allocated, "HUP memory leak"); + TEST_PASS(); + + TEST_START("HUP keeps owner pause and pending write independent"); + ua = uasync_create(); ASSERT_TRUE(ua != NULL, "create ua"); ua->use_epoll = backend; + reset_counters(); + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, sv), 0, "socketpair"); + for (int i = 0; i < 2; i++) ASSERT_EQ(socket_set_nonblocking(sv[i]), 0, "nonblocking"); + tc = tcp_conn_create(ua, sv[0], 64, 64, 4, 1, 0, on_fin, on_error, NULL); + ASSERT_TRUE(tc != NULL, "create tcp"); tcp_conn_pause_read(tc); + ASSERT_EQ(push_write(tc, bytes, 64), 0, "write while owner paused"); + uint8_t reply[64]; ASSERT_EQ(recv(sv[1], reply, sizeof(reply), 0), sizeof(reply), "paused read blocked write"); + ASSERT_EQ(memcmp(bytes, reply, sizeof(reply)), 0, "write corrupted"); + ASSERT_EQ(send(sv[1], bytes, 64, MSG_NOSIGNAL), 64, "peer send"); close(sv[1]); + for (int i = 0; i < 30; i++) uasync_poll(ua, 0); + ASSERT_TRUE(tc->read_stopped && tc->hup_received && !tc->socket_id, "manual pause not parked"); + ASSERT_EQ(tc->read_queue->count, 0, "HUP undid manual pause"); + tcp_conn_destroy(tc); uasync_drain_immediate(ua); uasync_destroy(ua, 0); + ASSERT_EQ(u_get_allocated_count(), allocated, "manual HUP memory leak"); TEST_PASS(); + } +} + #endif int main(void) { @@ -866,6 +924,7 @@ int main(void) { #endif test_double_push_close(); test_fin_before_close(); + test_hup_backpressure(); DEBUG_INFO(DEBUG_CATEGORY_SYS, "=== Test Statistics ==="); DEBUG_INFO(DEBUG_CATEGORY_SYS, "Tests run: %d", tests_run); From f0629ea679d82231f4929a9b7fb8d82cb78494e4 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:29:45 +0200 Subject: [PATCH 14/25] Exercise orderly and abrupt TLS shutdown in proxy integration --- tests/test_icmp_proxy.c | 2 +- tests/test_socks_http_proxy.c | 14 ++++++++++++-- tests/test_udp_proxy.c | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/test_icmp_proxy.c b/tests/test_icmp_proxy.c index 39df9179..80ea19d2 100644 --- a/tests/test_icmp_proxy.c +++ b/tests/test_icmp_proxy.c @@ -66,7 +66,7 @@ static const char* cfg_node_exit(void) { "tun_ifname=tun98\n" "[server: s1]\naddr=127.0.0.1:0\ntype=public\n" "[allowed_keys]\nallow_all=1\n" - "[tcp_proxy_server]\nenabled=yes\n"); + "[tcp_proxy_server]\nconnect_timeout=2000\n"); return buf; } diff --git a/tests/test_socks_http_proxy.c b/tests/test_socks_http_proxy.c index 740b137b..dbe3ea98 100644 --- a/tests/test_socks_http_proxy.c +++ b/tests/test_socks_http_proxy.c @@ -173,10 +173,20 @@ static int start_https_server(int* port, const char* dir, const char* cert, cons " self.send_header('Allow','GET,HEAD,POST,OPTIONS')\n" " self.send_header('Content-Length','0')\n" " self.end_headers()\n" + "class S(HTTPServer):\n" + " def shutdown_request(self,request):\n" + " if os.environ.get('UTUN_TEST_TLS_ABRUPT'):\n" + " return super().shutdown_request(request)\n" + " try:\n" + " request.settimeout(2.0)\n" + " plain=request.unwrap();plain.close()\n" + " except (ssl.SSLError,OSError) as e:\n" + " sys.stderr.write(f'HTTPS_SHUTDOWN_ERR: {e}\\n')\n" + " request.close()\n" "os.chdir(sys.argv[3])\n" "ctx=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)\n" "ctx.load_cert_chain(sys.argv[1],sys.argv[2])\n" - "httpd=HTTPServer(('127.0.0.1',int(sys.argv[4])),H)\n" + "httpd=S(('127.0.0.1',int(sys.argv[4])),H)\n" "httpd.socket=ctx.wrap_socket(httpd.socket,server_side=True)\n" "httpd.timeout=None\n" "ready=int(sys.argv[5]);os.write(ready,httpd.server_port.to_bytes(2,'big'));os.close(ready)\n" @@ -223,7 +233,7 @@ static char* make_cfg_server(void) { "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" "[server: s1]\naddr=127.0.0.1:%d\ntype=public\n" "[allowed_keys]\nallow_all=1\n" - "[tcp_proxy_server]\nenabled=yes\n", g_srv_port); + "[tcp_proxy_server]\nconnect_timeout=2000\n", g_srv_port); return buf; } diff --git a/tests/test_udp_proxy.c b/tests/test_udp_proxy.c index 1f2e6a5a..e3767bdd 100644 --- a/tests/test_udp_proxy.c +++ b/tests/test_udp_proxy.c @@ -65,7 +65,7 @@ static const char* cfg_node_exit(void) { "tun_ifname=tun98\n" "[server: s1]\naddr=127.0.0.1:0\ntype=public\n" "[allowed_keys]\nallow_all=1\n" - "[tcp_proxy_server]\nenabled=yes\n"); + "[tcp_proxy_server]\nconnect_timeout=2000\n"); return buf; } From c4eddfe50e59b52bd8d9b5e780eb97224af3a628 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:34:51 +0200 Subject: [PATCH 15/25] Cover proxy startup rollback and datagram socket failures --- tests/Makefile.am | 8 ++ tests/test_proxy_lifecycle.c | 200 +++++++++++++++++++++++++++++++++ tests/test_proxy_regressions.c | 2 + 3 files changed, 210 insertions(+) create mode 100644 tests/test_proxy_lifecycle.c diff --git a/tests/Makefile.am b/tests/Makefile.am index 94a5d480..7ad32aa5 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -1130,3 +1130,11 @@ test_reality_tls_LDADD = $(LIBUTUN) -lssl $(COMMON_LIBS) test_tun_if_SOURCES = test_tun_if.c $(async_regression_sources) test_tun_if_LDADD = $(LIBUTUN) $(COMMON_LIBS) test_tun_if_LDFLAGS = $(async_regression_ldflags) -Wl,--wrap=tun_platform_init -Wl,--wrap=tun_platform_write -Wl,--wrap=socketpair + +if OS_LINUX +check_PROGRAMS += test_proxy_lifecycle +test_proxy_lifecycle_SOURCES = test_proxy_lifecycle.c $(async_regression_sources) +test_proxy_lifecycle_LDADD = $(LIBUTUN) $(COMMON_LIBS) +test_proxy_lifecycle_LDFLAGS = $(async_regression_ldflags) -Wl,--wrap=socket -Wl,--wrap=bind -Wl,--wrap=sendto \ + -Wl,--wrap=socket_set_nonblocking -Wl,--wrap=uasync_add_socket_t -Wl,--wrap=uasync_set_timeout +endif diff --git a/tests/test_proxy_lifecycle.c b/tests/test_proxy_lifecycle.c new file mode 100644 index 00000000..8cd5c258 --- /dev/null +++ b/tests/test_proxy_lifecycle.c @@ -0,0 +1,200 @@ +/* Отказы реальных UDP-сокетов, атомарный запуск и независимое владение TCP/UDP/ICMP. */ +#include "async_regression.h" +#include "../src/utun_instance.h" +#include "../src/proxy/udp_proxy.h" +#include "../src/proxy/icmp_proxy.h" +#include "../src/proxy/tcp_proxy_client.h" +#include "../src/proxy/socks_proxy.h" +#include "../src/tun_if.h" +#include "../lib/platform_compat.h" +#include +#include + +static int fail_socket, deny_raw, mock_raw, raw_fd = -1, fail_nonblock, fail_bind, fail_read, fail_timer, send_mode; +static void (*expiry_cb)(void*); +static void* expiry_arg; + +int __real_socket(int, int, int); +int __wrap_socket(int domain, int type, int protocol) { + if ((deny_raw && type == SOCK_RAW) || fail_socket) { + fail_socket = 0; errno = type == SOCK_RAW ? EPERM : EMFILE; return -1; + } + if (mock_raw && type == SOCK_RAW) return raw_fd = __real_socket(AF_INET, SOCK_DGRAM, 0); + return __real_socket(domain, type, protocol); +} +int __real_socket_set_nonblocking(socket_t); +int __wrap_socket_set_nonblocking(socket_t fd) { + if (fail_nonblock) { fail_nonblock = 0; errno = EIO; return -1; } + return __real_socket_set_nonblocking(fd); +} +int __real_bind(int, const struct sockaddr*, socklen_t); +int __wrap_bind(int fd, const struct sockaddr* addr, socklen_t len) { + if (fail_bind) { fail_bind = 0; errno = EADDRNOTAVAIL; return -1; } + return __real_bind(fd, addr, len); +} +void* __real_uasync_add_socket_t(struct UASYNC*, socket_t, socket_t_callback_t, socket_t_callback_t, + socket_t_callback_t, const char*, void*); +void* __wrap_uasync_add_socket_t(struct UASYNC* ua, socket_t fd, socket_t_callback_t read, socket_t_callback_t write, + socket_t_callback_t error, const char* name, void* arg) { + if (fail_read && (!strcmp(name, "udp_flow") || !strcmp(name, "icmp_raw"))) { fail_read = 0; return NULL; } + return __real_uasync_add_socket_t(ua, fd, read, write, error, name, arg); +} +void* __real_uasync_set_timeout(struct UASYNC*, int, void*, timeout_callback_t, const char*); +void* __wrap_uasync_set_timeout(struct UASYNC* ua, int tb, void* arg, timeout_callback_t cb, const char* name) { + if (!strcmp(name, "udp_expire") || !strcmp(name, "icmp_expire")) { + expiry_cb = cb; expiry_arg = arg; + if (fail_timer) { fail_timer = 0; return NULL; } + } + return __real_uasync_set_timeout(ua, tb, arg, cb, name); +} +ssize_t __real_sendto(int, const void*, size_t, int, const struct sockaddr*, socklen_t); +ssize_t __wrap_sendto(int fd, const void* data, size_t len, int flags, const struct sockaddr* addr, socklen_t alen) { + if (send_mode) { + int mode = send_mode; send_mode = 0; + if (mode == 3) return len ? (ssize_t)len - 1 : 1; + errno = mode == 1 ? EAGAIN : mode == 2 ? EMSGSIZE : EINTR; return -1; + } + if (mock_raw && fd == raw_fd) return (ssize_t)len; + return __real_sendto(fd, data, len, flags, addr, alen); +} + +struct fixture { struct UASYNC* ua; struct UTUN_INSTANCE* inst; struct utun_config config; }; +static int setup(struct fixture* f) { + memset(f, 0, sizeof(*f)); f->ua = uasync_create(); CHECK(f->ua); + f->inst = u_calloc(1, sizeof(*f->inst)); CHECK(f->inst); + f->inst->ua = f->ua; f->inst->config = &f->config; f->inst->node_id = 1; + f->config.global.tcp_proxy_server_enabled = 1; + CHECK_EQ(tcp_proxy_server_init(f->inst), 0); + return 0; +} +static void cleanup(struct fixture* f) { + if (f->inst->tcp_proxy_client) tcp_proxy_client_destroy(f->inst->tcp_proxy_client); + tcp_proxy_server_destroy(f->inst); udp_proxy_destroy(f->inst); icmp_proxy_destroy(f->inst); + uasync_drain_immediate(f->ua); u_free(f->inst); uasync_destroy(f->ua, 0); +} +static struct ll_entry* request(int icmp, uint16_t port, size_t len) { + struct ll_entry* e = ll_alloc_lldgram(UDP_PROXY_RECV_HDR_SIZE + len); + if (!e) return NULL; + e->len = UDP_PROXY_RECV_HDR_SIZE + len; memset(e->dgram, 0, e->len); + uint64_t peer = 42, group = TOPO_GROUP_UTUN; + uint32_t ip = htonl(INADDR_LOOPBACK); uint16_t source = htons(12345); + e->dgram[0] = icmp ? ETCP_RT_ID_ICMP_PROXY : ETCP_RT_ID_UDP_PROXY; + memcpy(e->dgram + ROUTER_SVC_SRC_OFF, &peer, 8); memcpy(e->dgram + ROUTER_SVC_GROUP_OFF, &group, 8); + e->dgram[ROUTER_SVC_PAYLOAD_OFF] = 1; + memcpy(e->dgram + ROUTER_SVC_PAYLOAD_OFF + 1, &ip, 4); + memcpy(e->dgram + ROUTER_SVC_PAYLOAD_OFF + 5, icmp ? (void*)&ip : (void*)&source, icmp ? 4 : 2); + if (!icmp) { + memcpy(e->dgram + ROUTER_SVC_PAYLOAD_OFF + 7, &ip, 4); + memcpy(e->dgram + ROUTER_SVC_PAYLOAD_OFF + 11, &port, 2); + } + memset(e->dgram + UDP_PROXY_RECV_HDR_SIZE, 0x5a, len); + return e; +} + +// Каждый отказ проверяется до публикации flow, затем тот же tuple успешно создаётся заново. +static int udp_failures(int kind) { + struct fixture f; CHECK_EQ(setup(&f), 0); CHECK_EQ(udp_proxy_init(f.inst, f.ua), 0); + int peer = socket(AF_INET, SOCK_DGRAM, 0); CHECK(peer >= 0); CHECK_EQ(socket_set_nonblocking(peer), 0); + struct sockaddr_in addr = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + CHECK_EQ(bind(peer, (struct sockaddr*)&addr, sizeof(addr)), 0); + socklen_t size = sizeof(addr); CHECK_EQ(getsockname(peer, (struct sockaddr*)&addr, &size), 0); + struct ll_entry* e = request(0, addr.sin_port, kind == 10 ? 0 : 4); CHECK(e); + if (kind == 0) regression_fault_arm(FAULT_CALLOC, "udp_proxy.c", 1); + else if (kind == 1) fail_socket = 1; + else if (kind == 2) fail_nonblock = 1; + else if (kind == 3) fail_bind = 1; + else if (kind == 4) fail_read = 1; + else if (kind == 5) fail_timer = 1; + else if (kind < 10) send_mode = kind - 5; + struct ETCP_CONN conn = {.instance = f.inst}; udp_proxy_recv_cb(&conn, e); + regression_fault_reset(); + if (kind < 6) CHECK_EQ(f.inst->udp_proxy->flow_count, 0); + else CHECK_EQ(f.inst->udp_proxy->flow_count, 1); + if (kind < 6) CHECK(!f.inst->udp_proxy->expire_timer); + uint8_t buf[16]; + if (kind == 9 || kind == 10) CHECK_EQ(recv(peer, buf, sizeof(buf), 0), kind == 10 ? 0 : 4); + e = request(0, addr.sin_port, 4); CHECK(e); udp_proxy_recv_cb(&conn, e); + CHECK_EQ(f.inst->udp_proxy->flow_count, 1); CHECK(f.inst->udp_proxy->expire_timer); + CHECK_EQ(recv(peer, buf, sizeof(buf), 0), 4); + if (kind == 5) { + CHECK_EQ(uasync_cancel_timeout(f.ua, f.inst->udp_proxy->expire_timer), ERR_OK); + fail_timer = 1; expiry_cb(expiry_arg); + CHECK_EQ(f.inst->udp_proxy->flow_count, 0); CHECK(!f.inst->udp_proxy->expire_timer); + } + close(peer); cleanup(&f); return 0; +} + +// Raw-сокет эмулируется обычным UDP только для изоляции startup/request отказов без CAP_NET_RAW. +static int icmp_failures(int kind) { + struct fixture f; CHECK_EQ(setup(&f), 0); CHECK_EQ(icmp_proxy_init(f.inst, f.ua), 0); + struct icmp_proxy_ctx* ctx = f.inst->icmp_proxy; + if (kind == 0) deny_raw = 1; + else { mock_raw = 1; if (kind == 1) fail_nonblock = 1; if (kind == 2) fail_read = 1; } + int ret = icmp_proxy_enable_exit(f.inst); + CHECK(f.inst->icmp_proxy == ctx && f.inst->router_bindings.callbacks[ETCP_RT_ID_ICMP_PROXY] == icmp_proxy_recv_cb); + if (kind < 3) { + CHECK(ret < 0); CHECK_EQ(ctx->raw_sock, SOCKET_INVALID); CHECK(!ctx->raw_read_id); + deny_raw = 0; mock_raw = 1; CHECK_EQ(icmp_proxy_enable_exit(f.inst), 0); + } else CHECK_EQ(ret, 0); + CHECK_EQ(icmp_proxy_enable_exit(f.inst), 0); + CHECK(ctx->raw_sock != SOCKET_INVALID && ctx->raw_read_id); + if (kind == 3) { + struct ll_entry* e = request(1, 0, 4); CHECK(e); struct ETCP_CONN conn = {.instance = f.inst}; + fail_timer = 1; icmp_proxy_recv_cb(&conn, e); CHECK(!ctx->pending && !ctx->expire_timer); + e = request(1, 0, 4); CHECK(e); icmp_proxy_recv_cb(&conn, e); CHECK(ctx->pending && ctx->expire_timer); + CHECK_EQ(uasync_cancel_timeout(f.ua, ctx->expire_timer), ERR_OK); + fail_timer = 1; expiry_cb(expiry_arg); CHECK(!ctx->pending && !ctx->expire_timer); + } + int fd = ctx->raw_sock; + icmp_proxy_set_test_loopback(f.inst, 1); + CHECK_EQ(ctx->raw_sock, SOCKET_INVALID); CHECK(ctx->test_loopback); CHECK_EQ(fcntl(fd, F_GETFD), -1); + CHECK_EQ(icmp_proxy_enable_exit(f.inst), 0); + cleanup(&f); return 0; +} + +static int context_oom(int icmp) { + struct fixture f; CHECK_EQ(setup(&f), 0); + regression_fault_arm(FAULT_CALLOC, icmp ? "icmp_proxy.c" : "udp_proxy.c", 1); + int ret = icmp ? icmp_proxy_init(f.inst, f.ua) : udp_proxy_init(f.inst, f.ua); + CHECK(regression_fault_hit()); regression_fault_reset(); CHECK(ret < 0); + CHECK(!f.inst->icmp_proxy && !f.inst->udp_proxy); + CHECK(!f.inst->router_bindings.callbacks[icmp ? ETCP_RT_ID_ICMP_PROXY : ETCP_RT_ID_UDP_PROXY]); + CHECK_EQ(icmp ? icmp_proxy_init(f.inst, f.ua) : udp_proxy_init(f.inst, f.ua), 0); + cleanup(&f); return 0; +} + +static int ownership(int unused) { + (void)unused; + struct fixture f; CHECK_EQ(setup(&f), 0); + CHECK_EQ(udp_proxy_init(f.inst, f.ua), 0); CHECK_EQ(icmp_proxy_init(f.inst, f.ua), 0); + struct udp_proxy_ctx* udp = f.inst->udp_proxy; struct icmp_proxy_ctx* icmp = f.inst->icmp_proxy; + CHECK_EQ(tcp_proxy_server_init(f.inst), -1); CHECK(f.inst->tcp_proxy_server.enabled); + CHECK(!tcp_proxy_client_create(f.inst, f.ua, NULL, NULL, 1500, 1, NULL, 0, 42, 1, "127.0.0.1:0", 1, "bad")); + CHECK(!f.inst->router_bindings.callbacks[ETCP_RT_ID_TCP_PROXY_CLIENT]); + CHECK(f.inst->udp_proxy == udp && f.inst->icmp_proxy == icmp); + CHECK(!tcp_proxy_client_create(f.inst, f.ua, NULL, NULL, 1500, 1, NULL, 0, 42, 1, NULL, 0, NULL)); + struct tcp_proxy_client* p = tcp_proxy_client_create(f.inst, f.ua, NULL, NULL, 1500, 1, NULL, 0, 42, 1, "127.0.0.1:0", 0, NULL); + CHECK(p); f.inst->tcp_proxy_client = p; CHECK(socks_proxy_get_port(p->socks_listen)); + CHECK(!tcp_proxy_client_create(f.inst, f.ua, NULL, NULL, 1500, 1, NULL, 0, 42, 1, "127.0.0.1:0", 0, NULL)); + CHECK(f.inst->router_bindings.callbacks[ETCP_RT_ID_TCP_PROXY_CLIENT] == tcp_proxy_client_router_recv_cb); + tcp_proxy_client_destroy(p); f.inst->tcp_proxy_client = NULL; tcp_proxy_server_destroy(f.inst); + CHECK(f.inst->udp_proxy == udp && f.inst->icmp_proxy == icmp); + CHECK(f.inst->router_bindings.callbacks[ETCP_RT_ID_UDP_PROXY] == udp_proxy_recv_cb); + CHECK(f.inst->router_bindings.callbacks[ETCP_RT_ID_ICMP_PROXY] == icmp_proxy_recv_cb); + CHECK_EQ(tcp_proxy_server_init(f.inst), 0); cleanup(&f); return 0; +} + +int main(int argc, char** argv) { + const struct regression_case cases[] = { + {"UDP flow allocation", udp_failures, 0}, {"UDP socket", udp_failures, 1}, + {"UDP nonblocking", udp_failures, 2}, {"UDP bind", udp_failures, 3}, + {"UDP read registration", udp_failures, 4}, {"UDP expiry and renewal", udp_failures, 5}, + {"UDP EAGAIN", udp_failures, 6}, {"UDP EMSGSIZE", udp_failures, 7}, + {"UDP short send", udp_failures, 8}, {"UDP EINTR retry", udp_failures, 9}, + {"UDP empty datagram", udp_failures, 10}, {"UDP context OOM", context_oom, 0}, + {"ICMP context OOM", context_oom, 1}, {"ICMP unavailable raw", icmp_failures, 0}, + {"ICMP nonblocking", icmp_failures, 1}, {"ICMP read registration", icmp_failures, 2}, + {"ICMP expiry and renewal", icmp_failures, 3}, {"Shared contexts and listener rollback", ownership, 0} + }; + return regression_run(argc, argv, cases, sizeof(cases) / sizeof(cases[0])); +} diff --git a/tests/test_proxy_regressions.c b/tests/test_proxy_regressions.c index f2bd3c5d..39d83627 100644 --- a/tests/test_proxy_regressions.c +++ b/tests/test_proxy_regressions.c @@ -496,6 +496,7 @@ static void tun_tests(struct UTUN_INSTANCE* inst) { tcp_abort(pcb); CHECK(p->conn_count == 0); // IPv4 options смещают UDP-заголовок; последующий фрагмент не является UDP-запросом. + CHECK(udp_proxy_init(inst, ua) == 0); // Общий контекст создаёт владелец instance, не TCP client. uint8_t ip[36] = {0x46, 0, 0, 36, 0, 0, 0, 0, 64, 17}; uint32_t src = htonl(0x0a000002), dst = htonl(0xc0000201); memcpy(ip+12, &src, 4); memcpy(ip+16, &dst, 4); @@ -511,6 +512,7 @@ static void tun_tests(struct UTUN_INSTANCE* inst) { CHECK(message_count == 1 && messages[0].svc == ETCP_RT_ID_UDP_PROXY && messages[0].len == 12); CHECK(memcmp(messages[0].data+6, &dport, 2) == 0 && memcmp(messages[0].data+8, "data", 4) == 0); tcp_proxy_client_destroy(p); inst->tcp_proxy_client = NULL; pump(); + CHECK(inst->udp_proxy != NULL); udp_proxy_destroy(inst); puts("[PASS] TUN pending DATA survives FIN; aborted PCB freed; IPv4 options/fragment validation"); #endif } From bb2ef05157e1c0e10e2d5dc7c93000b2a85e75d5 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:36:22 +0200 Subject: [PATCH 16/25] Roll back required TUN mappings and verify instance startup failures --- src/proxy/tcp_proxy_client.c | 19 ++++++++++++++++--- tests/test_proxy_lifecycle.c | 24 +++++++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/proxy/tcp_proxy_client.c b/src/proxy/tcp_proxy_client.c index 917f129f..775dca24 100644 --- a/src/proxy/tcp_proxy_client.c +++ b/src/proxy/tcp_proxy_client.c @@ -752,6 +752,9 @@ struct tcp_proxy_client* tcp_proxy_client_create(struct UTUN_INSTANCE* inst, str } int need_tun = tun_name && tun_name[0] && tun_ip && tun_ip[0]; + if ((tun_name && tun_name[0]) != (tun_ip && tun_ip[0])) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: TUN requires both name and IP"); return NULL; + } if (!need_tun && !socks_enabled && !http_proxy_enabled) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "no proxy mode enabled"); return NULL; } @@ -784,10 +787,20 @@ struct tcp_proxy_client* tcp_proxy_client_create(struct UTUN_INSTANCE* inst, str int j; for (j = 0; j < mapping_count; j++) { uint16_t port_net = htons(mappings[j].local_port); struct tcp_pcb *lpcb = tcp_new(p->lwip); - if (!lpcb) continue; - tcp_bind(lpcb, INADDR_ANY, port_net); + if (!lpcb) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: mapping PCB allocation failed port=%u", mappings[j].local_port); + tcp_proxy_client_destroy(p); return NULL; + } + if (tcp_bind(lpcb, INADDR_ANY, port_net) != LERR_OK) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: mapping bind failed port=%u", mappings[j].local_port); + tcp_abort(lpcb); tcp_proxy_client_destroy(p); return NULL; + } struct tcp_pcb *listen_pcb = tcp_listen(lpcb); - if (listen_pcb) { tcp_arg(listen_pcb, p); tcp_accept(listen_pcb, tcp_proxy_client_accept_cb); } + if (!listen_pcb) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: mapping listen failed port=%u", mappings[j].local_port); + tcp_abort(lpcb); tcp_proxy_client_destroy(p); return NULL; + } + tcp_arg(listen_pcb, p); tcp_accept(listen_pcb, tcp_proxy_client_accept_cb); } } } diff --git a/tests/test_proxy_lifecycle.c b/tests/test_proxy_lifecycle.c index 8cd5c258..7c19dd87 100644 --- a/tests/test_proxy_lifecycle.c +++ b/tests/test_proxy_lifecycle.c @@ -184,6 +184,26 @@ static int ownership(int unused) { CHECK_EQ(tcp_proxy_server_init(f.inst), 0); cleanup(&f); return 0; } +// Конструктор instance сам откатывает proxy-ресурсы, сохраняя заимствованный config при отказе. +static int instance_rollback(int kind) { + struct UASYNC* ua = uasync_create(); CHECK(ua); + const char* text = "[global]\ntun_enabled=no\n" + "my_private_key=704f2e012c8fa8768130cb0f988a997dccb628372bc5ceccacc78dcbfec5916f\n" + "my_public_key=b3193173def895bd0fcea6f86af077c7d77216f10395275f627ac18242ec0f01\n" + "[server: s1]\naddr=127.0.0.1:0\ntype=public\n[tcp_proxy_server]\nconnect_timeout=2000\n"; + struct utun_config* cfg = parse_config_from_buf(text, strlen(text), "proxy-failure"); CHECK(cfg); + if (kind == 0) regression_fault_arm(FAULT_CALLOC, "udp_proxy.c", 1); + else if (kind == 1) regression_fault_arm(FAULT_CALLOC, "icmp_proxy.c", 1); + else { + cfg->global.tcp_proxy_client_socks_enabled = 1; + strcpy(cfg->global.tcp_proxy_client_socks_addr, "invalid"); + } + struct UTUN_INSTANCE* inst = utun_instance_create_from_config(ua, cfg); + if (kind < 2) CHECK(regression_fault_hit()); + regression_fault_reset(); CHECK(!inst); CHECK_EQ(cfg->global.tcp_proxy_server_enabled, 1); + free_config(cfg); uasync_destroy(ua, 0); return 0; +} + int main(int argc, char** argv) { const struct regression_case cases[] = { {"UDP flow allocation", udp_failures, 0}, {"UDP socket", udp_failures, 1}, @@ -194,7 +214,9 @@ int main(int argc, char** argv) { {"UDP empty datagram", udp_failures, 10}, {"UDP context OOM", context_oom, 0}, {"ICMP context OOM", context_oom, 1}, {"ICMP unavailable raw", icmp_failures, 0}, {"ICMP nonblocking", icmp_failures, 1}, {"ICMP read registration", icmp_failures, 2}, - {"ICMP expiry and renewal", icmp_failures, 3}, {"Shared contexts and listener rollback", ownership, 0} + {"ICMP expiry and renewal", icmp_failures, 3}, {"Shared contexts and listener rollback", ownership, 0}, + {"Instance UDP rollback", instance_rollback, 0}, {"Instance ICMP rollback", instance_rollback, 1}, + {"Instance listener rollback", instance_rollback, 2} }; return regression_run(argc, argv, cases, sizeof(cases) / sizeof(cases[0])); } From 1ce285c09b50d1e3a830d1f768ded0fc409c0bd7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:38:45 +0200 Subject: [PATCH 17/25] Preserve datagram bindings and verify optional ICMP behavior --- src/proxy/icmp_proxy.c | 3 +++ src/proxy/udp_proxy.c | 3 +++ tests/test_proxy_lifecycle.c | 26 +++++++++++++++++++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/proxy/icmp_proxy.c b/src/proxy/icmp_proxy.c index 2a2e7ee2..729fda7a 100644 --- a/src/proxy/icmp_proxy.c +++ b/src/proxy/icmp_proxy.c @@ -379,6 +379,9 @@ int icmp_proxy_init(struct UTUN_INSTANCE* inst, struct UASYNC* ua) { if (inst->icmp_proxy->ua == ua) return 0; DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: init attempted with another event loop"); return -EINVAL; } + if (inst->router_bindings.callbacks[ETCP_RT_ID_ICMP_PROXY]) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: service already belongs to another owner"); return -EINVAL; + } struct icmp_proxy_ctx* ctx = u_calloc(1, sizeof(struct icmp_proxy_ctx)); if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "icmp_proxy: context allocation failed"); return -ENOMEM; } ctx->inst = inst; ctx->ua = ua; ctx->raw_sock = SOCKET_INVALID; diff --git a/src/proxy/udp_proxy.c b/src/proxy/udp_proxy.c index 2247166b..00b7a1c3 100644 --- a/src/proxy/udp_proxy.c +++ b/src/proxy/udp_proxy.c @@ -305,6 +305,9 @@ int udp_proxy_init(struct UTUN_INSTANCE* inst, struct UASYNC* ua) { if (inst->udp_proxy->ua == ua) return 0; DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: init attempted with another event loop"); return -1; } + if (inst->router_bindings.callbacks[ETCP_RT_ID_UDP_PROXY]) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: service already belongs to another owner"); return -1; + } struct udp_proxy_ctx* ctx = u_calloc(1, sizeof(struct udp_proxy_ctx)); if (!ctx) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "udp_proxy: context allocation failed"); return -1; } ctx->inst = inst; ctx->ua = ua; diff --git a/tests/test_proxy_lifecycle.c b/tests/test_proxy_lifecycle.c index 7c19dd87..5f84b6c3 100644 --- a/tests/test_proxy_lifecycle.c +++ b/tests/test_proxy_lifecycle.c @@ -1,6 +1,8 @@ /* Отказы реальных UDP-сокетов, атомарный запуск и независимое владение TCP/UDP/ICMP. */ #include "async_regression.h" #include "../src/utun_instance.h" +#include "../src/transport_layer/etcp.h" +#include "../src/transport_layer/etcp_connections.h" #include "../src/proxy/udp_proxy.h" #include "../src/proxy/icmp_proxy.h" #include "../src/proxy/tcp_proxy_client.h" @@ -134,6 +136,14 @@ static int icmp_failures(int kind) { CHECK(f.inst->icmp_proxy == ctx && f.inst->router_bindings.callbacks[ETCP_RT_ID_ICMP_PROXY] == icmp_proxy_recv_cb); if (kind < 3) { CHECK(ret < 0); CHECK_EQ(ctx->raw_sock, SOCKET_INVALID); CHECK(!ctx->raw_read_id); + if (kind == 0) { + struct tun_if tun = {0}; tun.input_queue = queue_new(f.ua, 0, 0, 0, "icmp_reply"); CHECK(tun.input_queue); + struct tcp_proxy_client client = {.tun = &tun, .via_node_id = 42}; f.inst->tcp_proxy_client = &client; + struct ll_entry* e = request(1, 0, 4); CHECK(e); e->dgram[ROUTER_SVC_PAYLOAD_OFF] = ICMP_PROXY_SUBCMD_REPLY; + struct ETCP_CONN conn = {.instance = f.inst}; icmp_proxy_recv_cb(&conn, e); + e = queue_data_get(tun.input_queue); CHECK(e && e->len == 33 && e->dgram[0] == 4); + queue_dgram_free(e); queue_entry_free(e); queue_free(tun.input_queue); f.inst->tcp_proxy_client = NULL; + } deny_raw = 0; mock_raw = 1; CHECK_EQ(icmp_proxy_enable_exit(f.inst), 0); } else CHECK_EQ(ret, 0); CHECK_EQ(icmp_proxy_enable_exit(f.inst), 0); @@ -172,6 +182,12 @@ static int ownership(int unused) { CHECK(!tcp_proxy_client_create(f.inst, f.ua, NULL, NULL, 1500, 1, NULL, 0, 42, 1, "127.0.0.1:0", 1, "bad")); CHECK(!f.inst->router_bindings.callbacks[ETCP_RT_ID_TCP_PROXY_CLIENT]); CHECK(f.inst->udp_proxy == udp && f.inst->icmp_proxy == icmp); + struct tcp_proxy_client_mapping_config maps[2] = { + {.local_port = 80, .remote_port = 80, .remote_ip = "127.0.0.1"}, + {.local_port = 80, .remote_port = 81, .remote_ip = "127.0.0.1"} + }; + CHECK(!tcp_proxy_client_create(f.inst, f.ua, "proxy_test", "10.0.0.1", 1500, 1, maps, 2, 42, 0, NULL, 0, NULL)); + CHECK(!f.inst->router_bindings.callbacks[ETCP_RT_ID_TCP_PROXY_CLIENT]); CHECK(!tcp_proxy_client_create(f.inst, f.ua, NULL, NULL, 1500, 1, NULL, 0, 42, 1, NULL, 0, NULL)); struct tcp_proxy_client* p = tcp_proxy_client_create(f.inst, f.ua, NULL, NULL, 1500, 1, NULL, 0, 42, 1, "127.0.0.1:0", 0, NULL); CHECK(p); f.inst->tcp_proxy_client = p; CHECK(socks_proxy_get_port(p->socks_listen)); @@ -194,11 +210,18 @@ static int instance_rollback(int kind) { struct utun_config* cfg = parse_config_from_buf(text, strlen(text), "proxy-failure"); CHECK(cfg); if (kind == 0) regression_fault_arm(FAULT_CALLOC, "udp_proxy.c", 1); else if (kind == 1) regression_fault_arm(FAULT_CALLOC, "icmp_proxy.c", 1); + else if (kind == 3) deny_raw = 1; + else if (kind == 4) { mock_raw = 1; fail_read = 1; } else { cfg->global.tcp_proxy_client_socks_enabled = 1; strcpy(cfg->global.tcp_proxy_client_socks_addr, "invalid"); } struct UTUN_INSTANCE* inst = utun_instance_create_from_config(ua, cfg); + if (kind == 3) { + CHECK(inst && inst->tcp_proxy_server.enabled && inst->udp_proxy && inst->icmp_proxy); + CHECK_EQ(inst->icmp_proxy->raw_sock, SOCKET_INVALID); + utun_instance_destroy(inst); uasync_destroy(ua, 0); return 0; + } if (kind < 2) CHECK(regression_fault_hit()); regression_fault_reset(); CHECK(!inst); CHECK_EQ(cfg->global.tcp_proxy_server_enabled, 1); free_config(cfg); uasync_destroy(ua, 0); return 0; @@ -216,7 +239,8 @@ int main(int argc, char** argv) { {"ICMP nonblocking", icmp_failures, 1}, {"ICMP read registration", icmp_failures, 2}, {"ICMP expiry and renewal", icmp_failures, 3}, {"Shared contexts and listener rollback", ownership, 0}, {"Instance UDP rollback", instance_rollback, 0}, {"Instance ICMP rollback", instance_rollback, 1}, - {"Instance listener rollback", instance_rollback, 2} + {"Instance listener rollback", instance_rollback, 2}, {"Instance optional ICMP", instance_rollback, 3}, + {"Instance raw registration rollback", instance_rollback, 4} }; return regression_run(argc, argv, cases, sizeof(cases) / sizeof(cases[0])); } From d0edacb7b91c895b8c8053e6d9695678042ca60a Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:39:50 +0200 Subject: [PATCH 18/25] Test HUP registration failure and teardown with pending TCP writes --- tests/test_proxy_lifecycle.c | 43 ++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/tests/test_proxy_lifecycle.c b/tests/test_proxy_lifecycle.c index 5f84b6c3..9be89301 100644 --- a/tests/test_proxy_lifecycle.c +++ b/tests/test_proxy_lifecycle.c @@ -38,7 +38,8 @@ void* __real_uasync_add_socket_t(struct UASYNC*, socket_t, socket_t_callback_t, socket_t_callback_t, const char*, void*); void* __wrap_uasync_add_socket_t(struct UASYNC* ua, socket_t fd, socket_t_callback_t read, socket_t_callback_t write, socket_t_callback_t error, const char* name, void* arg) { - if (fail_read && (!strcmp(name, "udp_flow") || !strcmp(name, "icmp_raw"))) { fail_read = 0; return NULL; } + if ((fail_read == 1 && (!strcmp(name, "udp_flow") || !strcmp(name, "icmp_raw"))) || + (fail_read == 2 && !strcmp(name, "tcp_io"))) { fail_read = 0; return NULL; } return __real_uasync_add_socket_t(ua, fd, read, write, error, name, arg); } void* __real_uasync_set_timeout(struct UASYNC*, int, void*, timeout_callback_t, const char*); @@ -227,6 +228,43 @@ static int instance_rollback(int kind) { free_config(cfg); uasync_destroy(ua, 0); return 0; } +struct tcp_owner { struct tcp_conn* tc; int error; }; +static void tcp_failed(struct tcp_conn* tc, int err, void* arg) { + struct tcp_owner* owner = arg; owner->tc = NULL; owner->error = err; tcp_conn_destroy(tc); +} + +// Проверяем destroy из ошибки восстановления poll и независимую ошибку ожидающей записи при HUP. +static int tcp_hup_failure(int writing) { + struct UASYNC* ua = uasync_create(); CHECK(ua); + int fds[2]; CHECK_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); + CHECK_EQ(socket_set_nonblocking(fds[0]), 0); CHECK_EQ(socket_set_nonblocking(fds[1]), 0); + int sndbuf = 1024; CHECK_EQ(setsockopt(fds[0], SOL_SOCKET, SO_SNDBUF, &sndbuf, sizeof(sndbuf)), 0); + struct tcp_owner owner = {0}; + owner.tc = tcp_conn_create(ua, fds[0], 64, 32768, 2, 0, 0, NULL, tcp_failed, &owner); CHECK(owner.tc); + if (writing) { + tcp_conn_pause_read(owner.tc); + struct ll_entry* e = queue_entry_new_from_pool(owner.tc->entry_pool); CHECK(e); + e->dgram = memory_pool_alloc(owner.tc->data_pool); CHECK(e->dgram); memset(e->dgram, 0, 32768); e->len = 32768; + queue_data_put(owner.tc->write_queue, e); CHECK(owner.tc && owner.tc->write_buf); + close(fds[1]); + for (int i = 0; i < 20 && owner.tc; i++) uasync_poll(ua, 0); + CHECK(!owner.tc && (owner.error == EPIPE || owner.error == ECONNRESET)); + } else { + uint8_t bytes[512] = {0}; CHECK_EQ(send(fds[1], bytes, sizeof(bytes), MSG_NOSIGNAL), sizeof(bytes)); + close(fds[1]); for (int i = 0; i < 10; i++) uasync_poll(ua, 0); + CHECK(owner.tc && owner.tc->hup_received && !owner.tc->socket_id); + CHECK_EQ(owner.tc->read_queue->count, 2); fail_read = 2; + while (owner.tc && owner.tc->read_queue->head) { + struct memory_pool* pool = owner.tc->data_pool; + struct ll_entry* e = queue_data_get(owner.tc->read_queue); CHECK(e); + memory_pool_free(pool, e->dgram); queue_entry_free(e); + } + CHECK(!owner.tc && owner.error == ENOMEM && !fail_read); + } + uasync_drain_immediate(ua); CHECK_EQ(ua->socket_alloc_count, ua->socket_free_count); + uasync_destroy(ua, 0); return 0; +} + int main(int argc, char** argv) { const struct regression_case cases[] = { {"UDP flow allocation", udp_failures, 0}, {"UDP socket", udp_failures, 1}, @@ -240,7 +278,8 @@ int main(int argc, char** argv) { {"ICMP expiry and renewal", icmp_failures, 3}, {"Shared contexts and listener rollback", ownership, 0}, {"Instance UDP rollback", instance_rollback, 0}, {"Instance ICMP rollback", instance_rollback, 1}, {"Instance listener rollback", instance_rollback, 2}, {"Instance optional ICMP", instance_rollback, 3}, - {"Instance raw registration rollback", instance_rollback, 4} + {"Instance raw registration rollback", instance_rollback, 4}, + {"TCP HUP restore failure", tcp_hup_failure, 0}, {"TCP HUP pending write failure", tcp_hup_failure, 1} }; return regression_run(argc, argv, cases, sizeof(cases) / sizeof(cases[0])); } From 2894e0601402b632f3a4f1baa195a1aef6c6dbd0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:41:16 +0200 Subject: [PATCH 19/25] Document HUP polling contract and isolate ICMP fault injection --- lib/tcp_io.h | 5 ++++- tests/test_proxy_lifecycle.c | 6 ++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/tcp_io.h b/lib/tcp_io.h index 658e8638..710369d8 100644 --- a/lib/tcp_io.h +++ b/lib/tcp_io.h @@ -27,6 +27,9 @@ // Для записи выделить entry из tc->entry_pool и dgram из tc->data_pool, заполнить len и dgram_pool; // затем queue_data_put(tc->write_queue, e). Длина должна помещаться в буфер и uint16_t. // После принятого FIN новые данные не добавлять; закрывать сокет через push_close/destroy. +// HUP не снимает backpressure/явную pause_read: регистрация без доступной работы временно удаляется, +// low_water восстанавливает её с новым поколением uasync. FD/очереди сохраняются до решения владельца. +// Ошибка восстановления/ожидания очереди вызывает on_error, как остальные ошибки TCP. #ifndef TCP_IO_H #define TCP_IO_H @@ -52,7 +55,7 @@ struct tcp_conn { int read_high_water; // Число входных entry для приостановки чтения int read_low_water; // Порог очереди для возобновления чтения int rcvbuf_size; // Запрошенный SO_RCVBUF; <=0 — не настраивать буферы - uint8_t read_paused; // Чтение отключено backpressure или pause_read + uint8_t read_paused; // Чтение приостановлено; low_water снимает только автоматическую паузу uint8_t read_stopped; // Владелец запретил чтение; low_water/HUP не снимает запрет uint8_t hup_received; // Получен HUP с SO_ERROR=0; idle registration снимается до готовности очереди diff --git a/tests/test_proxy_lifecycle.c b/tests/test_proxy_lifecycle.c index 9be89301..f9d7f308 100644 --- a/tests/test_proxy_lifecycle.c +++ b/tests/test_proxy_lifecycle.c @@ -18,10 +18,11 @@ static void* expiry_arg; int __real_socket(int, int, int); int __wrap_socket(int domain, int type, int protocol) { - if ((deny_raw && type == SOCK_RAW) || fail_socket) { + int icmp = domain == AF_INET && type == SOCK_RAW && protocol == IPPROTO_ICMP; + if ((deny_raw && icmp) || fail_socket) { fail_socket = 0; errno = type == SOCK_RAW ? EPERM : EMFILE; return -1; } - if (mock_raw && type == SOCK_RAW) return raw_fd = __real_socket(AF_INET, SOCK_DGRAM, 0); + if (mock_raw && icmp) return raw_fd = __real_socket(AF_INET, SOCK_DGRAM, 0); return __real_socket(domain, type, protocol); } int __real_socket_set_nonblocking(socket_t); @@ -224,6 +225,7 @@ static int instance_rollback(int kind) { utun_instance_destroy(inst); uasync_destroy(ua, 0); return 0; } if (kind < 2) CHECK(regression_fault_hit()); + if (kind == 4) CHECK(!fail_read && raw_fd >= 0 && fcntl(raw_fd, F_GETFD) == -1); regression_fault_reset(); CHECK(!inst); CHECK_EQ(cfg->global.tcp_proxy_server_enabled, 1); free_config(cfg); uasync_destroy(ua, 0); return 0; } From 5914e26579316be38de05a5636bdf8d99f52c4e0 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:42:20 +0200 Subject: [PATCH 20/25] Update datagram and TCP exit documentation for explicit ownership --- src/proxy/icmp_proxy_doc.md | 21 ++++++++++++--------- src/proxy/tcp_proxy_server_doc.md | 10 +++++----- src/proxy/udp_proxy_doc.md | 14 ++++++++++---- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/proxy/icmp_proxy_doc.md b/src/proxy/icmp_proxy_doc.md index d2bbc085..152249d6 100644 --- a/src/proxy/icmp_proxy_doc.md +++ b/src/proxy/icmp_proxy_doc.md @@ -9,18 +9,19 @@ Каждый `UTUN_INSTANCE` владеет отдельным `inst->icmp_proxy`, глобального контекста нет. `init(inst, ua)` задаёт таймаут 50000 tb (5 с), случайное начало `next_token` и -сохраняет `server.enabled` в `is_exit`. Для exit открывает один неблокирующий -`AF_INET/SOCK_RAW/IPPROTO_ICMP` и регистрирует его чтение в uasync. +регистрирует общий binding. Raw socket отдельно запускает `icmp_proxy_enable_exit(inst)`: +один неблокирующий `AF_INET/SOCK_RAW/IPPROTO_ICMP` регистрируется в uasync до публикации. Повторный init сохраняет существующий контекст и возвращает 0. Ошибка создания -контекста или случайного token возвращает -1. Ошибка raw socket/регистрации -оставляет контекст без рабочего сокета, но init возвращает 0 и запрашивает router binding; -результат bind также не пробрасывается. Поэтому успешный init сам по себе не подтверждает -возможность отправки реального ping. Raw socket требует соответствующих прав ОС. +контекста/случайного token/binding возвращает отрицательный код без публикации контекста. +`enable_exit` возвращает -errno при отказе raw socket/nonblocking/регистрации, сохраняя +общий контекст и приём клиентских REPLY. Instance трактует отсутствие прав/поддержки raw +как отключение только ICMP exit с WARN; отказ обязательного ресурса прерывает создание instance. +TCP client/server не создают и не уничтожают общий контекст. `test_loopback` по умолчанию выключен. `icmp_proxy_set_test_loopback(inst, 1)` разрешает -виртуальный REPLY **только при отсутствии raw socket**; открытый сокет имеет приоритет, -флаг его не отключает. Без контекста setter ничего не делает. +виртуальный REPLY явно, закрывая ранее открытый raw socket. Переключение при pending-запросах +отклоняется с WARN; без контекста setter ничего не делает. Production fallback без сети отсутствует. ## Запрос и сопоставление ответа @@ -35,11 +36,13 @@ Raw reader проверяет IPv4 IHL/протокол, Echo Reply type/code и Данные ответа берутся из raw пакета. Сохранённый в request префикс payload до 1500 байт для формирования ответа не используется; он не ограничивает длину реального echo payload. -Таймер запускается после первого отправленного запроса, затем каждые 5 с, пока pending +Таймер резервируется до первого raw send, затем продлевается каждые 5 с, пока pending не пуст. Удаляет записи с возрастом **больше** 5 с на очередном обходе; удаление не происходит точно в момент достижения порога. `destroy` снимает binding, отменяет таймер, закрывает raw socket/регистрацию, освобождает pending и обнуляет `inst->icmp_proxy`. Все действия — в uasync; `inst` и `ua` заимствуются до destroy. +Отказ таймера до отправки отклоняет запрос; отказ продления освобождает все pending с ERROR. +Запрос без действующего таймера в pending не публикуется. ## Сообщения и доставка diff --git a/src/proxy/tcp_proxy_server_doc.md b/src/proxy/tcp_proxy_server_doc.md index 6c15fa94..cf0058e4 100644 --- a/src/proxy/tcp_proxy_server_doc.md +++ b/src/proxy/tcp_proxy_server_doc.md @@ -9,10 +9,10 @@ Контекст встроен в `inst->tcp_proxy_server`. `init(inst)` обнуляет его, читает `config->global.tcp_proxy_server_enabled`, при включении регистрирует сервис -`ETCP_RT_ID_TCP_PROXY_SERVER` (0x04) и вызывает UDP/ICMP init независимо от наличия клиента. +`ETCP_RT_ID_TCP_PROXY_SERVER` (0x04). NULL inst даёт -1; отсутствие config или отключённый сервер дают 0 без binding. -Результаты bind/UDP/ICMP init не пробрасываются: 0 не гарантирует готовность всех сервисов. -Повторный init при живых потоках недопустим. +Занятый binding/ошибка bind возвращает -1; enabled выставляется только после успеха. +Повторный init отклоняется. Общие UDP/ICMP создаёт и уничтожает instance независимо от TCP-модулей. INI-парсер включает сервер при обработке параметра секции `[tcp_proxy_server]`. Поддержанные ключи: `tcp_recv_buf`, `fib`, `so_mark`, `netif`, `bind_ip`, @@ -79,7 +79,7 @@ DATA retry и диагностический таймер, уничтожает через uasync_call_soon, чтобы текущая синхронная доставка могла проверить freed. Указатель нельзя использовать как живой поток после conn_free. -destroy выключает сервер, снимает binding и уничтожает общие UDP/ICMP-контексты. -Клиент того же instance должен быть остановлен раньше; ua должен обработать отложенные free. +destroy выключает сервер и снимает только его binding; общие UDP/ICMP-контексты сохраняются. +Instance останавливает proxy до разборки UTUN-группы и транспорта; ua обрабатывает отложенные free. Все операции выполняются в uasync. Каждую секунду диагностический таймер пишет размеры TCP-очередей, наличие write_buf, кеши пулов и OS socket buffers на уровне proxy=trace. diff --git a/src/proxy/udp_proxy_doc.md b/src/proxy/udp_proxy_doc.md index 160aab94..88f69a43 100644 --- a/src/proxy/udp_proxy_doc.md +++ b/src/proxy/udp_proxy_doc.md @@ -8,8 +8,8 @@ ## Контекст и жизненный цикл Контекст принадлежит `inst->udp_proxy`, глобального контекста нет. `init(inst, ua)` -создаёт его и запрашивает router binding, повторный init возвращает 0, сохраняя контекст. -`is_exit` — снимок роли при init, но диспетчер проверяет текущее +создаёт его вместе с router binding, повторный init с тем же ua возвращает 0, сохраняя контекст. +Контекст публикуется только после успеха; TCP client/server им не управляют. Диспетчер проверяет текущее `inst->tcp_proxy_server.enabled` для REQUEST. REPLY принимается только при наличии `tcp_proxy_client` и совпадении peer с его `via_node_id`. Один instance может совмещать роли. @@ -18,12 +18,18 @@ Exit индексирует flows линейным списком по неблокирующий `AF_INET/SOCK_DGRAM`, привязанный к `INADDR_ANY:0`, и read callback в uasync. Ответ принимается только от исходного `dst_ip:dst_port`; в REPLY адреса и порты меняются местами. REQUEST и принятый ответ обновляют `last_activity_tb`. +Новый flow публикуется после socket/nonblocking/bind/read registration и создания таймера. +Отказ любого этапа освобождает новый flow целиком. Результат sendto проверяется по точной длине: +нулевая датаграмма с sent=0 успешна, EINTR повторяется, остальные ошибки/короткий send +логируются с peer/tuple/длиной/errno. Датаграмма отбрасывается без добавления очереди повторов; +рабочий flow сохраняется, невалидный сокет удаляется. Таймер запускается при первом flow и проверяет список каждые 600000 tb (60 с), пока flows остаются. Удаляет flow при неактивности **больше** 60 с; удаление происходит на очередном обходе, а не точно в момент достижения порога. `destroy` снимает binding, отменяет таймер, закрывает сокеты/регистрации и обнуляет `inst->udp_proxy`. Все операции выполняются в uasync; `inst` и `ua` заимствуются до destroy. +Отказ продления таймера закрывает оставшиеся flows с ERROR, не оставляя их без механизма очистки. ## Сообщения и доставка @@ -44,5 +50,5 @@ router перед подкомандой, поэтому заголовок пр Возврат 0 означает постановку в очередь; -1 — отсутствие TUN, недопустимый размер или ошибку выделения. Входной payload остаётся у вызывающего кода. -`recv_cb` принимает владение entry/dgram при любом исходе. `init` не пробрасывает -ошибку router bind, поэтому 0 не подтверждает работоспособность регистрации. +`recv_cb` принимает владение entry/dgram при любом исходе. `init` возвращает -1 при +неверных аргументах, другом ua, занятом binding или ошибке выделения/регистрации. From e2ee5f2397e89a8bdb0ad8bea7df805e72257fb2 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:52:22 +0200 Subject: [PATCH 21/25] Document atomic proxy client creation and instance shutdown order --- doc/service_lifecycle.md | 10 ++++++++++ src/proxy/tcp_proxy_client_doc.md | 15 ++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/doc/service_lifecycle.md b/doc/service_lifecycle.md index 6823c6c3..26eb6d8c 100644 --- a/doc/service_lifecycle.md +++ b/doc/service_lifecycle.md @@ -71,6 +71,16 @@ ConnectivityManager до запроса запуска и передаёт ко | Группа | Сессии пиров, её маршруты и логические каналы, планировщик подключения и recovery | | Сессия пира группы | NCD handle с момента CONNECTING, согласованные эпохи, состояние обмена таблицей, очередь отправки | +Proxy настраивается при создании ядра. Instance владеет общими UDP/ICMP-контекстами; +TCP client/server владеют только своими bindings, listeners и TCP-потоками. +Общие контексты нужны для exit либо клиентского TUN; SOCKS/HTTP без TUN их не требуют. +Ошибка обязательного listener, TUN mapping или регистрации откатывает создание instance. +Недоступность raw ICMP по правам или поддержке отключает только ICMP exit с WARN; +клиентские ICMP REPLY и TCP/UDP продолжают работать. +`utun_instance_destroy()` закрывает proxy и обрабатывает отложенные free до остановки +UTUN/CHAT-групп и транспорта. Waiters/таймеры снимаются до освобождения их владельцев. +Остановка отдельного TCP proxy-модуля не уничтожает общие UDP/ICMP-контексты. + SQLite открывается ядром один раз: `db_path/chats.db`, либо `:memory:`, если путь не задан. Остановка чата сохраняет каналы и сообщения в этой БД. В варианте `:memory:` данные сохраняются только до уничтожения ядра. diff --git a/src/proxy/tcp_proxy_client_doc.md b/src/proxy/tcp_proxy_client_doc.md index 90f5805d..0e912edc 100644 --- a/src/proxy/tcp_proxy_client_doc.md +++ b/src/proxy/tcp_proxy_client_doc.md @@ -8,8 +8,8 @@ ## Создание и владение -`tcp_proxy_client_create` вызывается в uasync с живым `ua`. TUN/lwIP создаются только -при **обоих** непустых `tun_name` и `tun_ip`. В этом случае `tun_init_nat` создаёт +`tcp_proxy_client_create` вызывается в uasync с живым `ua`. Для TUN нужны **оба** +непустых `tun_name` и `tun_ip`; задание только одного — ошибка. `tun_init_nat` создаёт отдельный proxy TUN (test_mode выбирает тестовый интерфейс); MTU<=0 заменяется на 1500, MTU TUN передаётся в lwIP. Имя tun_init_nat не означает включение eim_nat на ingress. Без TUN необходим хотя бы флаг SOCKS или HTTP; эти listeners могут работать без lwIP. @@ -20,9 +20,10 @@ MTU TUN передаётся в lwIP. Имя tun_init_nat не означает обмена через router нужен instance. Mappings содержат порты в host byte order, адрес назначения — IPv4-строку. -Критическая ошибка TUN/lwIP/пула возвращает NULL. Ошибка отдельного listener или -router bind не отменяет создание всего клиента; проверять конкретные listener handles -и диагностику. UDP/ICMP инициализируются только для TUN после успешного TCP bind. +Все заданные SOCKS/HTTP listeners, TUN mappings, TUN/lwIP/пулы и TCP router binding +обязательны. Ошибка любого шага возвращает NULL и освобождает уже созданные ресурсы. +Повторное создание клиента или занятой binding отклоняются. UDP/ICMP создаёт владелец +instance после успешного создания TCP-модулей, если есть TUN либо включён exit. Клиент привязывается к `ETCP_RT_ID_TCP_PROXY_CLIENT` (0x08), сервер — к `ETCP_RT_ID_TCP_PROXY_SERVER` (0x04). Генератор stream_id общий для TUN/SOCKS/HTTP, @@ -81,7 +82,7 @@ CLOSE/ERROR при неудаче отправки могут повторять Пустое уведомление router (`ROUTER_SVC_HDR_SIZE=26`, без TCP command/sid) удаляет все lwIP/SOCKS/HTTP потоки клиента к настроенному exit. Это не 9-байтовый wire CLOSE_ALL. -`destroy` снимает TCP binding, закрывает listeners/клиентов, освобождает lwIP/TUN/пул. -Общие UDP/ICMP уничтожаются только при выключенном сервере того же instance. +`destroy` снимает только собственный TCP binding, закрывает listeners/клиентов, +освобождает lwIP/TUN/пул. Общие UDP/ICMP принадлежат instance и сохраняются. Владелец затем сам обнуляет `inst->tcp_proxy_client`. Все callbacks и очистка выполняются в uasync; уничтожать клиента следует вне его активного callback. From f10a6f70c512fdcf1cd85cf2b5a2916ee950acb8 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:54:40 +0200 Subject: [PATCH 22/25] Describe checked datagram startup and bounded TCP HUP handling --- doc/proxy_protocol.md | 48 +++++++++++++++++++++++++----------- src/proxy/socks_proxy_doc.md | 7 ++++++ 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/doc/proxy_protocol.md b/doc/proxy_protocol.md index bf2fd43e..d5466f0e 100644 --- a/doc/proxy_protocol.md +++ b/doc/proxy_protocol.md @@ -68,6 +68,11 @@ Backpressure router и окно потока независимы. При оши Force обходит лимит send_q router, но никогда не окно proxy. При отсутствии CONNECTED/кредита (-2) отправка ждёт ready/WINDOW, а не только router waiter. +OS TCP сохраняет ограничение read_high_water и явную остановку чтения при HUP. +Если HUP не оставляет работы для чтения/записи, tcp_io временно снимает регистрацию fd; +low_water восстанавливает её с новой generation. Сокет и очереди сохраняются, запись +обслуживается независимо от чтения. Ошибка повторной регистрации закрывает соединение. + ## Half-close и удаление потока Владелец выставляет fin_pending только после отправки всех исходящих DATA. @@ -87,6 +92,7 @@ FIN не должен ждать записи противоположного Клиентский close_pending может повторяться в poll/free пока поток жив; сервер после ошибки TCP удаляет поток и делает попытку ERROR. Гарантированного повторения CLOSE/ERROR после освобождения владельца нет. +Поздний WINDOW отсутствующего потока отбрасывается с debug-диагностикой, без создания потока. Router close/restart уведомляет сервис entry длиной 26 байт без payload. Proxy называет это CLOSE_ALL: exit удаляет потоки указанного peer, клиент — все свои @@ -133,13 +139,22 @@ UDP exit создаёт сокет на tuple client_node_id/src_ip/src_port/dst проверяет источник ответа, меняет src/dst местами в REPLY. Удаляет неактивные flows при очередной периодической проверке с порогом >60 с. Клиент восстанавливает IPv4 checksum, UDP checksum=0 (допустимо для IPv4). +Flow публикуется только после socket/nonblocking/bind/read registration и создания +таймера очистки. Ошибка продления общего таймера закрывает оставшиеся flows. +sendto проверяется по точной длине, включая успешный нулевой payload; EINTR повторяется. +EAGAIN/ENOBUFS и ошибки отдельного пакета логируются без очереди повторов; сломанный +сокет удаляется. Ошибки выделения ответа, router send и доставки в TUN диагностируются. 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: его ошибка оставляет контекст активным. +`icmp_proxy_init` создаёт общий контекст и binding; `icmp_proxy_enable_exit` отдельно +открывает raw socket. EPERM/EACCES/отсутствие поддержки raw отключают только ICMP exit +с WARN; клиентские REPLY сохраняются. Прочие ошибки запуска прерывают создание instance. +Тестовый `test_loopback` включается явно, закрывает raw socket и даёт виртуальные ответы +независимо от прав процесса. Production его не включает. +Pending raw request создаётся только с рабочим таймером истечения срока ожидания. Send API принимает payload 0..65507, TUN delivery — 0..65506 из-за однобайтового префикса в uint16_t длине entry. UDP send требует локальный UDP-контекст, ICMP send @@ -148,11 +163,13 @@ Send API принимает payload 0..65507, TUN delivery — 0..65506 из-з ## Жизненный цикл, диагностика и проверки Все операции и 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. +после router init; client/server имеют разные service IDs. Общие UDP/ICMP создаёт +и уничтожает UTUN_INSTANCE; TCP-модули освобождают только собственные ресурсы. +Контексты нужны для включённого exit либо клиентского TUN; SOCKS/HTTP без TUN их не требуют. +Заданные listeners, TUN mappings и bindings обязательны: ошибка откатывает создание. +Повторный init активного модуля и попытка занять чужой binding отклоняются. +При уничтожении instance TCP-клиент/сервер и общие контексты закрываются до остановки +групп и транспорта; ua обрабатывает отложенные free. Точные контракты — в .h. Категория proxy: info — подключения/закрытия/транзакции, debug — окна/паузы/повторы, trace — трафик и секундная диагностика exit TCP-очередей/пулов/OS buffers. @@ -164,17 +181,20 @@ trace — трафик и секундная диагностика exit TCP-о | 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_proxy_lifecycle | 25 сценариев отказов/rollback, fd/память/таймеры, владение bindings, ICMP без raw, HUP re-register и destroy из callback | | 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_socks_http_proxy | Реальные SOCKS IPv4/domain, HTTP и HTTPS CONNECT, byte-exact загрузки; штатный TLS shutdown либо UTUN_TEST_TLS_ABRUPT=1 | +| test_udp_proxy / test_icmp_proxy | Два instance через ETCP; ICMP выбирает явный test_loopback при недоступном raw socket | +| test_tcp_io | 19 сценариев, включая HUP high/low-water на epoll/poll, ручную паузу и независимую запись | | test_tcp_io_flush | Callback после последнего синхронного send | -Аудит 2026-10-24: перечисленные девять тестовых программ пересобраны и прошли на Linux. -ICMP прошёл через test_loopback: raw socket недоступен без соответствующих прав. -В успешном SOCKS/HTTP-тесте остались EPIPE/ENOTCONN при teardown, а тестовые конфиги -логируют неподдержанный enabled в секции сервера; наблюдения записаны в [tasks.md](tasks.md). -Полный check.sh, реальные TUN/load/burst и Windows/FreeBSD в этом аудите не запускались. +Проверка 2026-10-24: профильные тесты проходят на Linux. ICMP без прав на raw проверен +через явный test_loopback. Штатный TLS shutdown проходит без EPIPE/ENOTCONN; +аварийный сохраняет реальные ошибки записи, все 16 загрузок остаются byte-exact. +Тестовые конфиги используют поддержанный connect_timeout. Итоги полного прогона +и sanitizer-проверок — в [proxy_fix_plan.txt](proxy_fix_plan.txt). +Windows/FreeBSD runtime и настоящий raw ICMP в этом окружении не проверены. Создание proxy из конфигурации в utun_instance.c исключено под _WIN32. IPv4-фрагменты на входе proxy TUN отклоняются; сборки фрагментов нет. Описания модулей: `src/proxy/*_doc.md`; все семь заголовков `src/proxy/*.h` сверены с реализацией. diff --git a/src/proxy/socks_proxy_doc.md b/src/proxy/socks_proxy_doc.md index d1f794a4..2c7b0079 100644 --- a/src/proxy/socks_proxy_doc.md +++ b/src/proxy/socks_proxy_doc.md @@ -44,11 +44,15 @@ socks_proxy_conn_free_all(&conns, &conn_count); // все соединения `inst/ua/conns/count/next_stream_id` заимствуются и должны жить до закрытия listener и всех клиентов. Закрытие listener сохраняет активных клиентов. Вызовы и callbacks выполняются в uasync владельца. Адрес listener — IPv4:port; порт 0 выбирает ОС, фактический порт возвращает `socks_proxy_get_port()`. +При создании через `tcp_proxy_client_create` каждый включённый listener обязателен: +пустой/неверный адрес или ошибка bind/listen откатывают создание всего клиента. ### Ключевые нюансы - **Backpressure:** DATA ждёт CONNECTED и кредита `proxy_flow`. Неотправленные байты сохраняются в `tx_buf/tx_len` и отправляются порциями до 4096. При доступном окне ставится router send-ready waiter; safety-net 500 мс возобновляет отправку с обычным force=0. Чтение tcp_io ограничено его очередью. + HUP сохраняет high/low-water и ручную остановку чтения; временно снятая регистрация fd + восстанавливается после освобождения очереди. Запись не зависит от паузы чтения. - **Несколько соединений:** управляются через связный список `struct socks_proxy_conn*` + счётчик. В HTTP keep-alive каждое сообщение получает новый upstream stream; клиентский TCP-сокет сохраняется. Pipelining обрабатывается последовательно, включая запросы к разным адресатам. - **stream_id** генерируется монотонно (`++next_stream_id`), передаётся в заголовке ETCP-пакетов для мультиплексирования. @@ -172,3 +176,6 @@ SOCKS greeting принимает только предложенный мето Нормативные размеры сообщений и порядок байтов — в [proxy_protocol.md](../../doc/proxy_protocol.md). Контракты чистого HTTP codec (обнуление состояния, emit/source_len, used и EOF) — в [http_proxy.h](http_proxy.h). +`test_socks_http_proxy` проверяет 16 byte-exact загрузок и двусторонний TLS shutdown. +`UTUN_TEST_TLS_ABRUPT=1` отдельно воспроизводит обрыв TLS: реальные EPIPE/ENOTCONN +остаются ошибками TCP и диагностируются, уже доставленные ответы сохраняются. From 83194b19c7ab541f21e36a7fd9d8ce536de29a98 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 13:59:29 +0200 Subject: [PATCH 23/25] Use supported TCP proxy exit option in TUN integration fixture --- tests/tcp_proxy_full/exit.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tcp_proxy_full/exit.conf b/tests/tcp_proxy_full/exit.conf index 5aa7c2b8..9ec38f04 100644 --- a/tests/tcp_proxy_full/exit.conf +++ b/tests/tcp_proxy_full/exit.conf @@ -14,7 +14,7 @@ type=public allow_all=1 [tcp_proxy_server] -enabled=yes +connect_timeout=2000 [debug] connection=info From a42dc388744aec1cdabcea8ea8342b93d636ed01 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 14:00:17 +0200 Subject: [PATCH 24/25] Check real raw ICMP explicitly alongside deterministic loopback --- doc/proxy_protocol.md | 8 ++++---- tests/test_icmp_proxy.c | 12 +++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/doc/proxy_protocol.md b/doc/proxy_protocol.md index d5466f0e..197d735e 100644 --- a/doc/proxy_protocol.md +++ b/doc/proxy_protocol.md @@ -185,16 +185,16 @@ trace — трафик и секундная диагностика exit TCP-о | 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 загрузки; штатный TLS shutdown либо UTUN_TEST_TLS_ABRUPT=1 | -| test_udp_proxy / test_icmp_proxy | Два instance через ETCP; ICMP выбирает явный test_loopback при недоступном raw socket | +| test_udp_proxy / test_icmp_proxy | Два instance через ETCP; ICMP: явный test_loopback, UTUN_TEST_ICMP_RAW=1 требует настоящий raw socket | | test_tcp_io | 19 сценариев, включая HUP high/low-water на epoll/poll, ручную паузу и независимую запись | | test_tcp_io_flush | Callback после последнего синхронного send | -Проверка 2026-10-24: профильные тесты проходят на Linux. ICMP без прав на raw проверен -через явный test_loopback. Штатный TLS shutdown проходит без EPIPE/ENOTCONN; +Проверка 2026-10-24: профильные тесты проходят на Linux. ICMP проверен и через явный +test_loopback, и настоящим raw echo до 127.0.0.1 с правами root. Штатный TLS shutdown проходит без EPIPE/ENOTCONN; аварийный сохраняет реальные ошибки записи, все 16 загрузок остаются byte-exact. Тестовые конфиги используют поддержанный connect_timeout. Итоги полного прогона и sanitizer-проверок — в [proxy_fix_plan.txt](proxy_fix_plan.txt). -Windows/FreeBSD runtime и настоящий raw ICMP в этом окружении не проверены. +Windows/FreeBSD runtime в этом окружении не проверены. Создание proxy из конфигурации в utun_instance.c исключено под _WIN32. IPv4-фрагменты на входе proxy TUN отклоняются; сборки фрагментов нет. Описания модулей: `src/proxy/*_doc.md`; все семь заголовков `src/proxy/*.h` сверены с реализацией. diff --git a/tests/test_icmp_proxy.c b/tests/test_icmp_proxy.c index 80ea19d2..538cb8a5 100644 --- a/tests/test_icmp_proxy.c +++ b/tests/test_icmp_proxy.c @@ -145,6 +145,7 @@ static void test_timeout(void* arg) { int main(void) { printf("=== test_icmp_proxy ===\n"); debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); debug_set_categories(DEBUG_CATEGORY_ALL); + debug_set_category_level(DEBUG_CATEGORY_GENERAL, DEBUG_LEVEL_INFO); utun_instance_set_tun_init_enabled(0); srand((unsigned)time(NULL)); @@ -162,7 +163,16 @@ int main(void) { exit_node_id = exit_node->node_id; client_node_id = cli->node_id; - icmp_proxy_set_test_loopback(exit_node, 1); + if (getenv("UTUN_TEST_ICMP_RAW")) { + if (!exit_node->icmp_proxy || exit_node->icmp_proxy->raw_sock == SOCKET_INVALID) { + DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "ICMP raw test requires a working exit raw socket"); + goto done; + } + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "ICMP test: real raw socket fd=%d", (int)exit_node->icmp_proxy->raw_sock); + } else { + icmp_proxy_set_test_loopback(exit_node, 1); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "ICMP test: explicit test_loopback"); + } g_to_id = uasync_set_timeout(ua, 100, NULL, monitor, "mon"); void* to_id = uasync_set_timeout(ua, TEST_TIMEOUT_MS * 10, NULL, test_timeout, "to"); From 461417beac59eac50a2254549d0f69d2502d65af Mon Sep 17 00:00:00 2001 From: evgeny Date: Sat, 24 Oct 2026 14:03:56 +0200 Subject: [PATCH 25/25] Record completed proxy fixes and architecture validation results --- doc/proxy_fix_plan.txt | 63 +++++++++++++++++++++++++++++++++++++++--- doc/tasks.md | 38 ++++++++++++------------- 2 files changed, 78 insertions(+), 23 deletions(-) diff --git a/doc/proxy_fix_plan.txt b/doc/proxy_fix_plan.txt index b30fedb7..7dd32c03 100644 --- a/doc/proxy_fix_plan.txt +++ b/doc/proxy_fix_plan.txt @@ -1,7 +1,8 @@ Proxy: исправления и проверка архитектуры -Дата: 2026-10-24. Статус: проект изменений; исходники не изменены. +Дата: 2026-10-24. Статус: реализовано и проверено на Linux. +Ниже сохранён согласованный проект; фактические изменения и результаты — в конце. -Подтверждённые факты +Подтверждённые факты до исправлений 1. udp_proxy.c не проверяет bind/sendto, результат socket_set_nonblocking, часть выделений/регистраций/таймеров и отправку ответа через router. @@ -133,11 +134,65 @@ HTTPS-тест разделить на нормальное двусторонн - Текущие proxy9, test_tcp_io, тесты uasync/queue, затем ASAN/UBSan и полный check.sh. Windows/FreeBSD требуют отдельной проверки poll/select; Linux PASS её не заменяет. -Доказательства текущего анализа +Доказательства исходного анализа Диагностический test_socks_http_proxy под gdb: PASS, 16 byte-exact загрузок. Включены существующие proxy/socket debug через gdb, проектные файлы не менялись. Полный журнал: /tmp/proxy-design-debug.log; события lifecycle: /tmp/proxy-design-lifecycle.log. Системные вызовы TLS: /tmp/proxy-design-close.744427, сводка четырёх соединений: /tmp/proxy-design-close-summary.txt. -Это проверка существующей реализации и проекта изменений; исправленный код ещё не протестирован. +Эти журналы относятся к реализации до исправлений. + +Реализация и проверка архитектуры + +1. UTUN_INSTANCE единолично создаёт и уничтожает общие UDP/ICMP-контексты. TCP-модули + владеют только своими bindings/listeners/потоками. Их rollback не затрагивает общие + контексты. Занятой binding не перезаписывается, повторный init TCP отклоняется. + Proxy закрывается и deferred free обрабатываются до остановки групп/транспорта. +2. Все заданные listeners и TUN mappings обязательны. Частичный отказ возвращает NULL + и освобождает созданные ресурсы; create_from_config сохраняет config у вызывающего. +3. ICMP init создаёт контекст/binding, enable_exit атомарно создаёт raw socket/регистрацию. + Отсутствие прав/поддержки отключает только ICMP exit с явным WARN. Клиентские REPLY + сохраняются; остальные ошибки запуска фатальны. Test loopback включается явно. +4. UDP flow публикуется после всех обязательных шагов. Отказы создания/продления таймера + не оставляют бессрочных flows. Проверяются socket/nonblocking/bind/registration, + sendto, ответные выделения/router/TUN. EINTR повторяется, нулевая датаграмма допустима; + ошибки пакета логируются без очереди повторов, сломанный сокет закрывается. +5. TCP HUP сохраняет high/low-water и ручную остановку чтения. Без доступного I/O + регистрация временно снимается; low_water восстанавливает её с новой generation. + fd/очереди сохраняются; запись обслуживается независимо. Ошибка восстановления + регистрации корректно вызывает on_error, включая destroy владельца из callback. +6. Поздний WINDOW отсутствующего sid отбрасывается с DEBUG. Формат протокола и машина + CONNECTED/WINDOW/FIN сохранены. Реальные ошибки записи/SHUT_WR не превращаются в успех. + HTTPS fixture теперь завершает TLS двусторонне; abrupt close — отдельный режим. +7. Неподдержанный enabled в серверных конфигурациях профильных и TUN-тестов заменён + поддержанным connect_timeout. Контракты заголовков и документы модулей обновлены. + +Проверено: +- test_proxy_lifecycle: 25/25 изолированных сценариев, контроль fd/памяти/регистраций/таймеров. + Покрыты socket/nonblock/bind/registration/timer/OOM, отправка/EINTR/нулевой UDP, + частичный отказ instance, повторный запуск, ICMP REPLY после EPERM, HUP re-register, + ожидающая запись и destroy из error callback. /tmp/proxy-fix-lifecycle2.log. +- test_tcp_io: 19/19, HUP high_water=4 на epoll/poll, парковка без busy loop, + точная доставка данных и FIN после low_water, ручная пауза и независимая запись. +- SOCKS/HTTP/HTTPS: оба режима по 16 byte-exact загрузок. Штатный TLS shutdown без + EPIPE/ENOTCONN; abrupt сохраняет ошибки TCP без повреждения доставленных ответов. + /tmp/proxy-fix-check1/test_socks_http_proxy.log, /tmp/proxy-fix-tls-abrupt.log. +- Основной make check: 185/185 PASS. /tmp/proxy-fix-full-check2.log. + Первый check.sh остановлен общим лимитом 141с; local_transport_packets отдельно PASS. + Повтор завершил основной набор, но TUN-этапу помешал чужой процесс на UDP 15002. +- Те же TUN fixtures и идентичный src/utun выполнены в отдельном network namespace: + proxy9/9, burst1/1, load8/8 PASS; load проверил134217728 байт. + /tmp/proxy-fix-network-check.log; копия fixtures/логов: /tmp/utun-proxy-fix-network/. +- Настоящий raw ICMP echo до127.0.0.1 через два ETCP-instance и явный loopback PASS. + UTUN_TEST_ICMP_RAW=1 требует raw socket и не переключается на виртуальный ответ. + /tmp/proxy-fix-icmp-raw.log, /tmp/proxy-fix-icmp-loopback.log. +- ASAN/UBSan/LSan: 16/16 PASS с detect_leaks=1, включая оба TLS-режима, lifecycle, + tcp_io/flush, пакеты, regressions, HTTP, TCP/UDP/ICMP, services, uasync/queue и STCP. + Настоящий raw ICMP дополнительно PASS под теми же санитайзерами с root. + Все встроенные зависимости собраны с sanitizer-флагами в отдельной копии текущих + исходников /tmp/utun-proxy-fix-sanitizers; обычные объекты проекта не менялись. + /tmp/proxy-fix-sanitizer-results.log, /tmp/proxy-fix-sanitizer-icmp-raw.log. + +В проверенных Linux-сценариях противоречий владения/rollback/half-close не выявлено. +Сборка и runtime Windows/FreeBSD не проверялись; Linux epoll/poll не заменяют эту проверку. diff --git a/doc/tasks.md b/doc/tasks.md index 6346d550..2f0dd49e 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -4,25 +4,25 @@ ## Текущие задачи -[ ] **Proxy: проверить ошибки teardown и убрать неверный ключ в тестовых конфигах** — обнаружено при аудите документации 24.10.2026. - Проект исправлений и проверка владения/rollback/half-close: [doc/proxy_fix_plan.txt](proxy_fix_plan.txt). - Уточнение 24.10.2026: существующие proxy/socket debug включены через gdb без правок исходников. - Подтверждён обход backpressure при HUP: tcp_io error_cb снимает read_waiter и принудительно читает, - очередь выросла до53 блоков при high_water=4. Нужны ограниченное чтение и ожидание low_water без HUP busy loop. - strace четырёх HTTPS-потоков: после EOF назначения приходит TLS-запись24 байта от клиента; - send принимает её, затем SO_ERROR=EPIPE или shutdown=ENOTCONN. Повторный shutdown не обнаружен. - Не подавлять эти ошибки; отдельно проверить корректный TLS shutdown тестового сервера и abrupt close. - Доказательства: /tmp/proxy-design-debug.log, /tmp/proxy-design-lifecycle.log, - /tmp/proxy-design-close.744427 и /tmp/proxy-design-close-summary.txt. Исходники пока не изменены. - Девять профильных тестов PASS, но в test_socks_http_proxy после ответов встречаются EPIPE (32) - в tcp_io error_cb и ENOTCONN (107) при shutdown write; причина и корректность cleanup отдельно не установлены. - Побайтовая проверка всех 16 загрузок проходит. Начать с информативного лога FIN/close/socket events - (proxy/socket), сопоставить обе половины и очереди; поведение по одному PASS не считать проверенным. - test_udp_proxy, test_icmp_proxy и test_socks_http_proxy также используют [tcp_proxy_server] enabled=yes: - парсер логирует Unknown option, но включает сервер при обработке любого параметра этой секции. - В тестах использовать поддержанный параметр, например connect_timeout=2000, либо отдельно согласовать API конфига. - ICMP здесь проверен через fallback без raw socket (нет прав); реальный raw ping не подтверждён. - Логи: /tmp/proxy-doc-audit-network-logs/test_socks_http_proxy.log, test_udp_proxy.log и test_icmp_proxy.log в том же каталоге. +[+] **Proxy: исправить HUP/backpressure, владение и rollback** — завершено 24.10.2026. + Согласованный проект, причины и результаты: [doc/proxy_fix_plan.txt](proxy_fix_plan.txt). + HUP больше не обходит read_high_water/ручную паузу; idle HUP снимается с poll, + low_water восстанавливает регистрацию с новой generation, запись остаётся независимой. + Общими UDP/ICMP владеет instance; TCP-модули освобождают только свои ресурсы. + Proxy закрывается до групп/транспорта. Listeners/mappings обязательны, частичный запуск откатывается. + Raw ICMP запускается отдельно: EPERM сохраняет клиентские REPLY и TCP/UDP; остальные ошибки запуска фатальны. + UDP проверяет создание flow, таймеры и точную отправку; нулевой payload и EINTR обработаны. + Поздний WINDOW завершённого sid — DEBUG drop. Неверный enabled убран из серверных тестовых конфигов. + Штатный TLS shutdown проходит без EPIPE/ENOTCONN; abrupt сохраняет реальные ошибки TCP. + Оба TLS-режима: 16 byte-exact загрузок. Настоящий raw ICMP и явный loopback PASS. + test_proxy_lifecycle: 25/25 сценариев отказов без утечек fd/памяти; test_tcp_io: 19/19, epoll/poll HUP. + Основной make check: 185/185 PASS. Общий check.sh сначала не уложился в141с, + затем TUN-этапу помешал занятый другим процессом UDP15002. TUN-тесты повторены + с тем же бинарником в отдельном network namespace: proxy9/9, burst1/1, load8/8 PASS, + 134217728 проверенных байт. ASAN/UBSan/LSan: 16/16 и отдельный raw ICMP PASS, detect_leaks=1. + Логи: /tmp/proxy-fix-full-check2.log, /tmp/proxy-fix-network-check.log, + /tmp/proxy-fix-sanitizer-results.log, /tmp/proxy-fix-icmp-raw.log. + Windows/FreeBSD runtime не проверялся. [ ] **ETCP: полезная утилизация нескольких каналов и доставка при зависании части линков** — общая задача открыта. 24.10.2026: тесты отдельно проверяют обработку RTO (опоздание<=20мс), ожидание готового повтора (<=100мс)