HTTP server quramiz: RFC-compliant, State Machines with Invariants. Part 1
Asinxron server? Noldan? Albatta. Xotira boshqaruvi, connection lifecycle va parserlarga alohida e'tibor beramiz. Va bu safar chuqurroq yondashuv - hamma narsa state machine!
Introduction
HTTP server invariantlar asosida fikrlashni rivojlantirishda eng ajoyib projectlardan biridir. U nazariy tushunchalarni aktiv amaliyot bilan bog'lashda juda qo'l keladi. Yana bir bor C dasturlash tilini tanlashim sababi esa, men bu yerda erkinman, hammasini o'z ko'zim bilan ko'ra olaman - til mendan hech nimani hide qilmaydi.
Note
Biz oldin ham HTTP server qurishga urinib ko'rganmiz: cserve. Biroq legacy projectimizda xuddi PM kabi fikrlash bo'lgan: fichalar checklistini to'ldirsam bo'ldi degan narsa bor edi. Shuningdek maqsad sifatli server chiqarish emas, socket programmingni yaqinroq o'rganish ham ediki, istagancha xatolar qilib tashlaganmiz.
Kinetic bilan esa niyatimiz "invariantcha" fikrlashni rivojlantirish, tizimlarni haqiqatan tizimli qurishni o'rganishdir.
Bu safar biz ko'r-ko'rona nginx dan ko'chirmaymiz. Aksincha, yanada zamonaviy, cloud-native yechimlarni ko'rib, xususan Traefik, ular bergan yechimlarni o'zimizga moslab qurishga harakat qilamiz. Shuningdek, har bir qator kod to'liq RFC asosida yoziladi, hech nimani rejasiz, yoki checklist sifatida implement qilmaymiz.
| Document | Role |
|---|---|
| RFC 9110 | HTTP semantics (methods, status codes, fields, content, conditionals) |
| RFC 9112 | HTTP/1.1 message syntax, framing, connection management |
| RFC 9111 | HTTP caching (when serving cacheable responses) |
| RFC 9117 | Expect: 100-continue |
| RFC 7230 - RFC 7235 / RFC 2616 | superseded by the 911x series, historically relevant |
| RFC 2119 / RFC 8174 | Meaning of MUST, SHOULD, MAY |
RFC qoidalariga ko'ra, bizda 3 asosiy kalit so'zlar bor va biz ularga amal qilishimiz shart:
- MUST / MUST NOT - non-negotiable for a conformant server.
- SHOULD / SHOULD NOT - strong default; deviate only with documented reason.
- MAY - optional; listed when it affects interoperability or security posture.
Invariantlar ro'yxati post oxirida berilgan bo'lib, sarguzashtimiz davomida qulay reference qilish uchun har bir invariant maxsus ID ga ega bo'ldi. Keling endi serverimiz o'zi qanday state machine, avval shuni tushunib olamiz. Quyidagi chizma bizga aniq ma'lumot beradi:
Rasmdan ko'rishimiz mumkinki, ko'zimizga kichik ko'ringan "parse-process-response" loopimiz aslida juda katta va sub-machine'larga boy system bo'lib chiqmoqda. Read va Write fazalarini alohida qismlar sifatida process qilishimiz keyinchalik pipelining invariantlari satisfy bo'lishiga yo'l ochadi (keyingi postlarda). Persistent connectionlarni handle qilish, taymerlar, barchasi tizimli o'z o'rnida joylashgan - chegaralar aniq, yo'llar ham. Implement qilish kerak xolos.
Requirements
Serverni qurishdan oldin toolchain va tashqi kutubxonalarni belgilab olamiz:
- CMake - C/C++ dagi projectlar uchun standard build system. Har bir platformga alohida
Makefileyozib chiqmaslik uchun bu build system ishlatishimiz ma'qulroq. - libuv - Node.js ostida yotgan asinxron I/O kutubxonasi. Bizga u event loop, non-blocking TCP socketlar, signal handling va timerlarni bitta portable API ostida beradi. Ya'ni biz
epoll_create1/epoll_ctl/epoll_waitni qo'lda yozmaymiz, libuv platformaga qarab o'zi to'g'ri backendni tanlaydi.cserveda biz to'g'ridan-to'g'riepollchaqirganmiz, natijada kod Linux'ga qaram bo'lib qolgandi. Kinetic bunday xatoni takrorlamaydi. - libyaml - YAML parser, sof C'da yozilgan. Ishlashi Event-based: hujjatni token stream sifatida o'qiydi, memory tree qurib o'tirmaydi.
Nega YAML?
Konfiguratsiya formati tanlashda variantlar ko'p edi: TOML, JSON, INI, yoki o'zimizning DSL. Biz YAML'ni tanladik, chunki maqsadimiz Traefik va boshqa cloud-native proxy'lar kabi yechim qurish. Ular deyarli barchasi YAML'da yozilgan config'lardan foydalanadi: inson uchun o'qish oson, ichma-ich (nested) strukturalarni tabiiy ifodalaydi, va izohlar (#) qo'llab-quvvatlanadi. JSON izoh bermaydi, INI esa chuqur nesting'da qiynaladi. YAML bu ikki chegaraning o'rtasida turadi (garchi uning ham o'ziga xos muammolari bo'lsa ham, bizni use-casega tushadi).
Sample config va static config location
Traefik configlarini static va dynamic alohida qismlarga ajratadi: static config server ishga tushishida bir marta o'qiladi (portlar, log levels, providerlar, etc.); dynamic config esa runtimeda o'zgarishi mumkin (masalan docker compose configlari dynamic config bo'la oladi). Static config'ni default pathini quyidagicha define qildik:
Namunaviy configni Traefik'dan tikka chopdik, albatta maqsad bilan:
log:
level: INFO
format: common
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
file:
filename: "/etc/traefik/dynamic_config.yml"
watch: true
Hozircha to'liq configni o'qishimiz shart emas, bizga 3 ta asosiy data yetarli:
Base TCP Server
Endi asos - libuv event loop ustidagi eng oddiy TCP echo server. Bu bosqichda HTTP haqida hali gap yo'q: biz faqat connectionni qabul qilamiz, kelgan baytlarni aynan qaytaramiz (echo), va connection lifecycle'ni to'g'ri boshqarishni o'rganamiz. Aynan mana shu bosqichda Global Lifecycle invariantlarining poydevorini qo'yamiz.
Kod:
777d68f
Butun server global app struct atrofida qurilgan:
typedef struct {
uv_loop_t *loop;
uv_tcp_t server;
uv_signal_t sig_int, sig_term, sig_hup;
bool is_shutting_down;
} ktc_app_t;
static ktc_app_t app;
Har bir client connection uchun esa alohida kontekst:
typedef struct {
uv_tcp_t client_handle;
ktc_arena_t *arena;
bool is_closing;
int pending_closes_cnt;
} ktc_conn_t;
main oqimi tanish: config o'qiladi, loop yaratiladi, socket bind/listen qilinadi, signallar ulanadi, so'ng uv_run bilan loop aylanadi.
r = uv_listen((uv_stream_t *)&app.server, 128, on_connection);
// ...
uv_run(app.loop, UV_RUN_DEFAULT);
Lifecycle invariantlari
Endi asosiy savollarga o'tamiz. Server "ishlayapti" degani yetarli emas - u qanday ishlayotgani, ayniqsa connection o'lishi va shutdown paytida, muhim.
Savol: connection'ni yopganda xotirani qachon ozod qilamiz?
Bu naqadar oddiy ko'rinsa, o'shancha aldamchi savol. libuv asinxron: uv_close chaqirsak, handle darhol o'lmaydi - loop keyingi iteratsiyada callback orqali "endi o'ldi" deb xabar beradi. Agar biz uv_closedan keyin darhol free(conn) qilsak, loop hali handle'ga tegib turgan bo'lishi mumkin → use-after-free.
Talab. Connection strukturasi faqat unga bog'liq barcha handle'lar uv_closeni tugatgandan keyingina ozod qilinishi kerak. Bu bizning H11-LIFE-006 (bosqichli yopish) invariantimizning quyi qatlamidagi mexanika.
Implementatsiya. Reference counting. pending_closes_cnt har bir ochilgan handle'ni sanaydi, on_handle_closed esa har handle yopilganda uni kamaytiradi va nolga yetganda - va faqat o'shanda - xotirani ozod qiladi.
static void on_handle_closed(uv_handle_t *h) {
ktc_conn_t *c = h->data;
if (--c->pending_closes_cnt == 0) {
if (c->arena) {
ktc_arena_destroy(c->arena);
}
free(c);
}
}
static void conn_close(ktc_conn_t *c) {
if (c->is_closing) {
return;
}
c->is_closing = true;
uv_read_stop((uv_stream_t *)&c->client_handle);
c->pending_closes_cnt = 1;
uv_close((uv_handle_t *)&c->client_handle, on_handle_closed);
}
Nega. is_closing flagi idempotentlikni beradi: conn_close bir necha marta chaqirilsa ham (masalan, ham read xatosi, ham write xatosi bir vaqtda), yopish faqat bir marta boshlanadi. Bu double-free'ni oldini oladi. Hozir bitta handle bor, lekin keyin taymer qo'shsak, pending_closes_cnt ni oshirib, xuddi shu skelet ishlayveradi - dizayn oldindan kengaytiriladigan qilib qo'yilgan.
Savol: echo o'zi qanday ishlaydi va buferni kim ozod qiladi?
libuv read'dan oldin bufer so'raydi (on_alloc), keyin o'qilgan baytlarni on_readga beradi. Echo'da biz o'sha buferni to'g'ridan-to'g'ri qaytarib yozamiz.
static void on_alloc(uv_handle_t *handle, size_t suggested, uv_buf_t *buf) {
(void)handle;
buf->base = malloc(suggested);
buf->len = buf->base ? suggested : 0;
}
static void on_read(uv_stream_t *stream, ssize_t nread, const uv_buf_t *buf) {
ktc_conn_t *c = stream->data;
if (nread > 0) {
ktc_write_req_t *wr = malloc(sizeof(ktc_write_req_t));
if (!wr) {
free(buf->base);
conn_close(c);
return;
}
wr->base = buf->base;
uv_buf_t wbuf = uv_buf_init(buf->base, (unsigned int)nread);
int r = uv_write(&wr->req, stream, &wbuf, 1, on_write);
if (r) {
free(wr->base);
free(wr);
conn_close(c);
}
} else {
if (nread == UV_EOF) {
log_info("Client disconnected cleanly (EOF)");
}
free(buf->base);
conn_close(c);
}
}
Nega bufer write callback'gacha yashaydi. uv_write asinxron - u qaytganda yozuv hali tugamagan bo'lishi mumkin. Shu sabab buferni write tugagandan keyin (on_write) ozod qilamiz, aks holda libuv o'chib ketgan xotiradan o'qiydi. Buni ktc_write_req_t ichida base pointerini saqlab hal qildik:
static void on_write(uv_write_t *req, int status) {
ktc_write_req_t *wr = (ktc_write_req_t *)req;
if (status < 0) {
log_error("Write failed: %s", uv_strerror(status));
}
free(wr->base);
free(wr);
}
Savol: server SIGINT/SIGTERM olganda nima bo'ladi? Ochiq connectionlar-chi?
Bu graceful shutdown savoli. Xom exit() - bu vahshiylik: yarim yozilgan responselar, resurs leaklar, TCP RST. Bizga tartibli to'xtash kerak.
Talab. Signal kelganda: (1) yangi connection qabul qilishni to'xtatish, (2) mavjudlarini drain qilish (tugatishga imkon berish), (3) loop toza yopilishi.
static void on_signal(uv_signal_t *handle, int signum) {
(void)handle;
if (app.is_shutting_down) {
return;
}
app.is_shutting_down = true;
log_info("Caught signal %d, stopping server and draining...", signum);
if (!uv_is_closing((uv_handle_t *)&app.server)) {
uv_close((uv_handle_t *)&app.server, NULL);
}
uv_signal_stop(&app.sig_int);
uv_close((uv_handle_t *)&app.sig_int, NULL);
// ... sig_term, sig_hup ham
uv_stop(app.loop);
}
on_connection boshida esa tekshiruv bor - shutdown paytida yangi socketlar rad etiladi:
static void on_connection(uv_stream_t *server, int status) {
if (status < 0) { /* ... */ return; }
if (app.is_shutting_down) {
return;
}
// ... accept
}
Nega ikki bosqichli drain. mainda uv_run tugagach, loopda hali yopilmagan handle'lar qolishi mumkin. Biz uv_walk bilan barchasini yopamiz va yana bir marta loopni aylantiramiz - bu barcha uv_close callbacklariga ishlash imkonini beradi:
uv_run(app.loop, UV_RUN_DEFAULT);
log_info("Cleaning up remaining connections...");
uv_walk(app.loop, close_walk_cb, NULL);
uv_run(app.loop, UV_RUN_DEFAULT);
uv_loop_close(app.loop);
is_shutting_down flagi bu yerda ham idempotentlikni beradi: bir nechta signal ketma-ket kelsa, shutdown mantiqi faqat bir marta bajariladi.
Kelajakka ishora
Diagrammadagi taymerlar hali yo'q. Slowloris kabi hujumlarni (sekin bayt yuborib connectionni ushlab turish) to'xtatish uchun har connectionga read/idle timeout kerak bo'ladi. Skelet tayyor - pending_closes_cnt va conn_close allaqachon ko'p-handle'li hayotni ko'targan.
Request Line Parsing
TCP asos tayyor. Endi kelgan baytlarga ma'no beramiz. HTTP so'rovning birinchi qatori - request-line: GET /index.html HTTP/1.1\r\n. Uch bo'lak: method, target, version, bo'shliq bilan ajratilgan, CRLF bilan tugaydi.
Kod:
4dd4c3d
Bu yerda muhim dizayn qarori: parser incremental va zero-copy bo'ladi. Incremental - chunki TCP baytlarni ixtiyoriy bo'laklarda yetkazadi, biz butun qatorni kutib o'tira olmaymiz. Zero-copy - chunki tokenlarni strndup bilan ko'chirish (cserveda qilingan xato) har so'rovda malloc bosimini keltiradi. O'rniga biz faqat indekslarni eslab qolamiz, keyin ktc_str view'lar sifatida resolve qilamiz.
typedef struct {
ktc_req_line_state_t state;
ktc_req_line_err_t error;
ktc_str method;
ktc_str target;
ktc_str version;
size_t method_start;
size_t method_len;
// ... target_start/len, version_start/len
size_t bytes_consumed;
} ktc_req_line_parser_t;
Parser - sof state machine. Har bir kelgan bayt joriy holatga qarab bir tranzitsiyani yuzaga keltiradi:
Grammar va oktet oqimi
Savol: so'rovni matn (string) sifatida o'qiymizmi yoki bayt oqimi sifatida?
Talab (H11-PARSE-001). HTTP xabari - bu oktetlar oqimi (US-ASCII superset), hech qachon Unicode belgilar oqimi emas. Agar biz UTF-8 dekodlashga urinsak, smuggling va noto'g'ri chegaralashga yo'l ochamiz.
Implementatsiya. Parser const uint8_t *data qabul qiladi - char emas, uint8_t. Bu C darajasida "bu baytlar, belgilar emas" degan aniq signal. Har bir bayt raqamli qiymati bo'yicha tekshiriladi.
bool ktc_req_line_parser_feed(ktc_req_line_parser_t *parser, const uint8_t *data, size_t len) {
// ...
for (size_t i = 0; i < len; i++) {
uint8_t c = data[i];
// ...
}
}
Savol: request-line'dan oldin bo'sh qatorlar (CRLF) kelsa nima qilamiz?
Talab (H11-PARSE-006). Server request-line'dan oldingi kamida bitta bo'sh qatorni robustlik uchun e'tiborsiz qoldirishi kerak (SHOULD). Ba'zi eski klientlar POST'dan keyin ortiqcha CRLF yuboradi.
Implementatsiya. IDLE holatida \r kelsa SKIP_EMPTYga o'tamiz, u yerda \n kutamiz; xom \n esa IDLEda qoladi.
case KTC_REQ_LINE_STATE_IDLE:
if (c == '\r') {
parser->state = KTC_REQ_LINE_STATE_SKIP_EMPTY;
} else if (c == '\n') {
// xom LF boshda - IDLE'da qolamiz
} else if (c == ' ' || c == '\t') {
// method oldidagi bo'shliq - invalid
parser->error = KTC_REQ_LINE_ERR_BAD_SYNTAX;
return false;
} else {
if (!is_token_char(c)) { /* 400 */ }
parser->method_start = current_idx;
parser->state = KTC_REQ_LINE_STATE_METHOD;
}
break;
Nega method oldidagi bo'shliqni rad etamiz. Leading whitespace - bu klassik request smuggling vektori. Agar bizning parser bo'shliqni "kechirsa", lekin downstream boshqacha talqin qilsa, xabar chegaralari ikki xil ko'rinadi. RFC bu yerda qat'iy bo'lishga chaqiradi (H11-PARSE-004/005).
Method va target
Savol: method qanday belgilardan iborat bo'lishi mumkin?
Talab. Method - bu token (RFC 9110). Faqat ma'lum belgilar to'plami ruxsat etilgan: harflar, raqamlar, va !#$%&'*+-.^_\|~`.
Implementatsiya.
static bool is_token_char(uint8_t c) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) {
return true;
}
const char *specials = "!#$%&'*+-.^_`|~";
return strchr(specials, c) != NULL;
}
METHOD holatida bo'shliq kelguncha token belgilarni yig'amiz; bo'shliq kelsa uzunlikni yopamiz va uzunlik chegarasini tekshiramiz:
case KTC_REQ_LINE_STATE_METHOD:
if (c == ' ') {
parser->method_len = current_idx - parser->method_start;
if (parser->method_len == 0) { /* 400 */ }
if (parser->method_len > MAX_METHOD_LEN) {
parser->error = KTC_REQ_LINE_ERR_METHOD_NOT_IMPLEMENTED;
return false;
}
parser->state = KTC_REQ_LINE_STATE_TARGET;
} else {
if (!is_token_char(c)) { /* 400 */ }
}
break;
Nega juda uzun method → 501. H11-REQLINE-003: implement qilingan har qanday methoddan uzunroq method kelsa, uni parse qilib o'tirmasdan 501 (Not Implemented) bilan javob beramiz. Bu bufer overflow va DoS'ga qarshi erta himoya.
Savol: request-target'da qaysi belgilar taqiqlangan va uzunlik chegarasi nima?
Talab (H11-REQLINE-002). Target server chegarasidan uzun bo'lsa - 414 (URI Too Long). Bundan tashqari target ichida control belgilari va bo'shliq bo'lmasligi kerak.
Implementatsiya.
case KTC_REQ_LINE_STATE_TARGET:
if (parser->target_len == 0) {
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { /* 400 */ }
parser->target_start = current_idx;
parser->target_len = 1;
} else {
if (c == ' ') {
parser->target_len = current_idx - parser->target_start;
parser->state = KTC_REQ_LINE_STATE_VERSION;
} else if (c == '\r' || c == '\n' || c < 0x20 || c == 0x7F) {
// Control belgilar va bo'shliq URI'da taqiqlangan
parser->error = KTC_REQ_LINE_ERR_BAD_SYNTAX;
return false;
} else {
size_t current_len = current_idx + 1 - parser->target_start;
if (current_len > MAX_TARGET_LEN) {
parser->error = KTC_REQ_LINE_ERR_URI_TOO_LONG;
return false;
}
parser->target_len = current_len;
}
}
break;
Nega uzunlikni oqim davomida tekshiramiz. MAX_TARGET_LEN (8192) chegarasini har bir baytda tekshiramiz, oxirida emas. Bu muhim: yovuz klient 10 GB'lik URI yuborsa, biz 8193-baytdayoq uzamiz - butun narsani xotiraga yig'ib o'tirmaymiz.
Yakunlash va resolve
VERSION holati target'ga o'xshaydi, \r kelguncha token yig'ib, CRLF → COMPLETE. Yakunda bytes_consumed request-line tugagan aniq pozitsiyani ko'rsatadi - bu keyingi bosqich (header) qayerdan boshlashini biladi degani.
Tokenlar faqat parse tugagach, bufer manzili bilan resolve qilinadi - mana zero-copy'ning yakuni:
void ktc_req_line_parser_resolve(ktc_req_line_parser_t *parser, const uint8_t *buf_base) {
if (parser->state == KTC_REQ_LINE_STATE_COMPLETE) {
parser->method = ktc_str_from(buf_base + parser->method_start, parser->method_len);
parser->target = ktc_str_from(buf_base + parser->target_start, parser->target_len);
parser->version = ktc_str_from(buf_base + parser->version_start, parser->version_len);
} else {
parser->method = ktc_str_null();
// ...
}
}
Hozircha ochiq gap: version validatsiya qilinmayapti
Joriy implementatsiya VERSION tokenini shaklan qabul qiladi, lekin uni HTTP/1.1 ga tenglashtirmaydi. RFC 9112 §2.3 bo'yicha noto'g'ri versiyaga 505 (HTTP Version Not Supported) kerak. Bu keyingi milestone'da yopiladi - ayniqsa HTTP/1.0 vs 1.1 farqi keep-alive semantikasi uchun zarur bo'ladi.
Header Parsing
Request-line'dan keyin header bloki keladi: Name: value\r\n qatorlari, bo'sh \r\n (double CRLF) bilan yakunlanadi. Bu - HTTP'ning eng ko'p smuggling zaifligi to'plangan joyi, shu sabab parser qat'iy bo'lishi shart.
Kod:
d0f62e9
Yana state machine, kengroq alfavit bilan:
Har bir header nomi/qiymati indekslar bilan saqlanadi (zero-copy), va parser umumiy chegaralarni kuzatadi:
#define KTC_MAX_HEADERS 64
#define MAX_HEADERS_SIZE 16384
typedef struct {
ktc_header_state_t state;
ktc_header_err_t error;
ktc_header_t headers[KTC_MAX_HEADERS];
size_t header_count;
ktc_str host;
// internal indices...
size_t total_headers_size;
} ktc_header_parser_t;
Chegaralar va DoS himoyasi
Savol: klient cheksiz katta header bloki yuborsa?
Talab (H11-HDR-004). Header qatori, qiymati yoki butun header seksiyasi server chegarasidan oshsa - 4xx. Chegarasiz o'sish = OOM = DoS.
Implementatsiya. Har bir baytda ikki hisoblagichni oshiramiz va tekshiramiz - umumiy o'lcham, va header soni:
parser->total_headers_size++;
if (parser->total_headers_size > MAX_HEADERS_SIZE) {
parser->error = KTC_HEADER_ERR_TOO_LARGE;
return false;
}
// ... CR holatida yangi header qo'shishdan oldin:
if (parser->header_count >= KTC_MAX_HEADERS) {
parser->error = KTC_HEADER_ERR_TOO_LARGE;
return false;
}
mainda bu xato 431 (Request Header Fields Too Large) ga map qilinadi. Nega har baytda tekshiramiz - yana o'sha erta uzish printsipi: 1 MB header kutib o'tirmaymiz, chegarada darrov to'xtatamiz.
Xavfli syntax: bo'shliq, obs-fold, control belgilar
Savol: Host : localhost - nom va ikki nuqta orasidagi bo'shliqchi?
Talab (H11-HDR-001). Header nomi va : orasidagi bo'shliq - 400, istisnosiz. Bu klassik smuggling vektori.
Implementatsiya. NAME holatida : dan oldin bo'shliq/tab/CR/LF kelsa - darhol xato:
case KTC_HEADER_STATE_NAME:
if (c == ':') {
if (parser->current_name_len == 0) { /* 400: bo'sh nom */ }
parser->state = KTC_HEADER_STATE_VALUE_START;
} else if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
// Colon oldidagi bo'shliq taqiqlangan (H11-HDR-001)
parser->error = KTC_HEADER_ERR_BAD_SYNTAX;
return false;
} else {
if (!is_token_char(c)) { /* 400 */ }
if (parser->current_name_len == 0) {
parser->current_name_start = current_idx;
}
parser->current_name_len++;
}
break;
Savol: qiymatni bir necha qatorga bo'lib yozish (obs-fold) ruxsatmi?
Talab (H11-HDR-002). Obs-fold (qiymatni davom ettirish uchun keyingi qatorni bo'shliq bilan boshlash) - request'da 400 (afzal). Bu deprecated va smuggling xavfli.
Implementatsiya. Header tugagach (CRLF holati), keyingi qator bo'shliq/tab bilan boshlansa - bu obs-fold, rad etamiz:
case KTC_HEADER_STATE_CRLF:
if (c == '\r') {
parser->state = KTC_HEADER_STATE_DOUBLE_CRLF;
} else if (c == ' ' || c == '\t') {
// Obs-fold taqiqlangan (H11-HDR-002)
parser->error = KTC_HEADER_ERR_BAD_SYNTAX;
return false;
} else {
// yangi header nomi boshlandi
parser->current_name_start = current_idx;
parser->current_name_len = 1;
parser->state = KTC_HEADER_STATE_NAME;
}
break;
Savol: qiymat ichidagi xom CR/LF/NUL?
Talab (H11-HDR-003). Field-value ichida CR, LF yoki NUL - xabarni rad etamiz. Bu belgilar header injection'ga yo'l ochadi.
Implementatsiya. VALUE holatida \r normal yakun; boshqa control belgi (tab bundan mustasno) - xato:
case KTC_HEADER_STATE_VALUE:
if (c == '\r') {
parser->state = KTC_HEADER_STATE_CR;
} else if (c < 0x20 || c == 0x7F) {
if (c == '\t') {
parser->current_value_len++;
} else {
// Control/NUL/xom LF invalid (H11-HDR-003)
parser->error = KTC_HEADER_ERR_BAD_SYNTAX;
return false;
}
} else {
parser->current_value_len++;
}
break;
Qiymat resolve paytida esa oldingi/keyingi OWS (optional whitespace) trim qilinadi:
size_t vstart = parser->value_start[i];
size_t vlen = parser->value_len[i];
while (vlen > 0 &&
(buf_base[vstart + vlen - 1] == ' ' || buf_base[vstart + vlen - 1] == '\t')) {
vlen--;
}
parser->headers[i].value = ktc_str_from(buf_base + vstart, vlen);
Host invariantlari
Savol: HTTP/1.1 so'rovda Host yo'q, yoki ikkita, yoki noto'g'ri bo'lsa?
Talab (H11-HOST-001). Host yo'q, bir nechta, yoki invalid → 400. HTTP/1.1'da Host majburiy - virtual hosting'ning asosi.
Talab (H11-HOST-002). Agar target absolute-form (http://host/path) bo'lsa - Host header'ni e'tiborsiz qoldiramiz va authorityni target'dan olamiz.
Implementatsiya. Avval absolute-form tekshiramiz; bo'lsa authorityni URI'dan ajratamiz:
bool is_absolute = false;
if (starts_with_case_insensitive(request_target.ptr, request_target.len, "http://", 7) ||
starts_with_case_insensitive(request_target.ptr, request_target.len, "https://", 8)) {
is_absolute = true;
}
if (is_absolute) {
const uint8_t *p = request_target.ptr;
size_t rem = request_target.len;
// scheme'ni o'tkazamiz...
const uint8_t *slash = memchr(p, '/', rem);
size_t host_len = slash ? (size_t)(slash - p) : rem;
parser->host = ktc_str_from(p, host_len);
return true;
}
Aks holda Host header'ini qidiramiz, dublikatni rad etamiz, yo'qligini rad etamiz:
bool host_found = false;
for (size_t i = 0; i < parser->header_count; i++) {
if (ktc_str_eq_case_insensitive(parser->headers[i].name, ktc_str_from_cstr("Host"))) {
if (host_found) {
parser->error = KTC_HEADER_ERR_DUPLICATE_HOST;
return false;
}
host_found = true;
parser->host = parser->headers[i].value;
}
}
if (!host_found) {
parser->error = KTC_HEADER_ERR_MISSING_HOST;
return false;
}
Nega absolute-form Hostdan ustun. RFC 9112 §3.2.3: agar target authorityni to'liq bersa, Host header (klient yuborgan bo'lsa ham) ishonchsiz - target haqiqat manbai. Bu ikki xil authority talqin qilinib smuggling bo'lishining oldini oladi.
Body Parsing
Oxirgi bosqich - message body. Bu HTTP'ning eng nozik qismi: bu yerda request smuggling hujumlarining aksariyati yashaydi. Sabab - body uzunligini aniqlashning bir necha yo'li bor (Content-Length, Transfer-Encoding: chunked), va ular kelishmovchilikka tushsa, ikki server (masalan proxy va origin) xabar chegarasini boshqacha ko'radi.
Kod:
89fb402
Shu sabab body parsing ikki bosqichga bo'linadi: avval framing'ni aniqlash (qanday o'qiymiz?), keyin body'ni o'qish.
typedef enum {
KTC_BODY_FRAMING_NONE,
KTC_BODY_FRAMING_LENGTH,
KTC_BODY_FRAMING_CHUNKED
} ktc_body_framing_t;
Framing aniqlash: §6.3 pretsedenti
Savol: body uzunligini qanday aniqlaymiz? Va Content-Length bilan Transfer-Encoding ikkalasi ham bo'lsa-chi?
Talab (H11-FRAME-001). RFC 9112 §6.3 pretsedent tartibini aynan amalga oshirish kerak - ad-hoc yorliqlarsiz.
Talab (H11-FRAME-004, H11-SEC-003). Transfer-Encoding va Content-Length ikkalasi bir vaqtda kelsa - bu smuggling belgisi, darhol rad etamiz.
Implementatsiya. Header'lar bo'ylab yuramiz, TE va CL'ni topamiz, dublikat CL'ni ham ushlaymiz:
for (size_t i = 0; i < header_parser->header_count; i++) {
if (ktc_str_eq_case_insensitive(header_parser->headers[i].name,
ktc_str_from_cstr("Transfer-Encoding"))) {
has_te = true;
te_val = header_parser->headers[i].value;
}
if (ktc_str_eq_case_insensitive(header_parser->headers[i].name,
ktc_str_from_cstr("Content-Length"))) {
if (has_cl) {
return false; // Dublikat Content-Length invalid (H11-FRAME-003)
}
has_cl = true;
cl_val = header_parser->headers[i].value;
}
}
// TE va CL ikkalasi ham → ambiguity → smuggling'ni oldini olib darhol rad
if (has_te && has_cl) {
return false;
}
Savol: HEAD so'rovda body bo'lishi mumkinmi?
Talab (H11-FRAME-001, 4-band). HEAD so'rov body ko'tarmaydi. Aniq NONE.
if (ktc_str_eq_case_insensitive(method, ktc_str_from_cstr("HEAD"))) {
parser->framing = KTC_BODY_FRAMING_NONE;
return true;
}
Savol: Transfer-Encoding: gzip, chunked - chunked oxirgi bo'lmasa?
Talab (H11-FRAME-002). Request'da TE bor, lekin chunked oxirgi coding emas → 400, close. Faqat oxirgi coding chunked bo'lsagina uzunlik aniqlanadi.
Implementatsiya. Oxirgi comma'dan keyingi tokenni ajratib, trim qilib, chunkedga tenglashtiramiz:
const uint8_t *last_comma = custom_memrchr(te_val.ptr, ',', te_val.len);
ktc_str last_token = te_val;
if (last_comma) {
size_t offset = (size_t)(last_comma - te_val.ptr);
last_token = ktc_str_from(last_comma + 1, te_val.len - offset - 1);
}
// last_token'ni bo'shliqdan trim qilamiz...
if (ktc_str_eq_case_insensitive(last_token, ktc_str_from_cstr("chunked"))) {
parser->framing = KTC_BODY_FRAMING_CHUNKED;
parser->chunk_parser.state = KTC_CHUNK_STATE_SIZE;
} else {
return false; // chunked oxirgi emas (H11-FRAME-002)
}
Savol: Content-Length ni qanday parse qilamiz? Overflow-chi?
Talab (H11-FRAME-003). Invalid CL → 400. Bu yerda "invalid" ichiga integer overflow ham kiradi.
Implementatsiya. Qo'lda, raqamma-raqam, har qadamda overflow'ni tekshirib:
if (has_cl) {
if (cl_val.len == 0) {
return false;
}
size_t cl_len = 0;
for (size_t i = 0; i < cl_val.len; i++) {
if (cl_val.ptr[i] < '0' || cl_val.ptr[i] > '9') {
return false; // Faqat raqamlar (H11-FRAME-003)
}
size_t next_val = cl_len * 10 + (size_t)(cl_val.ptr[i] - '0');
if (next_val < cl_len) {
return false; // Overflow
}
cl_len = next_val;
}
parser->framing = KTC_BODY_FRAMING_LENGTH;
parser->content_length = cl_len;
return true;
}
Nega strtoul emas, qo'lda. strtoul +, -, oldingi bo'shliq, 0x prefikslarni "kechiradi" - bularning har biri smuggling yuzasi. Qo'lda parse qilib, faqat sof raqamlarni qabul qilamiz va overflowni aniq nazorat qilamiz.
Body o'qish: length va chunked
Length framing sodda - belgilangan miqdorda bayt ko'chiramiz, bufer overflow'dan himoyalanib:
if (parser->framing == KTC_BODY_FRAMING_LENGTH) {
size_t rem = parser->content_length - parser->body_consumed;
size_t chunk = len < rem ? len : rem;
if (*out_len + chunk > max_len) {
return false; // Output bufer overflow himoyasi
}
memcpy(out_buf + *out_len, data, chunk);
*out_len += chunk;
parser->body_consumed += chunk;
return parser->body_consumed < parser->content_length;
}
Chunked esa yana bir state machine:
SIZE → [EXTENSION] → SIZE_CRLF → DATA → DATA_CRLF → SIZE (keyingi chunk)
↘ (size 0) TRAILERS → COMPLETE
Savol: ulkan hex chunk-size (FFFF...) yuborilsa?
Talab (H11-CHUNK-003). Katta hex chunk-size'larni oldindan ko'zda tutish; overflow/aniqlik yo'qolishini oldini olish.
Implementatsiya. Har hex raqamdan oldin siljish overflow'ini tekshiramiz:
case KTC_CHUNK_STATE_SIZE:
if (/* hex digit */) {
// ... digit ni hisoblaymiz
if (cp->chunk_size > (SIZE_MAX >> 4)) {
cp->state = KTC_CHUNK_STATE_ERROR;
return false;
}
cp->chunk_size = (cp->chunk_size << 4) | digit;
} else if (c == ';') {
cp->state = KTC_CHUNK_STATE_EXTENSION;
} else if (c == '\r') {
cp->state = KTC_CHUNK_STATE_SIZE_CRLF;
} else {
cp->state = KTC_CHUNK_STATE_ERROR;
return false;
}
break;
Savol: chunk extension'lar (;name=value)?
Talab (H11-CHUNK-002). Tanilmagan chunk extension'larni e'tiborsiz qoldiramiz (skip).
case KTC_CHUNK_STATE_EXTENSION:
if (c == '\r') {
cp->state = KTC_CHUNK_STATE_SIZE_CRLF;
} else if (c == '\n') {
cp->state = KTC_CHUNK_STATE_ERROR; // CRsiz LF invalid
return false;
}
// aks holda extension baytini jimgina o'tkazamiz (H11-CHUNK-002)
break;
Chunk data'ni bayt-bayt out buferga yozamiz, chunk_remaining nolga yetganda DATA_CRLFga o'tamiz, va 0-o'lchamli chunk kelganda TRAILERS → COMPLETE.
Hozircha ochiq gap: trailer parsing va body limit
Ikki nuqta keyingi milestone'ga qoldi. Birinchisi - chunked trailer parsing hozir faqat trailersiz holatni (0\r\n\r\n) to'g'ri ushlaydi; haqiqiy trailer field'lari bilan erta yakunlanadi. Ikkinchisi - Content-Length yoki chunked body uchun umumiy o'lcham chegarasi yo'q (413 kerak). Bularni RFC bo'yicha to'liq yopganimizda alohida yoritamiz.
Shu bilan birinchi qism yakunlandi. Bizda endi: libuv event loop ustida to'g'ri lifecycle boshqaruvi, va uch bosqichli - request-line, header, body - zero-copy, incremental, smuggling-aware parser zanjiri bor. Har bir qadam RFC invariantiga bog'landi, checklistga emas.
Keyingi postda: persistent connection (keep-alive), pipelining, va timerlar. Aynan diagrammada ko'rgan Read/Write fazalarini ajratish shu yerda meva beradi.
Invariants
Invariantlar to'liq ro'yxatini serverimiz official reposida ko'rishingiz mumkin: INVARIANTS.
Global Lifecycle Rules
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-LIFE-001 | MUST | A connection carries ordered request/response pairs; responses on a connection MUST correspond to requests in the same order received (pipelining). | 9112 §9.2, 9112 §9.3.2 |
| H11-LIFE-002 | MUST | Do not apply a request to a resource until the entire request header section has been received. | 9110 §5.3 |
| H11-LIFE-003 | MUST | On a persistent connection, after sending a response, either read the entire request body of the current request or close the connection. | 9112 §9.3 |
| H11-LIFE-004 | MUST | Every message on a persistent connection MUST have a self-defined length (not connection-close delimited), except allowed cases in §6.3. | 9112 §9.3 |
| H11-LIFE-005 | MUST NOT | After sending or honoring Connection: close, process no further requests on that connection. |
9112 §9.6 |
| H11-LIFE-006 | SHOULD | Close connections in stages (half-close write, drain read) to avoid TCP RST wiping the final response. | 9112 §9.6 |
Message Parsing and Grammar
Octet Stream and Line Endings
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-PARSE-001 | MUST | Parse messages as a sequence of octets in a superset of US-ASCII - never as a Unicode character stream. | 9112 §2.2 |
| H11-PARSE-002 | MUST NOT | Generate bare CR (CR not followed by LF) in protocol elements except message content. | 9112 §2.2 |
| H11-PARSE-003 | MUST | Treat received bare CR as invalid or replace each with SP before processing. | 9112 §2.2 |
| H11-PARSE-004 | MUST NOT | Send whitespace between start-line and first header field. | 9112 §2.2 |
| H11-PARSE-005 | MUST | On whitespace between start-line and first header: reject or consume/ignore lines per §2.2 (smuggling mitigation). | 9112 §2.2 |
| H11-PARSE-006 | SHOULD | Ignore at least one empty line (CRLF) before the request-line. | 9112 §2.2 |
| H11-PARSE-007 | SHOULD | On grammar violation (outside robustness exceptions): respond 400 and close connection. | 9112 §2.2 |
Request Line
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-REQLINE-001 | MUST | Accept origin-form, absolute-form, authority-form (CONNECT), and asterisk-form (OPTIONS *). |
9112 §3.2 |
| H11-REQLINE-002 | MUST | Respond 414 (URI Too Long) if request-target exceeds server limit. | 9112 §3.2, 9110 §15.5.15 |
| H11-REQLINE-003 | SHOULD | Respond 501 (Not Implemented) for method longer than any implemented method. | 9112 §3.2 |
| H11-REQLINE-004 | SHOULD | On invalid request-line: 400 or 301 with properly encoded target - do not silently autocorrect. | 9112 §3.2 |
| H11-REQLINE-005 | MAY | Lenient whitespace parsing in request-line - discouraged (smuggling risk if inconsistent with other parsers). | 9112 §3.2 |
Status-line (responses the server sends)
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-STATUS-001 | MUST | Send the SP between status-code and reason-phrase even when reason-phrase is empty. | 9112 §4 |
| H11-STATUS-002 | MUST NOT | Send an HTTP version in responses that the server is not conformant with. | 9110 §6.2 |
| H11-STATUS-003 | SHOULD | Response version = highest conformant version with major ≤ request major. | 9110 §6.2 |
| H11-STATUS-004 | MAY | Send 505 (HTTP Version Not Supported) to refuse client major version. | 9110 §6.2 |
| H11-STATUS-005 | MUST NOT | Send 1xx response to an HTTP/1.0 client. | 9110 §15.2 |
Host and Request Target
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-HOST-001 | MUST | Respond 400 to HTTP/1.1 request without Host, with multiple Host field lines, or with invalid Host value. |
9112 §3.2, 9110 §7.2 |
| H11-HOST-002 | MUST | On absolute-form request-target: ignore received Host; use authority from request-target. |
9112 §3.2.3 |
| H11-HOST-003 | MUST | Accept absolute-form even from direct clients (not only proxies). | 9112 §3.2.3 |
| H11-HOST-004 | MUST | Reject CONNECT with empty/invalid port → 400. | 9110 §9.3.6 |
| H11-HOST-005 | MUST | Reject https scheme requirements if not received over valid TLS for that origin (unless trusted gateway). |
9110 §4.3.3 |
| H11-HOST-006 | MAY | For empty authority on http/https URI: reject or apply configured default consistent with connection context. |
9112 §3.2.3 |
Headers
Syntax and Dangerous Fields
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-HDR-001 | MUST | Reject 400 any request with whitespace between header field name and :. |
9112 §5.2 |
| H11-HDR-002 | MUST | On obs-fold in request (outside message/http): 400 (preferred) or replace fold with SP before interpreting. |
9112 §5.2 |
| H11-HDR-003 | MUST | On CR, LF, or NUL in field value: reject message or replace with SP. |
9110 §5.5 |
| H11-HDR-004 | MUST | Respond 4xx when header line, value, or entire header section exceeds server limits. | 9110 §5.4 |
| H11-HDR-005 | MUST NOT | Apply request until full header section received. | 9110 §5.3 |
| H11-HDR-006 | SHOULD | Ignore unrecognized header/trailer fields (unless field definition says otherwise). | 9110 §5.1 |
Response header obligations
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-HDR-007 | MUST | Origin server with clock: generate Date on all 2xx, 3xx, 4xx responses. |
9110 §6.6.1 |
| H11-HDR-008 | MAY | Generate Date on 1xx and 5xx. |
9110 §6.6.1 |
| H11-HDR-009 | MUST | Generate Allow on 405 (Method Not Allowed). |
9110 §10.2.1 |
| H11-HDR-010 | MUST | Generate WWW-Authenticate on 401 (≥1 challenge). |
9110 §11.6.1 |
Message Body Framing
Body length is determined by RFC 9112 §6.3 precedence (implement exactly this order):
Transfer-Encoding: chunked(final coding) → decode chunks until zero chunk + trailers.Transfer-Encodingpresent in response, chunked not final → read until connection close.Transfer-Encodingin request, chunked not final → 400, close.- HEAD response, or 1xx / 204 / 304 → no body; terminated by empty line after headers.
- 2xx CONNECT → tunnel after header empty line; ignore TE/CL in that response.
- Both
Transfer-EncodingandContent-Length→ TE wins; treat as error (smuggling); server MUST close after response. - Invalid
Content-Length(unrecoverable) → 400, close (request). - Valid
Content-Lengthwithout TE → that many octets; incomplete → close. - Request, none of above → body length 0.
- Response, none of above → body until connection close.
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-FRAME-001 | MUST | Implement §6.3 precedence exactly - no ad-hoc shortcuts. | 9112 §6.3 |
| H11-FRAME-002 | MUST | On request TE present, chunked not final: 400, close. | 9112 §6.3 |
| H11-FRAME-003 | MUST | On invalid CL in request: 400, close (unless comma-list duplicate same values). | 9112 §6.3 |
| H11-FRAME-004 | MUST | On both TE and CL in request: process per TE or reject; always close after response. | 9112 §6.3 |
| H11-FRAME-005 | MUST | On HTTP/1.0 message with Transfer-Encoding: treat framing faulty, close after processing. |
9112 §6.2 |
| H11-FRAME-006 | MUST NOT | Send Content-Length in message that has Transfer-Encoding. |
9112 §6.2 |
| H11-FRAME-007 | SHOULD | Prefer length-delimited or chunked responses over close-delimited. | 9112 §6.3 |
| H11-FRAME-008 | MAY | Reject request body without Content-Length (non-chunked) with 411. |
9112 §6.3 |
Responses Without Body
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-FRAME-009 | MUST NOT | Send Content-Length on 1xx or 204 responses. |
9110 §8.6, 9112 §6.1 |
| H11-FRAME-010 | MUST NOT | Send Transfer-Encoding on 1xx or 204 responses. |
9112 §6.1 |
| H11-FRAME-011 | MUST NOT | Send Content-Length or Transfer-Encoding on 2xx CONNECT response. |
9110 §9.3.6, 9112 §6.1 |
| H11-FRAME-012 | MUST NOT | Send content in HEAD response. | 9110 §9.3.2 |
| H11-FRAME-013 | MUST NOT | Generate content in 205 response. | 9110 §15.3.6 |
Chunked Transfer Encoding
| ID | Level | Invariant | RFC |
|---|---|---|---|
| H11-CHUNK-001 | MUST | Be able to parse and decode chunked transfer coding. | 9112 §7.1 |
| H11-CHUNK-002 | MUST | Ignore unrecognized chunk extensions. | 9112 §7.1.1 |
| H11-CHUNK-003 | MUST | Anticipate large hex chunk-size values; prevent overflow/precision loss. | 9112 §7.1 |
| H11-CHUNK-004 | SHOULD | Treat chunk extension parameters as error. | 9112 §7.1 |
| H11-CHUNK-005 | ought | Limit total chunk-extension length; 4xx if exceeded. | 9112 §7.1.1 |
| H11-CHUNK-006 | MUST NOT | Send Transfer-Encoding on response unless request indicates HTTP/1.1+. |
9112 §6.1 |
| H11-CHUNK-007 | SHOULD | Respond 501 to transfer coding not understood. | 9112 §6.1 |
