সরাসরি পর্বে যান
পর্ব ৪৫ · ৬২-এর মধ্যে · মডিউল ৭

প্রজেক্ট — C-তে একটি ছোট HTTP সার্ভার

Project — A Small, Correct and Safe HTTP/1.1 Static-File Server in C

Taught in Bangla, this hands-on project builds a small, correct and safe HTTP/1.1 static-file server in C through milestones: accepting connections and parsing the request head, routing and correct status codes, safe file serving with path checks, MIME types, streaming and ETag, then keep-alive, timeouts and multiple clients, ending with the full mini_httpd.c, a defensive-coding checklist, logging, testing and measurement.

এই পর্বে যা শিখবেন

  • একটি HTTP server-এর নকশা — একটি request-এর পুরো জীবনচক্র, কোন ফাংশন কী দায়িত্ব নেয় আর কোথায় কোন status code জন্মায়
  • চারটি মাইলস্টোনে চালানো যায় এমন C কোড: head পড়া ও parse, routing ও status code, নিরাপদ ফাইল পরিবেশন, keep-alive ও concurrency
  • Path traversal, header injection, integer overflow, NUL byte আর Slowloris — প্রতিটির প্রতিরক্ষা নিজের কোডে বসানো
  • Common Log Format, curl ও load-testing টুল দিয়ে নিজের server পরীক্ষা, আর sanitizer ও fuzzing-এর ধারণা
  • কেন বাস্তবে nginx বা Caddy সামনে রাখা হয়, আর এই প্রজেক্টকে Range, gzip, TLS বা HTTP/2-এর দিকে বাড়ানোর পথ

১ · প্রজেক্টের লক্ষ্য আর নকশা — একটি request-এর জীবনচক্র

মডিউল ৭-এর আগের চার পর্বে আপনি একটি একটি করে যন্ত্রাংশ বানিয়েছেন: TCP socket, readn/writen আর SIGPIPE (পর্ব ৪১), UDP (পর্ব ৪২), fork-thread-pool দিয়ে একসাথে অনেক client (পর্ব ৪৩), আর select/poll/epoll (পর্ব ৪৪)। পর্ব ৩৬-এ দেখেছেন HTTP/1.1-এর বার্তা কেমন, আর সেখানকার ছোট server শুধু 200 আর 404 জানত। এই পর্বে সব জোড়া লাগিয়ে বানাব mini_httpd — একটি ছোট static file server, যা একটি ফোল্ডারের ফাইল HTTP/1.1-এ পরিবেশন করে, আর যার প্রতিটি লাইনের পেছনে একটি “কেন” আছে।

যা বানাব

GET আর HEAD; সঠিক status code; document root-এর ভেতর থেকে নিরাপদে ফাইল; MIME type; ETag আর 304; keep-alive; timeout; fork দিয়ে একসাথে অনেক client; log.

ছোট, সঠিক, নিরাপদ

যা বানাব না

POST/upload, dynamic পেজ, TLS, HTTP/2 — এগুলো অনুশীলন আর “সামনে কোথায়” অংশে ইঙ্গিত হিসেবে থাকবে। ছোট পরিসরে সঠিক হওয়াই লক্ষ্য।

পরিসর সীমিত

উপজেলা ভূমি অফিসের নথি শাখার কথা ভাবুন। কাউন্টারে একজন কর্মী বসে আছেন (server). আপনি একটি আবেদনপত্র দিলেন (request). তিনি পুরো ফর্মটি হাতে না আসা পর্যন্ত পড়া শুরু করেন না (head পড়া), ফর্ম ঠিকমতো পূরণ হয়েছে কি না দেখেন (parse), আপনি যে আলমারির নথি চাইছেন সেটি জনসাধারণের জন্য খোলা কি না যাচাই করেন (path পাহারা), নথি এনে দেন (ফাইল পরিবেশন), রেজিস্টার খাতায় লেখেন (log). আরেকটি প্রশ্ন থাকলে আপনি কাউন্টারেই থাকেন (keep-alive) — কিন্তু অনেকক্ষণ চুপ করে দাঁড়িয়ে থাকলে পরের জনকে ডাকা হয় (timeout). আর কেউ যদি “পাশের ঘরের আলমারি থেকেও দিন” বলে চালাকি করে লেখে, কর্মী সেটা ধরেন — এটাই path traversal প্রতিরোধ।

একটি request-এর জীবনচক্র — mini_httpd-এর ফাংশনগুলো

accept → read_head → parse → route → safe_path → serve_file → log → keep-alive or close

  1. acceptfork()নতুন সংযোগ
  2. read_head\r\n\r\n431 · 408
  3. parse + routemethod · Host400 · 405 · 501
  4. safe_path%XX · ..400 · 403
  5. serve_file + log200 · 304404 · তারপর keep-alive
ছবি ৪৫.১ — প্রতিটি ধাপ একটি ফাংশন, আর প্রতিটি ফাংশনের ব্যর্থতার নিজস্ব status code আছে। ব্যর্থ হলে পরের ধাপে যাওয়াই হয় না।

এই ভাগাভাগি শুধু সাজানোর জন্য নয়। প্রতিটি ফাংশনের একটিই কাজ হলে তাকে আলাদা করে পরীক্ষা করা যায়, আর নিরাপত্তার যাচাই একটি নির্দিষ্ট জায়গায় থাকে — “ফাইল খোলার আগে path যাচাই হয়েছে কি?” প্রশ্নের উত্তর খুঁজতে পুরো কোড পড়তে হয় না। কাজটি চারটি মাইলস্টোনে ভাগ করা:

মাইলস্টোনযোগ হয়নতুন status code
M1 · পড়া ও parseaccept, ফাঁকা লাইন পর্যন্ত জমিয়ে পড়া, request line আর header একটি struct-এ200 (Content-Length-সহ)
M2 · routingযাচাইয়ের নির্দিষ্ট ক্রম, HEAD, error পেজ400, 404, 405, 431, 501
M3 · নিরাপদ ফাইলpercent-decode, traversal প্রতিরোধ, MIME, streaming, ETag403, 304
M4 · keep-alive ও concurrencyএকই সংযোগে অনেক request, idle timeout, fork-per-connection, logসংযোগ বন্ধের নিয়ম
সম্পূর্ণ কোডসব একসাথে, আরও কড়া parse, realpath, Slowloris-এর সময়সীমা408, 413, 414, 505

প্রতিটি মাইলস্টোন একটি স্বয়ংসম্পূর্ণ প্রোগ্রাম, যা এই পাতাতেই চালানো যায়। চালানোর পরিবেশে বাইরের ইন্টারনেট নেই, তাই প্রোগ্রাম fork() দিয়ে নিজেকে দুই ভাগ করে: একটি ভাগ server (loopback ঠিকানা 127.0.0.1-এ), অন্য ভাগ একটি পরীক্ষক client, যে কয়েকটি request পাঠিয়ে উত্তর ছাপে। আউটপুটের ক্রম ঠিক রাখতে সাধারণত শুধু client ছাপে। জায়গা বাঁচাতে কোডের লাইন কিছুটা ঘন; নিজের কম্পিউটারে লেখার সময় ছড়িয়ে লিখুন।

২ · মাইলস্টোন ১ — সংযোগ নেওয়া, head পড়া আর parse করা

প্রথম প্রলোভন: “একটি read() করলেই তো request চলে আসে।” পর্ব ৩৬-এর ছোট server তাই করত, আর loopback-এ ছোট বার্তার জন্য সেটা কাজও করে। কিন্তু TCP একটি byte stream — সে বার্তার সীমানা রাখে না (পর্ব ৪১)। ধীর মোবাইল সংযোগে একটি request দুই-তিন টুকরোয় আসতে পারে; আবার কোনো client দুটি request একসাথে পাঠাতে পারে। তাই নিয়ম:

  • শেষ চিনুন: HTTP/1.1-এর head শেষ হয় একটি ফাঁকা লাইনে, অর্থাৎ বাইট-ক্রম \r\n\r\n (CRLF দুবার)। যতক্ষণ এটা দেখা না যায়, পড়তে থাকুন।
  • সীমা রাখুন: কেউ কখনো ফাঁকা লাইন না পাঠালে আপনার buffer অসীম হতে পারে না। একটি নির্দিষ্ট আকার (এখানে ১০২৪ বাইট, পূর্ণ কোডে ৮১৯২) ভরে গেলে উত্তর 431 Request Header Fields Too Large (RFC 6585) — কিছু server এ অবস্থায় সাধারণ 400-ও দেয়।
  • প্রতিটি কপিতে আকার: sscanf-এ %s কখনো একা নয় — %15s, %255s, যাতে কোনো লম্বা method বা target আপনার array উপচে না যায়। C-তে string আর buffer-এর এই নিয়মগুলো বিস্তারিত আছে C কোর্সের string পর্বে।
  • Header-এর নাম বড়-ছোট হাতের অক্ষরে আলাদা নয়: Host, host, hOsT একই (পর্ব ৩৬)। তাই তুলনায় strncasecmp.

নিচের প্রোগ্রামে client ইচ্ছে করে request-টি তিন টুকরোয়, প্রতিটির মাঝে ১০০ ms বিরতি দিয়ে পাঠায় — ধীর নেটওয়ার্কের নকল। Server-এর read_head() কয়বার read() করল, তা উত্তরের body-তেই লিখে দেয়। Request-এর অংশগুলো রাখা হয় struct req-এ:

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <arpa/inet.h>
struct req { char method[16], target[256], version[16], host[128]; };  /* the parsed request */
static int read_head(int s, char *b, int cap, int *reads) {   /* >0 head size, 0 closed, -1 too big */
    int n = 0; ssize_t k; b[0] = '\0';
    while (!strstr(b, "\r\n\r\n")) {                           /* TCP is a byte stream: keep reading */
        if (n == cap - 1) return -1;                           /* limit reached: answer 431 */
        if ((k = read(s, b + n, cap - 1 - n)) <= 0) return 0;
        n += k; b[n] = '\0'; ++*reads; }
    return n; }
static void parse(const char *b, struct req *r) { sscanf(b, "%15s %255s %15s", r->method, r->target, r->version);
    for (const char *p = strstr(b, "\r\n") + 2; strncmp(p, "\r\n", 2) != 0; p = strstr(p, "\r\n") + 2)
        if (strncasecmp(p, "Host:", 5) == 0) sscanf(p + 5, " %127[^\r]", r->host); }   /* any case */
int main(void) {
    struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons(47451), .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
    int ls = socket(AF_INET, SOCK_STREAM, 0), n, got = 0; char b[1024], out[1024], body[600];
    setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int)); fflush(stdout);
    if (bind(ls, (struct sockaddr *)&a, sizeof a) < 0 || listen(ls, 8) < 0) { perror("bind"); return 1; }
    if (fork() == 0) {                                         /* child = server, one connection */
        int s = accept(ls, NULL, NULL), reads = 0, h = read_head(s, b, sizeof b, &reads); struct req r = {0};
        if (h > 0) { parse(b, &r); n = snprintf(body, sizeof body, "method=%s target=%s version=%s host=%s reads=%d\n",
                                                  r.method, r.target, r.version, r.host, reads); }
        n = snprintf(out, sizeof out, "HTTP/1.1 %s\r\nContent-Type: text/plain\r\nContent-Length: %d\r\n\r\n%s",
                     h > 0 ? "200 OK" : "431 Request Header Fields Too Large", h > 0 ? n : 0, h > 0 ? body : "");
        if (h != 0 && write(s, out, n) != n) perror("write");
        shutdown(s, SHUT_WR); while (read(s, b, sizeof b) > 0) {} close(s); _exit(0); }   /* lingering close */
    const char *part[] = {"GET /hello HT", "TP/1.1\r\nhOsT: loc", "alhost\r\nUser-Agent: demo\r\n\r\n"};
    int c = socket(AF_INET, SOCK_STREAM, 0); if (connect(c, (struct sockaddr *)&a, sizeof a) < 0) return 1;
    for (int i = 0; i < 3; i++) { if (write(c, part[i], strlen(part[i])) < 0) return 1; usleep(100000); }  /* 3 pieces */
    while ((n = read(c, out + got, sizeof out - 1 - got)) > 0) got += n;
    out[got] = '\0'; printf("client got %d bytes:\n", got); for (char *p = out; *p; p++) if (*p != '\r') putchar(*p);
    close(c); wait(NULL); return 0; }

আউটপুট:

client got 130 bytes:
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 65

method=GET target=/hello version=HTTP/1.1 host=localhost reads=3

reads=3 — request তিন টুকরোয় এসেছে, আর read_head() তিনবারই জমিয়ে রেখে প্রতিবার strstr(b, "\r\n\r\n") দিয়ে শেষ খুঁজেছে। এক read()-এর server হলে প্রথম টুকরো “GET /hello HT” পেয়েই ভুল parse করত। দ্বিতীয় লক্ষ করার বিষয়: client লিখেছিল hOsT: localhost, তবু host=localhost পাওয়া গেছে — strncasecmp-এর কল্যাণে। Body-র দৈর্ঘ্য ৬৫ বাইট (শেষের \n-সহ), তাই Content-Length: 65; মোট উত্তর ৬৫ বাইট body আর ৬৫ বাইট head মিলে ১৩০ বাইট। snprintf-এর ফেরত মানই Content-Length হিসেবে ব্যবহার হয়েছে, হাতে গোনা হয়নি — হাতে গোনা সংখ্যা কোড বদলালেই ভুল হয়ে যায়।

শেষ লাইনের shutdown(s, SHUT_WR) আর তারপর খালি করে পড়া — একে বলে lingering close. Server যদি না-পড়া বাইট রেখেই close() করে, Linux অন্য পক্ষকে RST পাঠাতে পারে, আর তাতে client-এর কাছে পৌঁছানো উত্তরও হারিয়ে যেতে পারে। আগে নিজের লেখার দিক বন্ধ করে (FIN), client বন্ধ না করা পর্যন্ত বাকিটা পড়ে ফেলে দিলে উত্তর নিরাপদে পৌঁছায়। একটি ছোট নোট: POSIX অনুযায়ী strncasecmp-এর ঘর <strings.h>; glibc-তে <string.h> থেকেই চলে আসে, তাই এখানে আলাদা include লাগেনি — পূর্ণ কোডে দুটোই আছে।

৩ · মাইলস্টোন ২ — routing আর সঠিক status code

পর্ব ৩৬-এর ছোট server দুটি কাজ করত না: Host না থাকলে 400, আর অনুমতিহীন method-এ Allow-সহ 405. এখন সেগুলোসহ পুরো সিদ্ধান্ত-প্রক্রিয়া বানাব। মূল কথা: যাচাইগুলোর একটি নির্দিষ্ট ক্রম আছে, আর প্রথম ব্যর্থতাই উত্তর ঠিক করে। আগে দেখতে হবে বার্তাটি HTTP হিসেবেই বোঝা যায় কি না, তারপর method, তারপর resource.

ক্রমযাচাইব্যর্থ হলেকেন
১Head সীমার মধ্যে শেষ হলো?431Memory বাঁচানো; বাকিটা পড়ার মানে নেই
২Request line-এ ঠিক তিনটি অংশ, target / দিয়ে শুরু, version HTTP/1.0 বা 1.1?400বার্তাই বোঝা যাচ্ছে না
৩HTTP/1.1 হলে ঠিক একটি Host?400RFC 9112-এর বাধ্যতামূলক নিয়ম; দুটি Host মানে বিভ্রান্তি
৪Method server চেনে?501 Not Implementedঅচেনা method-এর body কীভাবে পড়তে হয়, তাও জানা নেই
৫এই resource-এ method চলে?405 + Allow: GET, HEADRFC 9110 অনুযায়ী 405-এর সাথে Allow পাঠাতেই হবে
৬Resource আছে?404—
৭সব ঠিক200 (HEAD হলে body ছাড়া)HEAD-এর header GET-এর মতোই, Content-Length-ও একই

ক্রম কেন গুরুত্বপূর্ণ, একটি উদাহরণে দেখুন: DELETE /nothing HTTP/1.1, Host ছাড়া। ক্রম মানলে উত্তর 400 — কারণ বার্তাটিই অবৈধ। 405 বা 404 দিলে client ভুল জিনিস ঠিক করতে যাবে। নিচের প্রোগ্রামে client দশটি পরীক্ষা পাঠায়; জায়গা বাঁচাতে পরীক্ষার তালিকায় | চিহ্ন \r\n-এর বদলে লেখা, আর client পাঠানোর আগে সেটি আসল \r\n-এ বদলে নেয়। উত্তর ছাপার সময়ও \r\n দেখানো হয় | দিয়ে, যাতে এক লাইনে পুরো উত্তর দেখা যায়।

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <arpa/inet.h>
static int read_head(int s, char *b, int cap) { int n = 0; ssize_t k; b[0] = '\0';   /* as in M1 */
    while (!strstr(b, "\r\n\r\n")) { if (n == cap - 1) return -1; if ((k = read(s, b + n, cap - 1 - n)) <= 0) return 0; b[n += k] = '\0'; }
    return n; }
static const char *route(const char *b, const char **body) { char m[16], t[256], v[16], sp[20]; int hosts = 0;  /* first failure wins */
    if (sscanf(b, "%15s %255s %15s", m, t, v) != 3 || *t != '/' || (strcmp(v, "HTTP/1.1") && strcmp(v, "HTTP/1.0"))) return "400 Bad Request";
    for (const char *p = strstr(b, "\r\n") + 2; strncmp(p, "\r\n", 2); p = strstr(p, "\r\n") + 2) hosts += !strncasecmp(p, "Host:", 5);
    if (hosts > 1 || (hosts == 0 && !strcmp(v, "HTTP/1.1"))) return "400 Bad Request";   /* exactly one Host */
    snprintf(sp, sizeof sp, " %s ", m); if (!strstr(" GET HEAD POST PUT DELETE OPTIONS PATCH ", sp)) return "501 Not Implemented";
    if (strcmp(m, "GET") && strcmp(m, "HEAD")) return "405 Method Not Allowed";           /* known, not allowed here */
    if (strcmp(t, "/") == 0) { *body = "Welcome to mini-httpd"; return "200 OK"; } return "404 Not Found"; }
int main(void) { struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons(47452), .sin_addr = {htonl(INADDR_LOOPBACK)}};
    int ls = socket(AF_INET, SOCK_STREAM, 0), n; char b[1024], out[1024], q[1300], big[1200] = "GET / HTTP/1.1|X-Big: ";
    const char *T[] = {"GET / HTTP/1.1|Host: x||", "HEAD / HTTP/1.1|Host: x||", "GET /fees HTTP/1.1|Host: x||", "DELETE / HTTP/1.1|Host: x||",
        "BREW / HTTP/1.1|Host: x||", "GET / HTTP/1.1||", "GET / HTTP/1.0||", "GET / HTTP/1.1|Host: a|Host: b||", "GET fees HTTP/1.1|Host: x||", big};
    setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int)); memset(big + 22, 'a', sizeof big - 23); fflush(stdout);
    if (bind(ls, (struct sockaddr *)&a, sizeof a) < 0 || listen(ls, 16) < 0) { perror("bind"); return 1; }
    if (fork() == 0) { for (int i = 0; i < 10; i++) {                        /* child = server */
        const char *body = NULL; int s = accept(ls, NULL, NULL), h = read_head(s, b, sizeof b);
        const char *st = h < 0 ? "431 Request Header Fields Too Large" : route(b, &body); if (!body) body = st;  /* error page */
        n = snprintf(out, sizeof out, "HTTP/1.1 %s\r\nContent-Type: text/plain\r\nContent-Length: %zu\r\n%sConnection: close\r\n\r\n%s",
            st, strlen(body), strncmp(st, "405", 3) ? "" : "Allow: GET, HEAD\r\n", h > 0 && !strncmp(b, "HEAD ", 5) ? "" : body);
        if (h != 0 && write(s, out, n) != n) perror("write");
        shutdown(s, SHUT_WR); while (read(s, b, sizeof b) > 0) {} close(s); } _exit(0); }
    for (int i = 0, got = 0, c, k = 0; i < 10; i++, got = k = 0) {            /* parent = client */
        for (const char *p = T[i]; *p; p++) if (*p == '|') { q[k++] = '\r'; q[k++] = '\n'; } else q[k++] = *p;   /* '|' = \r\n */
        if ((c = socket(AF_INET, SOCK_STREAM, 0)) < 0 || connect(c, (struct sockaddr *)&a, sizeof a) < 0 || write(c, q, k) < 0) return 1;
        while ((n = read(c, out + got, sizeof out - 1 - got)) > 0) got += n;
        out[got] = '\0'; close(c); printf("%2d > %.40s\n   < ", i + 1, T[i]);
        for (char *p = out; *p; p++) { if (*p != '\r') putchar(*p == '\n' ? '|' : *p); } putchar('\n'); }
    wait(NULL); return 0; }

আউটপুট:

 1 > GET / HTTP/1.1|Host: x||
   < HTTP/1.1 200 OK|Content-Type: text/plain|Content-Length: 21|Connection: close||Welcome to mini-httpd
 2 > HEAD / HTTP/1.1|Host: x||
   < HTTP/1.1 200 OK|Content-Type: text/plain|Content-Length: 21|Connection: close||
 3 > GET /fees HTTP/1.1|Host: x||
   < HTTP/1.1 404 Not Found|Content-Type: text/plain|Content-Length: 13|Connection: close||404 Not Found
 4 > DELETE / HTTP/1.1|Host: x||
   < HTTP/1.1 405 Method Not Allowed|Content-Type: text/plain|Content-Length: 22|Allow: GET, HEAD|Connection: close||405 Method Not Allowed
 5 > BREW / HTTP/1.1|Host: x||
   < HTTP/1.1 501 Not Implemented|Content-Type: text/plain|Content-Length: 19|Connection: close||501 Not Implemented
 6 > GET / HTTP/1.1||
   < HTTP/1.1 400 Bad Request|Content-Type: text/plain|Content-Length: 15|Connection: close||400 Bad Request
 7 > GET / HTTP/1.0||
   < HTTP/1.1 200 OK|Content-Type: text/plain|Content-Length: 21|Connection: close||Welcome to mini-httpd
 8 > GET / HTTP/1.1|Host: a|Host: b||
   < HTTP/1.1 400 Bad Request|Content-Type: text/plain|Content-Length: 15|Connection: close||400 Bad Request
 9 > GET fees HTTP/1.1|Host: x||
   < HTTP/1.1 400 Bad Request|Content-Type: text/plain|Content-Length: 15|Connection: close||400 Bad Request
10 > GET / HTTP/1.1|X-Big: aaaaaaaaaaaaaaaaaa
   < HTTP/1.1 431 Request Header Fields Too Large|Content-Type: text/plain|Content-Length: 35|Connection: close||431 Request Header Fields Too Large

সারি ধরে মিলিয়ে নিন। (২) HEAD-এর উত্তরে Content-Length: 21 আছে, কিন্তু ||-এর পরে কিছু নেই — body শূন্য, অথচ GET করলে কত বাইট আসত তা জানানো হলো। (৪) DELETE চেনা method, তাই 501 নয়, 405 আর সাথে Allow: GET, HEAD. (৫) BREW কেউ চেনে না — 501. (৬) HTTP/1.1-এ Host নেই — 400, অথচ (৭) একই request HTTP/1.0-এ 200, কারণ ১.০-তে Host বাধ্যতামূলক ছিল না। (৮) দুটি Host — 400; সামনের proxy একটি আর পেছনের server অন্যটি ধরলে বিপদ, তাই RFC 9112 এটা নিষেধ করে। (৯) target / দিয়ে শুরু নয় — 400. (১০) ফাঁকা লাইন আসার আগেই ১০২৩ বাইটের buffer ভরে গেছে — 431, আর server বাকি বাইট না পড়ে lingering close করেছে। প্রতিটি error পেজের লেখা নির্দিষ্ট — server কখনো request-এর কোনো অংশ পেজে ফিরিয়ে বসায় না, তাই এখানে XSS বা header injection-এর সুযোগ নেই।

এই প্রোগ্রাম HTTP/2.0-এর মতো অন্য version পেলে 400 দেয়। আরও নিখুঁত উত্তর হলো 505 HTTP Version Not Supported — শব্দটি “HTTP/” দিয়ে শুরু হলে কিন্তু 1.0/1.1 না হলে 505, আর একেবারে অচেনা হলে 400. পূর্ণ কোডে এটি আছে; নিজে যোগ করে দেখা একটি ভালো ছোট অনুশীলন।

৪ · মাইলস্টোন ৩ — নিরাপদে ফাইল পরিবেশন: path, MIME, streaming আর ETag

এবার আসল কাজ: URL-এর path থেকে ডিস্কের একটি ফাইল। সবচেয়ে সরল চিন্তা — document root আর path জোড়া দিন: root /www আর path /notes.txt → /www/notes.txt. কিন্তু path যদি হয় /../etc/passwd? জোড়া দিলে /www/../etc/passwd, আর operating system .. দেখে এক ধাপ ওপরে উঠে খুলবে /etc/passwd. একে বলে path traversal বা directory traversal — ওয়েব server-এর ইতিহাসে সবচেয়ে বেশিবার ফিরে আসা দুর্বলতাগুলোর একটি।

Attacker সরাসরি .. না লিখে percent-encoding-এ লুকাতে পারে: %2e মানে ., %2f মানে /. তাই নিয়মগুলো এই ক্রমে:

  1. ঠিক একবার decode করুন — query (?-এর পরের অংশ) বাদ দিয়ে। দুবার decode করলে %252e প্রথমবার হয় %2e, দ্বিতীয়বার . — যাচাই যদি প্রথম দফার পরে হয়, দ্বিতীয় দফা নতুন .. বানিয়ে ফেলে।
  2. Decode-এর পরে যাচাই করুন — আগে নয়। Encode করা অবস্থায় %2e%2e-তে কোনো .. দেখা যায় না।
  3. বাতিল করুন: %00 (NUL — C-র string এখানেই শেষ ধরে নেবে), অন্য নিয়ন্ত্রণ-অক্ষর (বিশেষত CR আর LF), আর যেকোনো .. segment — অর্থাৎ /..-এর পরে / বা string-এর শেষ। /..hidden নামের ফোল্ডার কিন্তু বৈধ।
  4. শেষ পাহারা: পূর্ণ কোডে realpath() দিয়ে আসল path বের করে নিশ্চিত হওয়া যে সেটি document root-এর ভেতরে — এতে root-এর ভেতরের কোনো symbolic link বাইরের ফাইলের দিকে ইশারা করলেও ধরা পড়ে।

নিচের ডায়াগ্রামে document root /www ধরে আটটি target একই পরীক্ষার ভেতর দিয়ে পাঠানো হলো। “পরে” চাপুন:

safe_path() · document root = /www
Target (তারে যা এল)
একবার decode-এর পর
পরীক্ষা
ফল
  1. সাধারণ request. /notes.txt-এ কোনো % নেই, তাই decode-এর পরেও একই। /.. নেই — ফল 0, অর্থাৎ নিরাপদ; server খুলবে /www/notes.txt.
  2. সরাসরি আক্রমণ। /../etc/passwd-এ /..-এর পরের অক্ষর / — এটি একটি পূর্ণ .. segment. না আটকালে /www/../etc/passwd = /etc/passwd. ফল 403.
  3. Encode করে লুকানো। %2e%2e চোখে .. নয়, কিন্তু একবার decode করলেই /../etc/passwd. আমরা decode-এর পরে পরীক্ষা করি, তাই ধরা পড়ল — 403.
  4. বড়-ছোট hex আর encode করা slash. %2E আর %2e একই বাইট, %2F হলো /. শুধু ছোট হাতের %2e খোঁজার মতো “কালো তালিকা” এখানে ফসকে যেত; decode করে তারপর পরীক্ষা করায় কোনো রূপই বাদ পড়ে না — 403.
  5. Encode করা slash দিয়ে কয়েক ধাপ ওপরে। /..%2f..%2fetc/passwd decode হয়ে /../../etc/passwd. প্রথম /..-তেই ফল 403.
  6. দুবার encode. %25 মানে % চিহ্ন, তাই একবার decode করলে পাওয়া যায় আক্ষরিক %2e%2e — এটি একটি সাধারণ (এবং অস্তিত্বহীন) ফোল্ডারের নাম, .. নয়। ফল 0, তারপর ফাইল না পেয়ে 404. কেউ ভুল করে দ্বিতীয়বার decode করলে এখানেই .. জন্ম নিত।
  7. মিথ্যা সতর্কসংকেত এড়ানো। /..hidden/a.txt-এ /.. আছে, কিন্তু পরের অক্ষর h — এটি ..hidden নামের বৈধ ফোল্ডার। Segment দেখে বিচার করায় বৈধ নাম আটকে যায় না।
  8. NUL বাইট। %00 decode হলে C-র string সেখানেই শেষ: notes\0.txt হয়ে যেতে পারত শুধু notes, আর “.txt দিয়ে শেষ হলে নিরাপদ” ধরনের যাচাই বোকা বনত। তাই decode-এর সময়ই 400.

MIME type, streaming আর ETag

Content-Type: browser body দেখে নয়, header দেখে ঠিক করে কী করবে। তাই extension থেকে একটি ছোট MIME type সারণি: .html → text/html; charset=utf-8, .txt → text/plain; charset=utf-8, .png → image/png; অচেনা হলে নিরাপদ ডিফল্ট application/octet-stream (browser ডাউনলোড করবে, চালাবে না)। বাংলা লেখার জন্য charset=utf-8 জরুরি।

Streaming: ফাইল পুরোটা memory-তে না তুলে একটি ছোট buffer-এ read() → write() লুপ। ৪ GB-র ভিডিও হলেও server-এর memory ৪ KB-ই লাগে। Content-Length আসে fstat()-এর st_size থেকে। ফাইল পড়া-লেখার মূল কথা দেখতে পারেন C কোর্সের file I/O পর্বে।

ETag আর 304: ফাইলের আকার আর শেষ পরিবর্তনের সময় (mtime) hex-এ জুড়ে একটি ETag, যেমন "f-6aded040" — nginx-ও প্রায় এমনভাবেই (mtime আর আকার দিয়ে) ETag বানায়। Client পরের বার If-None-Match-এ সেটি পাঠালে আর মিলে গেলে body ছাড়াই 304 Not Modified. এই conditional request-এর পুরো ছবি — Cache-Control, Last-Modified, proxy আর CDN — পর্ব ৩৮-এ দেখেছেন; এখানে শুধু server-এর দিকটা বসাচ্ছি।

এই মাইলস্টোনে পরীক্ষার কৌশলও একটু আলাদা। আমরা নেটওয়ার্ক নয়, handler পরীক্ষা করছি — আর handler শুধু একটি file descriptor চেনে, সেটি TCP না অন্য কিছু তা জানে না। তাই প্রতিটি পরীক্ষায় socketpair() দিয়ে একজোড়া জোড়া-লাগানো socket বানানো হয়: এক মাথায় server-এর কোড উত্তর লেখে, অন্য মাথা থেকে পরীক্ষক পড়ে। প্রোগ্রাম নিজেই mkdtemp() দিয়ে /tmp-এ একটি অস্থায়ী ফোল্ডার বানায়: ভেতরে www/index.html, www/notes.txt, আর root-এর বাইরে secret.txt — যাতে দেখা যায় সেটি সত্যিই আটকানো হচ্ছে। ফাইলের mtime একটি নির্দিষ্ট সময়ে বসানো (২৬ অক্টোবর ২০২৬, ০৪:০০ UTC), যাতে ETag প্রতিবার একই আসে। শেষে ফোল্ডারটি মুছে ফেলা হয়।

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/socket.h>
static char base[] = "/tmp/cn45-XXXXXX", root[64];          /* doc root = base/www; base/secret.txt is OUTSIDE it */
static int hex(int c) { const char *d = "0123456789abcdef0123456789ABCDEF", *p = c ? strchr(d, c) : NULL; return p ? (int)(p - d) % 16 : -1; }
static int safe_path(const char *t, char *out) { size_t n = 0;     /* decode %XX exactly ONCE, then check */
    for (int c, h, l; *t && *t != '?'; t++, out[n++] = (char)c)          /* the ?query is not part of the path */
        if ((c = (unsigned char)*t) == '%') { if ((h = hex(t[1])) < 0 || (l = hex(t[2])) < 0 || (c = h * 16 + l) == 0) return 400; t += 2; }
    out[n] = '\0'; for (const char *p = out; (p = strstr(p, "/..")); p++) if (p[3] == '/' || p[3] == '\0') return 403;  /* ".." */
    return *out == '/' ? 0 : 400; }
static int serve(int s, const char *path, const char *inm) { char full[512], buf[4096], et[48]; struct stat st; ssize_t k; int fd;
    snprintf(full, sizeof full, "%s%s%s", root, path, path[strlen(path) - 1] == '/' ? "index.html" : "");
    if ((fd = open(full, O_RDONLY | O_NOFOLLOW)) < 0 || fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { if (fd >= 0) close(fd); return 404; }
    snprintf(et, sizeof et, "\"%llx-%llx\"", (unsigned long long)st.st_size, (unsigned long long)st.st_mtime);   /* size-mtime */
    if (inm && strcmp(inm, et) == 0) { dprintf(s, "HTTP/1.1 304 Not Modified\r\nETag: %s\r\n\r\n", et); close(fd); return 0; }
    const char *ext = strrchr(full, '.'), *ct = !ext ? "application/octet-stream" : !strcmp(ext, ".html") ? "text/html; charset=utf-8"
                    : !strcmp(ext, ".txt") ? "text/plain; charset=utf-8" : !strcmp(ext, ".png") ? "image/png" : "application/octet-stream";
    dprintf(s, "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %lld\r\nETag: %s\r\n\r\n", ct, (long long)st.st_size, et);
    while ((k = read(fd, buf, sizeof buf)) > 0) { if (write(s, buf, k) != k) break; } close(fd); return 0; }   /* stream in pieces */
int main(void) { char p[512], b[2048]; int sv[2], got, code; ssize_t n;
    const char *name[] = {"secret.txt", "www/index.html", "www/notes.txt"}, *text[] = {"TOP SECRET\n", "<h1>Salam</h1>\n", "Bus 6 at 7:30\n"};
    const char *T[] = {"/", "/notes.txt", "/../secret.txt", "/%2e%2e/secret.txt", "/..%2Fsecret.txt", "/%252e%252e/secret.txt", "/notes%00.txt", "/"};
    if (!mkdtemp(base) || snprintf(root, sizeof root, "%s/www", base) < 0 || mkdir(root, 0700) < 0) return 1;
    for (int i = 0, fd; i < 3; i++) { snprintf(p, sizeof p, "%s/%s", base, name[i]); if ((fd = open(p, O_WRONLY | O_CREAT, 0600)) < 0) return 1;
        dprintf(fd, "%s", text[i]); futimens(fd, (struct timespec[2]){{1792987200, 0}, {1792987200, 0}}); close(fd); }  /* fixed mtime */
    for (int i = 0; i < 8 && socketpair(AF_UNIX, SOCK_STREAM, 0, sv) == 0; i++) {   /* test harness: the server code only sees an fd */
        if (!(code = safe_path(T[i], p))) code = serve(sv[1], p, i == 7 ? "\"f-6aded040\"" : NULL);   /* last one: If-None-Match */
        if (code) dprintf(sv[1], "HTTP/1.1 %d %s\r\nContent-Length: 0\r\n\r\n", code, code == 403 ? "Forbidden" : code == 404 ? "Not Found" : "Bad Request");
        close(sv[1]); for (got = 0; (n = read(sv[0], b + got, sizeof b - 1 - got)) > 0; got += n) {} b[got] = '\0'; close(sv[0]);
        printf("%-24s < ", T[i]); for (char *q = b; *q; q++) { if (*q != '\r') putchar(*q == '\n' ? '|' : *q); } putchar('\n'); }
    for (int i = 0; i < 3; i++) { snprintf(p, sizeof p, "%s/%s", base, name[i]); unlink(p); } rmdir(root); rmdir(base); return 0; }

আউটপুট (বাম পাশে target, ডান পাশে উত্তর, | = \r\n):

/                        < HTTP/1.1 200 OK|Content-Type: text/html; charset=utf-8|Content-Length: 15|ETag: "f-6aded040"||<h1>Salam</h1>|
/notes.txt               < HTTP/1.1 200 OK|Content-Type: text/plain; charset=utf-8|Content-Length: 14|ETag: "e-6aded040"||Bus 6 at 7:30|
/../secret.txt           < HTTP/1.1 403 Forbidden|Content-Length: 0||
/%2e%2e/secret.txt       < HTTP/1.1 403 Forbidden|Content-Length: 0||
/..%2Fsecret.txt         < HTTP/1.1 403 Forbidden|Content-Length: 0||
/%252e%252e/secret.txt   < HTTP/1.1 404 Not Found|Content-Length: 0||
/notes%00.txt            < HTTP/1.1 400 Bad Request|Content-Length: 0||
/                        < HTTP/1.1 304 Not Modified|ETag: "f-6aded040"||

/ চাওয়ায় server index.html দিয়েছে, MIME text/html; charset=utf-8, Content-Length ১৫ (<h1>Salam</h1> আর \n), ETag "f-6aded040": f = ১৫ বাইট, 6aded040 = ১৭৯২৯৮৭২০০ সেকেন্ড — ২৬ অক্টোবর ২০২৬, ০৪:০০ UTC. notes.txt-এর আকার ১৪ = hex e. তিনটি traversal চেষ্টাই 403, আর secret.txt-এর লেখা কোথাও আসেনি। দুবার encode করা %252e%252e হলো 404 — একবার decode করায় সেটি নিরীহ একটি নাম। %00 — 400. শেষ লাইনে একই /, কিন্তু client If-None-Match: "f-6aded040" পাঠিয়েছে: মিলে গেছে, তাই 304 Not Modified, body নেই — ১৫ বাইটের ফাইলে লাভ সামান্য, কিন্তু ৫ MB-র ছবিতে পুরো ৫ MB বাঁচে। লক্ষ করুন, ফাইল খোলায় O_NOFOLLOW ব্যবহার হয়েছে: শেষ অংশটি symbolic link হলে open() ব্যর্থ হয়। এটি মাঝের ফোল্ডারের link আটকায় না — সেজন্য পূর্ণ কোডে realpath().

একটি প্রচলিত ভুল “সমাধান”: path থেকে ../ খুঁজে মুছে ফেলা। ....// থেকে ../ মুছলে আবার ../ তৈরি হয়! মুছে ঠিক করার চেষ্টা নয় — সন্দেহজনক হলে পুরো request বাতিল করুন। আর অনুমতির সীমা হিসেবে ফাইলের নাম-পরীক্ষাকে একমাত্র ভরসা বানাবেন না: server-কে এমন ব্যবহারকারী হিসেবে চালান যার কাছে document root ছাড়া আর কিছু পড়ার অনুমতিই নেই (least privilege; বিস্তারিত OS কোর্সের sandboxing পর্বে)।

৫ · মাইলস্টোন ৪ — keep-alive, timeout আর একসাথে অনেক client

HTTP/1.1-এ সংযোগ ডিফল্টে persistent: একটি request-এর উত্তরের পর server সংযোগ খোলা রাখে, পরের request-এর অপেক্ষায় (পর্ব ৩৬-এ দেখেছেন এতে প্রতি বস্তুতে এক RTT বাঁচে)। তাই handler আর “একটি request” নয়, একটি লুপ:

  • কখন থামবে: client Connection: close পাঠালে (উত্তরেও সেটা জানিয়ে), HTTP/1.0 হলে (যদি না keep-alive চায়), protocol-এর error হলে (stream-এর তাল হারিয়েছে), বা নির্দিষ্ট সময় কোনো request না এলে।
  • বাড়তি বাইট: এক read()-এ একটি request-এর শেষ আর পরেরটার শুরু একসাথে আসতে পারে (pipelining). তাই \r\n\r\n-এর পরের বাইটগুলো ফেলে না দিয়ে memmove করে buffer-এর শুরুতে আনা।
  • অলস সংযোগ: প্রতিটি খোলা সংযোগ একটি process (বা thread) আর একটি file descriptor দখল করে রাখে। SO_RCVTIMEO দিয়ে read()-এর অপেক্ষার সীমা বসানো হয়; সময় পেরোলে read() −1 ফেরত দেয় (errno EAGAIN), আর server সংযোগ বন্ধ করে — এটাই idle timeout.
Browser ⇄ mini_httpd · idle timeout = ৫ সেকেন্ড
সময়
Client
Server worker
সংযোগ
  1. সংযোগ। Handshake শেষে accept() নতুন socket দেয়; server fork() করে একটি worker বানায়, যে শুধু এই সংযোগ দেখবে। Worker প্রথমেই SO_RCVTIMEO = ৫ সেকেন্ড বসায়।
  2. প্রথম request. Connection: close নেই, version HTTP/1.1 — তাই উত্তরের পর সংযোগ খোলা থাকবে। উত্তরে ১৫ বাইটের body, আর log-এ একটি লাইন।
  3. অপেক্ষা শুরু। Buffer-এ পরের request-এর কোনো বাইট আগেই এসে নেই (বাকি ০), তাই worker আবার read()-এ বসে। এখন থেকে ৫ সেকেন্ডের ঘড়ি চলছে।
  4. দ্বিতীয় request. ১.৩ সেকেন্ড পরে browser একটি ছবি বা ফাইল চাইল — একই TCP সংযোগে, তাই handshake-এর একটি RTT বাঁচল (পর্ব ২৯)। ঘড়ি থামল, উত্তর গেল।
  5. আবার অপেক্ষা। প্রতিটি সফল read()-এর পর ঘড়ি নতুন করে শুরু হয় — SO_RCVTIMEO প্রতিটি read()-এর আলাদা সীমা।
  6. Timeout. ৬.৩০১ সেকেন্ডে, অর্থাৎ শেষ request-এর ঠিক ৫ সেকেন্ড পরে, read() হাল ছাড়ে। Worker সংযোগ বন্ধ করে — client-এর কাছে FIN যায়। খোলা রাখার খরচ (memory, process, fd) আর আবার খোলার খরচ (handshake) — এই দুইয়ের মাঝামাঝি একটি সংখ্যা বাছাই করাই timeout-এর নকশা।
  7. পরিষ্কার। Client-এর পরের read() শূন্য ফেরত দেয়। Browser এটা স্বাভাবিক ধরে; পরের request-এর জন্য নতুন সংযোগ খুলবে। Worker _exit() করে, আর SIGCHLD উপেক্ষা করায় kernel নিজেই তাকে মুছে দেয় — কোনো zombie থাকে না।

একসাথে অনেক client — কোন মডেল?

একটি client অলস বসে থাকলে বাকিরা যেন আটকে না যায়, সেটাই concurrency-র প্রশ্ন। পর্ব ৪৩ আর ৪৪-এ চারটি মডেল দেখেছেন; এই প্রজেক্টে বেছেছি সবচেয়ে সরলটি:

মডেলসুবিধাঅসুবিধা
fork প্রতি সংযোগে (আমাদের পছন্দ)সবচেয়ে সরল; একটি worker crash করলে বাকিরা নিরাপদ; ভাগ-করা memory-র বাগ নেইপ্রতিটি সংযোগে একটি process — হাজার হাজার অলস সংযোগে memory আর context switch-এর খরচ
Thread প্রতি সংযোগেProcess-এর চেয়ে হালকাএক thread-এর memory বাগে পুরো server পড়ে; lock লাগে
Thread poolসর্বোচ্চ thread সংখ্যা বাঁধা — চাপে ভেঙে পড়ে নাসব thread অলস keep-alive-এ আটকে থাকলে নতুনরা লাইনে দাঁড়ায়
Event loop (epoll)এক thread-এ হাজারো সংযোগ; অলস সংযোগ প্রায় বিনা খরচেকোড কঠিন — প্রতিটি সংযোগের অবস্থা হাতে রাখতে হয় (state machine)

nginx-এর মতো server শেষ মডেলটি (কয়েকটি worker process, প্রতিটিতে একটি event loop) ব্যবহার করে। আমাদের fork-মডেল শেখার জন্য আদর্শ, কারণ প্রতিটি সংযোগের কোড সরল একটি লুপ। নিচের প্রোগ্রামে দুটি client একসাথে সংযোগ করে: [A] দুটি request করে চুপ বসে থাকে, [B] তখন একটি request করে Connection: close দিয়ে। Server প্রতিটি request-এর জন্য Common Log Format-এ একটি log লাইন ছাপে। সব process-এর ছাপা ঠিক ক্রমে আসার জন্য stdout-কে line-buffered করা হয়েছে, আর server উত্তর পাঠানোর আগে log ছাপে।

#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <arpa/inet.h>
static void conn(int s, const char *ip) { char b[2048] = "", m[16], t[256], v[16], ts[40], *e; int n = 0, k = 0; time_t now;
    setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &(struct timeval){1, 0}, sizeof(struct timeval));   /* idle limit 1 s */
    for (int keep = 1; keep; ) {                  /* request after request on ONE connection (keep-alive) */
        while (!(e = strstr(b, "\r\n\r\n")) && n < (int)sizeof b - 1 && (k = read(s, b + n, sizeof b - 1 - n)) > 0) b[n += k] = '\0';
        if (!e || sscanf(b, "%15s %255s %15s", m, t, v) != 3) { printf("[server] %s %s\n", ip, k < 0 ? "idle 1 s: closing" : "closed, too big or malformed"); break; }
        keep = !strstr(b, "\r\nConnection: close\r\n") && strcmp(v, "HTTP/1.0");
        const char *st = strcmp(t, "/") ? "404 Not Found" : "200 OK", *body = *st == '2' ? "Welcome to mini-httpd\n" : "404 Not Found\n";
        now = time(NULL); strftime(ts, sizeof ts, "%d/%b/%Y:%H:%M:%S %z", gmtime(&now));
        printf("%s - - [%s] \"%s %s %s\" %.3s %zu\n", ip, ts, m, t, v, st, strlen(body));         /* Common Log Format */
        dprintf(s, "HTTP/1.1 %s\r\nContent-Length: %zu\r\n%s\r\n%s", st, strlen(body), keep ? "" : "Connection: close\r\n", body);
        k = e + 4 - b; memmove(b, b + k, n - k + 1); n -= k; } close(s); }     /* keep pipelined bytes for the next round */
static void ask(int c, const char *tag, const char *req, char *r) { int n = 0, k; if (write(c, req, strlen(req)) < 0) return;
    while (!(strstr(r, "\r\n\r\n") && r[n - 1] == '\n') && (k = read(c, r + n, 1023 - n)) > 0) r[n += k] = '\0';
    printf("%s %.*s -> %.*s\n", tag, (int)strcspn(req, "\r"), req, (int)strcspn(r, "\r"), r); r[0] = '\0'; }
static int dial(struct sockaddr_in *a) { int c = socket(AF_INET, SOCK_STREAM, 0); return connect(c, (struct sockaddr *)a, sizeof *a) ? -1 : c; }
int main(void) { struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons(47454), .sin_addr = {htonl(INADDR_LOOPBACK)}};
    int ls = socket(AF_INET, SOCK_STREAM, 0); char r[1024] = "";
    setvbuf(stdout, NULL, _IOLBF, 0); setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int));    /* line-buffered */
    if (bind(ls, (struct sockaddr *)&a, sizeof a) < 0 || listen(ls, 16) < 0) { perror("bind"); return 1; }
    if (fork() == 0) { for (int i = 0; i < 2; i++) {                    /* server: fork one worker per connection */
            struct sockaddr_in p; socklen_t pl = sizeof p; char ip[16]; int s = accept(ls, (struct sockaddr *)&p, &pl);
            inet_ntop(AF_INET, &p.sin_addr, ip, sizeof ip); if (fork() == 0) { close(ls); conn(s, ip); _exit(0); } close(s); }
        while (wait(NULL) > 0) {} _exit(0); }
    int A = dial(&a), B = dial(&a); if (A < 0 || B < 0) return 1;                  /* two clients at once */
    ask(A, "[A]", "GET / HTTP/1.1\r\nHost: x\r\n\r\n", r); ask(A, "[A]", "GET /about HTTP/1.1\r\nHost: x\r\n\r\n", r);
    ask(B, "[B]", "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", r); printf("[B] read() = %zd: server closed B\n", read(B, r, 1));
    printf("[A] idle, waiting...\n"); printf("[A] read() = %zd: server closed the idle connection\n", read(A, r, 1));
    close(A); close(B); wait(NULL); return 0; }

আউটপুট (log-এর সময় আপনার চালানোয় আলাদা হবে):

127.0.0.1 - - [26/Oct/2026:17:03:03 +0000] "GET / HTTP/1.1" 200 22
[A] GET / HTTP/1.1 -> HTTP/1.1 200 OK
127.0.0.1 - - [26/Oct/2026:17:03:03 +0000] "GET /about HTTP/1.1" 404 14
[A] GET /about HTTP/1.1 -> HTTP/1.1 404 Not Found
127.0.0.1 - - [26/Oct/2026:17:03:03 +0000] "GET / HTTP/1.1" 200 22
[B] GET / HTTP/1.1 -> HTTP/1.1 200 OK
[B] read() = 0: server closed B
[A] idle, waiting...
[server] 127.0.0.1 idle 1 s: closing
[A] read() = 0: server closed the idle connection

প্রথম চার লাইন: [A]-এর দুটি request একই সংযোগে — একটি 200 (২২ বাইট), একটি 404 (১৪ বাইট); প্রতিটির আগে server-এর log লাইন। পঞ্চম-ষষ্ঠ লাইনে [B]: A-র সংযোগ তখনো খোলা আর তার worker read()-এ আটকে আছে, তবু B সঙ্গে সঙ্গে উত্তর পেল — কারণ B-র জন্য আলাদা worker process. এটাই concurrency. B Connection: close চেয়েছিল, তাই উত্তরের পর server বন্ধ করল: read() = 0. শেষে A চুপ; প্রায় এক সেকেন্ড পরে server-এর worker লেখে idle 1 s: closing, আর A-র read() শূন্য পায়। Log লাইনের গঠন: client-এর IP, - - (ident আর ব্যবহারকারী — অজানা), সময়, উদ্ধৃতিচিহ্নে request line, status, body-র বাইট। এখানে সময় GMT-তে (+0000); পূর্ণ কোড স্থানীয় সময় দেয়, বাংলাদেশে +0600.

Slowloris — শুধু idle timeout যথেষ্ট নয়: ২০০৯ সালে প্রকাশিত Slowloris নামের একটি আক্রমণ-কৌশল দেখায়, একজন attacker শত শত সংযোগ খুলে প্রতিটিতে খুব ধীরে, কয়েক সেকেন্ড পরপর একটি করে header লাইন পাঠালে প্রতি-সংযোগে-একটি-worker ধরনের server-এর সব worker আটকে যায়। প্রতিটি read() সময়ের আগেই কিছু পাচ্ছে, তাই SO_RCVTIMEO কখনো বাজে না! প্রতিরক্ষা: পুরো head আসার একটি মোট সময়সীমা (পূর্ণ কোডে HEAD_SEC, ভাঙলে 408), প্রতি IP-তে সংযোগের সীমা, আর event-driven server বা সামনে একটি reverse proxy. এটি Slowloris শ্রেণির ধীর আক্রমণ; বিস্তারিত পর্ব ৫০-এ।

৬ · সব জোড়া লাগানো — সম্পূর্ণ mini_httpd.c

চারটি মাইলস্টোন জোড়া দিয়ে, প্রতিটি জায়গা আরও কড়া করে, পূর্ণ প্রোগ্রাম দাঁড়ায় প্রায় ২৬৫ লাইনে। তার আগে দেখে নিন একটি সাধারণ request — curl http://localhost:8080/notes.txt — server-এর কোন কোন ফাংশনের ভেতর দিয়ে যায়। ধরা যাক document root /srv/www, আর নেটওয়ার্কে request দুই টুকরোয় এসেছে:

GET /notes.txt · মোট ৮৬ বাইট (২৫ + ২২ + ২৪ + ১৩ + ২)
ফাংশন
হাতে যা আছে
সিদ্ধান্ত
  1. accept. Kernel handshake শেষ করে সংযোগটি listen queue-তে রেখেছিল; accept() তাকে fd 4 হিসেবে দিল। Server fork করে — child এই সংযোগ দেখবে, parent সঙ্গে সঙ্গে নিজের কপি close() করে পরের client-এর জন্য ফিরে যায়।
  2. প্রথম টুকরো। ৪০ বাইট এসেছে — request line আর Host-এর অর্ধেক। ফাঁকা লাইন নেই, সীমাও পেরোয়নি, তাই আবার read().
  3. দ্বিতীয় টুকরো। এবার মোট ৮৬ বাইট: request line ২৫, Host ২২, User-Agent ২৪, Accept ১৩, ফাঁকা লাইন ২ (প্রতিটি \r\n-সহ)। \r\n\r\n শুরু ৮২-তে, তাই এই request ব্যবহার করল ৮২ + ৪ = ৮৬ বাইট।
  4. parse. Request line-এ ঠিক তিনটি অংশ, version 1.1, একটি Host, কোনো body নেই (Content-Length বা Transfer-Encoding নেই), Connection: close-ও নেই। ফল 0.
  5. Method. GET অনুমোদিত। অন্য কোনো চেনা method হলে এখানেই 405 আর Allow; অচেনা হলে 501.
  6. safe_path. কোনো % নেই, তাই decode-এর পর একই; নিয়ন্ত্রণ-অক্ষর বা .. segment নেই।
  7. serve_file. realpath() নিশ্চিত করে আসল path /srv/www/-এর ভেতরে। fstat() বলে এটি সাধারণ ফাইল, ১৪ বাইট। ETag-এ e = ১৪। Header একটি writen()-এ, তারপর ফাইলটি ১৬ KB-র buffer দিয়ে stream — এখানে একবারেই শেষ।
  8. log. Common Log Format-এ একটি লাইন — একটিমাত্র write()-এ, যাতে অনেক worker একসাথে লিখলেও লাইনগুলো মিশে না যায়। বাংলাদেশের ঘড়িতে সময়ের অংশ +0600.
  9. keep-alive. close = 0, তাই সংযোগ খোলা। এই request ঠিক ৮৬ বাইট ব্যবহার করেছে, বাকি শূন্য; worker আবার read_head()-এ ফেরে — পরের request এলে আবার এই যাত্রা, না এলে ৫ সেকেন্ড পরে বন্ধ।

নিচে পূর্ণ কোড পাঁচ ভাগে। এগুলো এই পাতায় চালানোর জন্য নয় (server-টি চিরকাল চলতে থাকে) — নিজের Linux বা WSL-এ চালাবেন। পাঁচটি ট্যাবের কোড ক্রমানুসারে একটির পর একটি একটি ফাইলে বসান, নাম দিন mini_httpd.c. এই পূর্ণ প্রোগ্রামটি একটি পরীক্ষামূলক client দিয়ে GET, HEAD, symbolic link, traversal, 304, 405, pipelining, CRLF injection, Content-Length overflow, Host :, HTTP/1.0, নিয়ন্ত্রণ-অক্ষর, folded header, লম্বা target, বড় head আর ধীর (Slowloris-ধাঁচের) client-সহ ৩০টি request-এ, AddressSanitizer চালু রেখে, যাচাই করা হয়েছে।

Include, সীমাগুলো, writen() (পর্ব ৪১) আর read_head() — এবার memmem দিয়ে, যাতে head-এর ভেতরের NUL বাইটেও খোঁজা ঠিক থাকে, আর মোট সময়সীমাসহ।

/* mini_httpd.c -- a small, careful static-file HTTP/1.1 server (a learning project, not for production)
 * build: gcc -Wall -Wextra -O2 -o mini_httpd mini_httpd.c
 * run:   ./mini_httpd 8080 ./www      then open http://127.0.0.1:8080/  */
#define _GNU_SOURCE                       /* memmem, strcasestr */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <signal.h>
#include <time.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <arpa/inet.h>

#define MAX_HEAD 8192                     /* request line + all headers */
#define IDLE_SEC 5                        /* keep-alive: close after this much silence */
#define HEAD_SEC 10                       /* a whole request head must arrive within this (Slowloris) */
#define MAX_REQS 100                      /* requests served on one connection */

struct req { char method[16], target[2048], version[16], inm[128]; int hosts, close, body; };
static char root[PATH_MAX];               /* document root, already passed through realpath() */
static size_t rootlen;

static int writen(int s, const void *p, size_t n) {            /* write() may send less: loop (lesson 41) */
    for (const char *c = p; n > 0; ) {
        ssize_t k = write(s, c, n);
        if (k < 0 && errno == EINTR) continue;
        if (k <= 0) return -1;
        c += k; n -= (size_t)k;
    }
    return 0;
}

/* 1 = head complete (*end points at "\r\n\r\n"), 0 = closed or idle, -1 = too big, -2 = too slow */
static int read_head(int s, char *b, size_t *n, char **end) {
    time_t start = time(NULL);
    while (!(*end = memmem(b, *n, "\r\n\r\n", 4))) {
        if (*n >= MAX_HEAD) return -1;
        if (time(NULL) - start > HEAD_SEC) return -2;
        ssize_t k = read(s, b + *n, MAX_HEAD - *n);
        if (k < 0 && errno == EINTR) continue;
        if (k < 0 && *n > 0) return -2;                        /* went silent half-way through a request */
        if (k <= 0) return 0;                                  /* peer closed, or idle timeout */
        *n += (size_t)k;
    }
    return 1;
}

কড়া parse: NUL আর অন্য নিয়ন্ত্রণ-অক্ষর, একা CR বা LF, ভাঁজ-করা (folded) header লাইন, অতিরিক্ত অংশ, লম্বা target (414), অচেনা version (505), Name : value (400), ঠিক একটি Host, আর overflow-নিরাপদ Content-Length.

static int content_length(const char *v, unsigned long long *out) {   /* digits only, and no overflow */
    unsigned long long x = 0;
    if (*v == '\0') return -1;
    for (; *v >= '0' && *v <= '9'; v++) {
        if (x > (ULLONG_MAX - (unsigned)(*v - '0')) / 10) return -1;
        x = x * 10 + (unsigned)(*v - '0');
    }
    if (*v) return -1;
    *out = x;
    return 0;
}

static int parse(char *b, size_t len, struct req *r) {         /* 0 = fine, else an error status */
    char extra[2], *p, *nl, *colon, *v, *e;
    unsigned long long cl;
    if (strlen(b) != len) return 400;                          /* a NUL byte hidden inside the head */
    for (p = b; *p; p++)                                       /* other controls, a bare CR or a bare LF */
        if ((*p == '\r' && p[1] != '\n') || (*p == '\n' && (p == b || p[-1] != '\r'))
            || ((unsigned char)*p < 0x20 && *p != '\r' && *p != '\n' && *p != '\t') || *p == 0x7f) return 400;
    if ((nl = strstr(b, "\r\n"))) *nl = '\0';
    if ((v = strchr(b, ' ')) && strcspn(v + 1, " \t") >= sizeof r->target) return 414;   /* target too long */
    if (sscanf(b, "%15s %2047s %15s %1s", r->method, r->target, r->version, extra) != 3) return 400;
    if (strcmp(r->version, "HTTP/1.1") && strcmp(r->version, "HTTP/1.0"))
        return strncmp(r->version, "HTTP/", 5) ? 400 : 505;
    if (r->target[0] != '/') return 400;
    r->close = strcmp(r->version, "HTTP/1.0") == 0;            /* 1.0 closes unless it asks for keep-alive */
    for (p = nl ? nl + 2 : b + len; *p; p = nl ? nl + 2 : p + strlen(p)) {
        if ((nl = strstr(p, "\r\n"))) *nl = '\0';
        colon = strchr(p, ':');
        if (*p == ' ' || *p == '\t' || !colon || colon == p || colon[-1] == ' ' || colon[-1] == '\t')
            return 400;                                        /* folded line, or "Name : v": both invalid */
        *colon = '\0';
        v = colon + 1 + strspn(colon + 1, " \t");
        for (e = v + strlen(v); e > v && (e[-1] == ' ' || e[-1] == '\t'); ) *--e = '\0';
        if (!strcasecmp(p, "Host")) r->hosts++;
        else if (!strcasecmp(p, "Connection")) r->close = strcasestr(v, "close") ? 1 : strcasestr(v, "keep-alive") ? 0 : r->close;
        else if (!strcasecmp(p, "If-None-Match")) snprintf(r->inm, sizeof r->inm, "%s", v);
        else if (!strcasecmp(p, "Transfer-Encoding")) r->body = 1;
        else if (!strcasecmp(p, "Content-Length")) { if (content_length(v, &cl)) return 400; if (cl > 0) r->body = 1; }
    }
    if (r->hosts > 1 || (r->hosts == 0 && !strcmp(r->version, "HTTP/1.1"))) return 400;   /* exactly one Host */
    return 0;
}

safe_path() এখন সব নিয়ন্ত্রণ-অক্ষর আর backslash বাতিল করে; সাথে reason phrase, MIME সারণি, HTTP তারিখ আর ছোট error উত্তর।

static int hex(int c) { const char *d = "0123456789abcdef0123456789ABCDEF", *p = c ? strchr(d, c) : NULL; return p ? (int)(p - d) % 16 : -1; }

static int safe_path(const char *t, char *out, size_t cap) {   /* decode %XX once, then check */
    size_t n = 0;
    for (; *t && *t != '?'; t++) {
        int c = (unsigned char)*t, h, l;
        if (c == '%') {
            if ((h = hex(t[1])) < 0 || (l = hex(t[2])) < 0) return 400;
            c = h * 16 + l; t += 2;
        }
        if (c < 0x20 || c == 0x7f || c == '\\') return 400;    /* NUL, CR, LF, other controls, backslash */
        if (n + 1 >= cap) return 414;
        out[n++] = (char)c;
    }
    out[n] = '\0';
    for (const char *p = out; (p = strstr(p, "/..")); p++)
        if (p[3] == '/' || p[3] == '\0') return 403;          /* a ".." path segment */
    return out[0] == '/' ? 0 : 400;
}

static const char *reason(int c) {
    switch (c) {
    case 200: return "OK";                 case 304: return "Not Modified";
    case 400: return "Bad Request";        case 403: return "Forbidden";
    case 404: return "Not Found";          case 405: return "Method Not Allowed";
    case 408: return "Request Timeout";    case 413: return "Content Too Large";
    case 414: return "URI Too Long";       case 431: return "Request Header Fields Too Large";
    case 501: return "Not Implemented";    case 505: return "HTTP Version Not Supported";
    default:  return "Internal Server Error";
    }
}

static const char *mime(const char *path) {
    static const char *table[][2] = {
        {".html", "text/html; charset=utf-8"}, {".css", "text/css; charset=utf-8"},
        {".js", "text/javascript; charset=utf-8"}, {".txt", "text/plain; charset=utf-8"},
        {".png", "image/png"}, {".jpg", "image/jpeg"}, {".svg", "image/svg+xml"}, {".pdf", "application/pdf"} };
    const char *dot = strrchr(path, '.'), *slash = strrchr(path, '/');
    for (size_t i = 0; dot && dot > slash && i < sizeof table / sizeof table[0]; i++)
        if (!strcasecmp(dot, table[i][0])) return table[i][1];
    return "application/octet-stream";
}

static void http_date(char *out, size_t cap, time_t t) {       /* IMF-fixdate, always GMT */
    struct tm tm;
    strftime(out, cap, "%a, %d %b %Y %H:%M:%S GMT", gmtime_r(&t, &tm));
}

/* send headers (+ body text); returns bytes of body sent, or -1 */
static long send_simple(int s, int code, const struct req *r, int head_only) {
    char h[512], date[64], body[128];
    int bl = snprintf(body, sizeof body, "%d %s\n", code, reason(code));
    http_date(date, sizeof date, time(NULL));
    int hl = snprintf(h, sizeof h, "HTTP/1.1 %d %s\r\nDate: %s\r\nContent-Type: text/plain; charset=utf-8\r\n"
                      "Content-Length: %d\r\n%s%s\r\n", code, reason(code), date, bl,
                      code == 405 ? "Allow: GET, HEAD\r\n" : "", r->close ? "Connection: close\r\n" : "");
    if (writen(s, h, (size_t)hl) < 0 || (!head_only && writen(s, body, (size_t)bl) < 0)) return -1;
    return head_only ? 0 : bl;
}

serve_file(): realpath-এ সীমানা যাচাই, ETag, Last-Modified, 304, আর ঠিক Content-Length-এর সমান বাইটের streaming (ফাইল মাঝপথে ছোট হলে সংযোগ বন্ধ); আর এক লাইনে এক write()-এর log.

/* GET/HEAD of a file. *code = 200/304 (answered) or 404/414 (caller answers). Returns body bytes, -1 = write error */
static long serve_file(int s, const char *path, const struct req *r, int head_only, int *code) {
    char full[PATH_MAX], real[PATH_MAX], h[768], date[64], lm[64], et[64], buf[16384];
    struct stat st;
    int fd, len = snprintf(full, sizeof full, "%s%s%s", root, path, path[strlen(path) - 1] == '/' ? "index.html" : "");
    *code = 404;
    if (len < 0 || (size_t)len >= sizeof full) { *code = 414; return 0; }
    if (!realpath(full, real)) return 0;                       /* realpath also follows symlinks ... */
    if (strncmp(real, root, rootlen) || (real[rootlen] != '/' && real[rootlen] != '\0')) return 0;  /* ... must stay inside */
    if ((fd = open(real, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)) < 0) return 0;   /* a FIFO must not hang us */
    if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) { close(fd); return 0; }
    snprintf(et, sizeof et, "\"%llx-%llx\"", (unsigned long long)st.st_size, (unsigned long long)st.st_mtime);
    http_date(date, sizeof date, time(NULL));
    http_date(lm, sizeof lm, st.st_mtime);
    int not_mod = r->inm[0] && (strstr(r->inm, et) || !strcmp(r->inm, "*"));
    *code = not_mod ? 304 : 200;
    int hl = not_mod
        ? snprintf(h, sizeof h, "HTTP/1.1 304 Not Modified\r\nDate: %s\r\nETag: %s\r\n%s\r\n", date, et,
                   r->close ? "Connection: close\r\n" : "")
        : snprintf(h, sizeof h, "HTTP/1.1 200 OK\r\nDate: %s\r\nContent-Type: %s\r\nContent-Length: %lld\r\n"
                   "Last-Modified: %s\r\nETag: %s\r\n%s\r\n", date, mime(real), (long long)st.st_size, lm, et,
                   r->close ? "Connection: close\r\n" : "");
    long sent = 0;
    if (writen(s, h, (size_t)hl) < 0) sent = -1;
    while (sent >= 0 && sent < st.st_size && !not_mod && !head_only) {   /* stream exactly st_size bytes */
        size_t want = (size_t)(st.st_size - sent) < sizeof buf ? (size_t)(st.st_size - sent) : sizeof buf;
        ssize_t k = read(fd, buf, want);
        if (k <= 0) { sent = -1; break; }                      /* file shrank: Content-Length would lie, so close */
        if (writen(s, buf, (size_t)k) < 0) sent = -1; else sent += k;
    }
    close(fd);
    return sent;
}

static void log_clf(const char *ip, const struct req *r, int code, long bytes) {   /* Common Log Format */
    char ts[64], bs[24] = "-", line[2400]; time_t now = time(NULL); struct tm tm;
    strftime(ts, sizeof ts, "%d/%b/%Y:%H:%M:%S %z", localtime_r(&now, &tm));
    if (bytes > 0) snprintf(bs, sizeof bs, "%ld", bytes);
    int n = snprintf(line, sizeof line, "%s - - [%s] \"%s %s %s\" %d %s\n", ip, ts, r->method[0] ? r->method : "-",
                     r->target[0] ? r->target : "-", r->version[0] ? r->version : "-", code, bs);
    if (n > 0) writen(STDOUT_FILENO, line, (size_t)n < sizeof line ? (size_t)n : sizeof line - 1);   /* one write() per line */
}

Keep-alive লুপ, কোন error-এর পর সংযোগ বন্ধ, request body-তে 413, আর fork-per-connection-এর main(). Server ডিফল্টে শুধু 127.0.0.1-এ শোনে।

static void handle_conn(int s, const char *ip) {
    static char b[MAX_HEAD + 1];
    size_t n = 0;
    struct timeval tv = {IDLE_SEC, 0};
    setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);    /* idle and Slowloris limit */
    setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv);    /* a client that never reads */
    for (int served = 1; served <= MAX_REQS; served++) {
        struct req r = {0};
        char *end, path[2048];
        int rc = read_head(s, b, &n, &end), code = 0, head = 0;
        long sent = 0;
        if (rc == 0) break;                                    /* closed or idle: nothing to answer */
        size_t used = rc == 1 ? (size_t)(end - b) + 4 : n;
        if (rc == 1) { *end = '\0'; code = parse(b, (size_t)(end - b), &r); }
        else code = rc == -1 ? 431 : 408;
        if (served == MAX_REQS) r.close = 1;
        if (!code && r.body) code = 413;                       /* a static server accepts no request bodies */
        if (!code) {
            head = !strcmp(r.method, "HEAD");
            snprintf(path, sizeof path, " %s ", r.method);      /* known method, but not allowed here? */
            if (strcmp(r.method, "GET") && !head)
                code = strstr(" POST PUT DELETE OPTIONS PATCH CONNECT TRACE ", path) ? 405 : 501;
            else if (!(code = safe_path(r.target, path, sizeof path)))
                sent = serve_file(s, path, &r, head, &code);
        }
        if (code >= 400) {
            if (code != 403 && code != 404 && code != 405) r.close = 1;   /* the byte stream may be out of sync */
            sent = send_simple(s, code, &r, head);
        }
        log_clf(ip, &r, code, sent);
        if (sent < 0 || r.close) break;
        memmove(b, b + used, n - used);                        /* keep pipelined bytes for the next request */
        n -= used;
    }
    struct timeval one = {1, 0};                               /* lingering close (as in M1): FIN first, then */
    shutdown(s, SHUT_WR);                                      /* drain for at most ~2 s, so that unread bytes */
    setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &one, sizeof one);  /* do not turn our last answer into an RST */
    for (time_t t0 = time(NULL); time(NULL) - t0 < 2 && read(s, b, MAX_HEAD) > 0; ) {}
    close(s);
}

int main(int argc, char **argv) {
    char *endp;
    long port = argc == 3 ? strtol(argv[1], &endp, 10) : 0;
    if (argc != 3 || *endp || port < 1 || port > 65535) { fprintf(stderr, "usage: %s PORT DOCROOT\n", argv[0]); return 2; }
    if (!realpath(argv[2], root)) { perror(argv[2]); return 1; }
    rootlen = strlen(root);
    signal(SIGPIPE, SIG_IGN);                                  /* a vanished client must not kill us (lesson 41) */
    signal(SIGCHLD, SIG_IGN);                                  /* children are reaped automatically: no zombies */
    struct sockaddr_in a = {.sin_family = AF_INET, .sin_port = htons((unsigned short)port), .sin_addr = {htonl(INADDR_LOOPBACK)}};
    int ls = socket(AF_INET, SOCK_STREAM, 0);
    setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &(int){1}, sizeof(int));
    if (ls < 0 || bind(ls, (struct sockaddr *)&a, sizeof a) < 0 || listen(ls, 128) < 0) { perror("listen"); return 1; }
    printf("serving %s at http://127.0.0.1:%ld/\n", root, port);
    fflush(stdout);
    for (;;) {                                                 /* fork per connection (lesson 43) */
        struct sockaddr_in peer; socklen_t pl = sizeof peer; char ip[INET_ADDRSTRLEN];
        int s = accept(ls, (struct sockaddr *)&peer, &pl);
        if (s < 0) { if (errno != EINTR) perror("accept"); continue; }
        inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof ip);
        pid_t pid = fork();
        if (pid == 0) { close(ls); handle_conn(s, ip); _exit(0); }
        if (pid < 0) perror("fork");
        close(s);
    }
}

Build আর চালানো (Linux বা Windows-এর WSL-এ; কোডটি POSIX socket আর fork() ব্যবহার করে, তাই সরাসরি Windows-এ চলবে না):

gcc -Wall -Wextra -O2 -o mini_httpd mini_httpd.c
mkdir -p www && echo '<h1>Salam</h1>' > www/index.html
./mini_httpd 8080 www
# অন্য terminal-এ:
curl -i http://127.0.0.1:8080/

Port ১০২৪-এর নিচে (যেমন 80) শুনতে Linux-এ বিশেষ অনুমতি লাগে; শেখার সময় 8080-এর মতো port ব্যবহার করুন, আর server-কে কখনো root হিসেবে চালাবেন না।

৭ · প্রতিরক্ষামূলক কোডিং — চেকলিস্ট

Server-এর প্রতিটি বাইট আসে এমন কারও কাছ থেকে, যাকে আপনি চেনেন না। তাই প্রতিটি ইনপুটকে শত্রুভাবাপন্ন ধরে নিয়ে লেখা — এটাই defensive coding. আমাদের কোডে কোন ঝুঁকি কোথায় আর কীভাবে আটকানো হয়েছে:

ঝুঁকিকী হতে পারতmini_httpd-এর প্রতিরক্ষা
Buffer overflowলম্বা method বা target array উপচে stack নষ্ট — কোড চালানো পর্যন্ত গড়াতে পারে%15s/%2047s-এর মতো প্রস্থ, সব জায়গায় snprintf, head-এর সীমা MAX_HEAD
Integer overflowContent-Length: 99999999999999999999999 বা -1 থেকে ভুল আকার, ভুল হিসাবcontent_length(): শুধু অঙ্ক, প্রতিটি ধাপে overflow পরীক্ষা; atoi নয় (সে ভুল হলেও চুপ থাকে)
NUL বাইটstrstr/strlen আগেভাগে থেমে যায়; “.txt-এ শেষ” ধরনের যাচাই বোকা বনেHead-এ strlen != len → 400; path-এ %00 → 400
Header injection (CRLF)Path-এর %0d%0a কোনো response header-এ (যেমন redirect-এর Location) বসলে attacker নিজের header বা পুরো উত্তর ঢুকিয়ে দেয়Decode-এর পর সব নিয়ন্ত্রণ-অক্ষর বাতিল; request-এর কোনো অংশ কখনো response header-এ কপি হয় না
Path traversal ও symlinkDocument root-এর বাইরের ফাইল (কনফিগ, পাসওয়ার্ড, key) ফাঁসএকবার decode, .. segment বাতিল, realpath()-এর পর root-এর ভেতরে কি না যাচাই
Slowloris ও অলস সংযোগসব worker আটকে — server নিজেই অচল (DoS)SO_RCVTIMEO, head-এর মোট সময়সীমা HEAD_SEC → 408
সম্পদের সীমাঅসীম request, অসীম সংযোগ, ধীর পাঠক clientMAX_REQS, SO_SNDTIMEO; সংযোগ-সংখ্যার সীমা অনুশীলনে যোগ করবেন
SIGPIPE ও zombieবন্ধ সংযোগে লিখলে process মরে যায়; শেষ child-রা zombie হয়ে জমেsignal(SIGPIPE, SIG_IGN) আর writen-এর error পরীক্ষা (পর্ব ৪১); SIGCHLD উপেক্ষা
Request smugglingসামনের proxy আর আমাদের server body-র শেষ ভিন্নভাবে ধরলে লুকানো requestBody থাকলেই 413 আর সংযোগ বন্ধ; Name : value 400; দুটি Host 400 — অস্পষ্ট হলে বাতিল
অপ্রয়োজনীয় প্রকাশError পেজে version, path বা stack দেখানোনির্দিষ্ট ছোট error লেখা, কোনো Server: version নেই, ডিফল্টে শুধু 127.0.0.1-এ শোনা
মূল ধারণা

প্রতিটি সীমার পেছনে একটি প্রশ্ন: “কেউ যদি এখানে অসীম, শূন্য, ঋণাত্মক, অনেক ধীর, অনেক দ্রুত বা ভুল এনকোডিং পাঠায়?” এই প্রশ্ন প্রতিটি ইনপুটে করা, আর উত্তর না জানা থাকলে বাতিল করা — নিরাপদ server-এর এটাই রহস্য। বাড়তি সুরক্ষা আসে স্তরে স্তরে (defense in depth): কোডের যাচাই, তার নিচে কম অনুমতির ব্যবহারকারী, তার নিচে container বা sandbox (OS কোর্সের নিরাপত্তা পর্ব)।

আরও গভীরে: realpath() আর তারপর open()-এর মাঝখানে কেউ ফাইলটিকে symlink দিয়ে বদলে দিলে যাচাই আর ব্যবহারের মাঝের ফাঁক (TOCTOU) কাজে লাগানো সম্ভব। Linux 5.6 থেকে openat2() system call-এর RESOLVE_BENEATH ফ্ল্যাগ kernel-কেই বলে দেয় “এই ফোল্ডারের বাইরে কোনোভাবেই যাবে না” — যাচাই আর খোলা একসাথে, তাই ফাঁক নেই। বড় server-গুলো parser-কে fuzzing দিয়ে (AFL++, libFuzzer-এর মতো টুল লক্ষ লক্ষ বিকৃত request বানিয়ে চালায়) আর AddressSanitizer দিয়ে নিয়মিত পরীক্ষা করে; আর seccomp দিয়ে process কোন system call করতে পারবে তাও বেঁধে দেওয়া যায়।

৮ · Log, পরীক্ষা আর মাপজোখ

Common Log Format

১৯৯০-এর দশকের NCSA HTTPd আর CERN httpd-এর সময় থেকে চলে আসা এই ফরম্যাট আজও প্রায় সব server বোঝে, আর log বিশ্লেষণের অসংখ্য টুল এটা পড়তে পারে:

127.0.0.1 - - [26/Oct/2026:10:00:00 +0600] "GET /notes.txt HTTP/1.1" 200 14
│          │ │  │                            │                         │   └ body-র বাইট (না থাকলে -)
│          │ │  │                            │                         └ status code
│          │ │  │                            └ request line, হুবহু
│          │ │  └ সময়, UTC থেকে পার্থক্যসহ
│          │ └ authuser (HTTP authentication-এর নাম, নইলে -)
│          └ ident (প্রায় সবসময় -)
└ client-এর IP

“Combined” ফরম্যাটে শেষে Referer আর User-Agent যোগ হয় — Apache আর nginx-এর ডিফল্ট log এর কাছাকাছি। দুটি সতর্কতা: log-এ request line হুবহু থাকে, তাই query string-এ পাসওয়ার্ড বা token থাকলে সেটা log-এ চলে আসে (পর্ব ৩৬); আর log ফাইলকেও পাহারা দিতে হয় — log-এ client-এর পাঠানো নিয়ন্ত্রণ-অক্ষর থাকলে log দেখার টুলকেও বিভ্রান্ত করা যায় (আমরা সেগুলো আগেই বাতিল করি)।

curl দিয়ে হাতে পরীক্ষা

নিচের কমান্ডগুলো শুধু নিজের কম্পিউটারে নিজের server-এ চালাবেন। অন্যের server-এ এ ধরনের পরীক্ষা অনুমতি ছাড়া করা অনৈতিক, আর অনেক দেশে আইনত অপরাধ।

curl -i http://127.0.0.1:8080/                      # status line + header + body
curl -I http://127.0.0.1:8080/notes.txt             # HEAD: শুধু header
curl -i -X DELETE http://127.0.0.1:8080/            # 405 আর Allow আশা করুন
curl -i --path-as-is http://127.0.0.1:8080/../secret.txt      # 403 আশা করুন
curl -i http://127.0.0.1:8080/%2e%2e/secret.txt     # 403 আশা করুন
curl -i -H 'If-None-Match: "e-6aded040"' http://127.0.0.1:8080/notes.txt   # মিললে 304
printf 'GET / HTTP/1.1\r\n\r\n' | nc 127.0.0.1 8080  # Host নেই: 400

লক্ষ করুন --path-as-is: curl নিজে থেকে URL-এর /../ “পরিষ্কার” করে ফেলে, তাই এই অপশন না দিলে আপনার server আক্রমণটি দেখতেই পাবে না — আর আপনি ভুল করে ভাববেন সব ঠিক আছে। Browser-ও একই কাজ করে; attacker-রা কিন্তু কাঁচা socket ব্যবহার করে।

Load test — ab আর wrk

ab -n 10000 -c 50 -k http://127.0.0.1:8080/        # ApacheBench: ১০০০০ request, ৫০টি একসাথে, keep-alive
wrk -t 2 -c 100 -d 10s http://127.0.0.1:8080/      # ২ thread, ১০০ সংযোগ, ১০ সেকেন্ড

এই টুলগুলোর load testing আউটপুটে দেখবেন প্রতি সেকেন্ডে কতটি request (Requests per second), latency-র গড় আর বণ্টন (wrk-এ --latency দিলে ৫০, ৯০, ৯৯ শতাংশের মান), আর ব্যর্থ request-এর সংখ্যা। সংখ্যাগুলো নির্ভর করে যন্ত্র, ফাইলের আকার আর মডেলের ওপর, তাই অন্যের সংখ্যার সাথে নয়, নিজের আগের সংখ্যার সাথে তুলনা করুন। একটি শিক্ষণীয় পরীক্ষা: -c বাড়াতে থাকুন আর ps দিয়ে mini_httpd-এর process-সংখ্যা দেখুন — fork-মডেলের খরচ চোখের সামনে আসবে। -k ছাড়া আর সহ চালিয়ে keep-alive-এর লাভও মাপতে পারেন। আরও পদ্ধতিগত মাপজোখ পর্ব ৫৬-এ।

Sanitizer — বাগ ধরার যন্ত্র

পরীক্ষার সময় build করুন gcc -g -fsanitize=address,undefined -Wall -Wextra mini_httpd.c. এতে array-র সীমা পেরোনো, মুক্ত memory ব্যবহার আর অনির্ধারিত আচরণ ঘটার মুহূর্তেই প্রোগ্রাম থেমে লাইন নম্বর দেখায়। তারপর ওপরের curl কমান্ড আর বিকৃত request পাঠান। C-তে নিরাপদ কোডের অভ্যাসগুলো আছে C কোর্সের undefined behavior ও secure coding পর্বে।

৯ · সামনে কোথায় — এক্সটেনশন, আর কেন বাস্তবে nginx বা Caddy

mini_httpd-কে বাড়ানোর কয়েকটি স্বাভাবিক পথ (কয়েকটি নিচের অনুশীলনে):

  • Directory listing: ফোল্ডারে index.html না থাকলে ফাইলের তালিকা — প্রতিটি ফাইলের নাম HTML-escape করে (নইলে <script> নামের ফাইল XSS হয়ে যায়), আর /docs → 301 Location: /docs/.
  • Range আর 206: Range: bytes=200-499 এলে শুধু সেই অংশ, 206 Partial Content আর Content-Range (range request, পর্ব ৩৬) — ভিডিওর মাঝখানে লাফ দেওয়া আর ভাঙা ডাউনলোড আবার শুরু এভাবেই চলে।
  • gzip: Accept-Encoding: gzip দেখে আগে থেকে সংকুচিত .gz কপি পাঠানো, সাথে Content-Encoding: gzip আর Vary: Accept-Encoding.
  • TLS: নিজে TLS লেখা নয় — সামনে একটি reverse proxy (nginx, Caddy) HTTPS নেবে আর 127.0.0.1:8080-এ আমাদের server-কে সাধারণ HTTP-তে ডাকবে (পর্ব ৪৮)।
  • HTTP/2 ও HTTP/3: binary frame, stream আর QUIC (পর্ব ৩৭) — এগুলোও proxy সামনে থেকে দেয়; পেছনের server HTTP/1.1-এ থাকতে পারে।
  • Event loop: fork বাদ দিয়ে epoll আর প্রতিটি সংযোগের জন্য একটি ছোট state machine (পর্ব ৪৪) — সবচেয়ে বড় কিন্তু সবচেয়ে শিক্ষণীয় রূপান্তর।

তাহলে বাস্তবে নিজের লেখা server কেন নয়? কারণ একটি production web server-এর কাজ আমাদের তালিকার চেয়ে অনেক বড়: TLS আর certificate নবায়ন, HTTP/2 আর HTTP/3, cache, load balancing, rate limiting, হাজারো কনফিগারেশন — আর সবচেয়ে বড় কথা, বহু বছর ধরে লক্ষ লক্ষ সাইটে পরীক্ষিত parser, যার নিরাপত্তা-ত্রুটি পাওয়া গেলে দ্রুত সংশোধন আসে। তাই সাধারণ নকশা হলো: সামনে nginx বা Caddy, পেছনে নিজের application (Node.js, Python, Go, বা এমন একটি C প্রোগ্রাম) শুধু 127.0.0.1-এ।

# উদাহরণ — Caddyfile: HTTPS নিজে থেকে নিয়ে পেছনে পাঠানো
example.com {
    reverse_proxy 127.0.0.1:8080
}

# উদাহরণ — nginx: একই কাজ (certificate-এর লাইন বাদ রাখা হয়েছে)
server {
    listen 443 ssl;
    server_name example.com;
    location / { proxy_pass http://127.0.0.1:8080; }
}

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • একটি HTTP server request কোথায় শেষ বোঝে? — Head শেষ \r\n\r\n-এ; body-র শেষ Content-Length বা chunked দিয়ে। TCP নিজে কোনো সীমানা দেয় না, তাই জমিয়ে পড়তে হয়।
  • Path traversal কীভাবে ঠেকাবেন? — একবার decode, তারপর .. segment ও NUL বাতিল, realpath() করে root-এর ভেতরে কি না যাচাই, আর কম অনুমতিতে চালানো।
  • 405 আর 501-এর পার্থক্য? — 405: method চেনা কিন্তু এই resource-এ চলে না, Allow পাঠাতে হয়; 501: server method-টিই চেনে না।
  • Slowloris কী আর প্রতিকার কী? — খুব ধীরে header পাঠিয়ে সব worker আটকে রাখা; head-এর মোট সময়সীমা, প্রতি-IP সংযোগ সীমা, event-driven server বা reverse proxy.
  • fork-per-connection বনাম epoll? — fork সরল ও বিচ্ছিন্ন কিন্তু প্রতি সংযোগে একটি process; epoll এক thread-এ হাজারো সংযোগ সামলায়, কোড জটিল।

১০ · AI-এর যুগে — AI-লেখা server কোড যাচাই

“C-তে একটা web server লিখে দিন” — AI কয়েক সেকেন্ডে ৮০ লাইনের কোড দেবে, আর সেটা browser-এ পেজও দেখাবে। সমস্যা হলো, এই ধরনের কোডে প্রায়ই ঠিক সেই ভুলগুলো থাকে যা এই পর্বে একটি একটি করে ঠেকিয়েছি — আর কোড “চলছে” বলে চোখে পড়ে না। চেনা ভুলগুলো:

AI-লেখা কোডে যা প্রায়ই দেখা যায়সমস্যাযাচাইয়ের উপায়
একটি read(), ধরে নেওয়া পুরো request এসেছেটুকরো বা জোড়া request-এ ভুল parseRequest টুকরো করে পাঠিয়ে দেখুন (M1-এর মতো)
sprintf(path, "%s%s", root, url), কোনো যাচাই নেইPath traversal আর buffer overflow একসাথেcurl --path-as-is আর %2e%2e দিয়ে পরীক্ষা; ASan
URL দুবার decode, বা ../ মুছে “ঠিক” করাযাচাই এড়ানোর পথ খুলে যায়%252e%252e আর ....// দিয়ে পরীক্ষা
কোনো timeout নেই, SIGPIPE উপেক্ষা নেইএক ধীর বা হারিয়ে যাওয়া client-এ server আটকে বা মরে যায়nc দিয়ে সংযোগ খুলে চুপ থাকুন; মাঝপথে client বন্ধ করুন
0.0.0.0-এ bind, root হিসেবে চালানোর পরামর্শপরীক্ষার server পুরো নেটওয়ার্কে খোলা, ভুল হলে পুরো যন্ত্র ঝুঁকিতে127.0.0.1, ১০২৪-এর ওপরের port, সাধারণ ব্যবহারকারী
বানানো RFC নম্বর বা status code-এর ভুল অর্থআত্মবিশ্বাসী ভুলRFC 9110/9112 নিজে খুলে মিলিয়ে নিন

AI-কে কোড লিখতে নয়, পর্যালোচনা করতে ব্যবহার করুন — নির্দিষ্ট হুমকির তালিকা দিয়ে:

আমি C-তে (Linux, gcc) শেখার জন্য একটি ছোট static file HTTP/1.1 server লিখেছি; কোড নিচে দিলাম। ১. প্রতিটি ফাংশনে কোন ইনপুট attacker নিয়ন্ত্রণ করে, তা তালিকা করো। ২. বিশেষভাবে খোঁজো: buffer overflow, Content-Length-এ integer overflow, NUL বাইট, path traversal (%2e%2e, দুবার encode, symlink), CRLF header injection, Slowloris, SIGPIPE — প্রতিটির জন্য লাইন নম্বর আর একটি পরীক্ষার request দাও, যা আমি শুধু নিজের যন্ত্রে চালাব। ৩. Status code-এর সিদ্ধান্তগুলো RFC 9110 আর 9112-এর কোন অংশের সাথে মেলে, এক লাইনে বলো; নিশ্চিত না হলে স্পষ্ট লিখবে, কোনো RFC নম্বর বানাবে না। ৪. কোড নতুন করে লিখে দিও না — শুধু সমস্যা আর ছোট সংশোধন।

AI-এর server কোড ব্যবহারের আগে — চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১এত পরিশ্রম করে নিজের server বানালাম — তবু production-এ কেন nginx বা Caddy-কেই সামনে রাখা উচিত?

ওয়েবের প্রথম দশকে server মানেই ছিল প্রতি সংযোগে একটি process বা thread — CERN httpd, তারপর NCSA HTTPd, আর ১৯৯৫ সালে NCSA-র কোড থেকে জন্ম নেওয়া Apache. আমাদের mini_httpd-এর fork-মডেল ঠিক সেই প্রজন্মের। ১৯৯৯ সালে Dan Kegel “C10K problem” নামে একটি লেখায় প্রশ্ন তোলেন: একটি যন্ত্রে একসাথে দশ হাজার client কীভাবে সামলানো যায়? উত্তর এসেছিল event-driven নকশায় — অল্প কয়েকটি process, প্রতিটিতে একটি event loop, আর Linux-এর epoll-এর মতো kernel সুবিধা।

এই চিন্তা থেকেই Igor Sysoev nginx লেখেন, যার প্রথম প্রকাশ্য সংস্করণ আসে ২০০৪ সালে। অলস keep-alive সংযোগ সেখানে প্রায় বিনা খরচে বসে থাকে, তাই Slowloris-এর মতো ধীর আক্রমণে fork- বা thread-মডেলের মতো সহজে কাবু হয় না। পরে, ২০১৫ সালের দিকে, Go ভাষায় লেখা Caddy এল একটি ভিন্ন প্রতিশ্রুতি নিয়ে: ডিফল্টেই HTTPS, Let's Encrypt থেকে certificate নিজে থেকে নেওয়া আর নবায়ন করা। Go memory-safe ভাষা হওয়ায় buffer overflow-এর পুরো একটি শ্রেণির বাগ সেখানে প্রায় অসম্ভব।

আমাদের তালিকার বাইরেও production server-কে অনেক কিছু করতে হয়: TLS-এর সঠিক কনফিগারেশন, HTTP/2 আর HTTP/3, compression, cache, load balancing, rate limiting, log rotation, কনফিগারেশন না থামিয়ে পুনরায় লোড করা। আর সবচেয়ে গুরুত্বপূর্ণ — তাদের parser কোটি কোটি request আর অসংখ্য নিরাপত্তা-গবেষকের চোখের সামনে দিয়ে গেছে; ত্রুটি পাওয়া গেলে দ্রুত সংশোধন বের হয়, আর লাখো ব্যবহারকারী তা পায়। নিজের server-এ ত্রুটি থাকলে তা খুঁজে পাওয়ার দায়ও আপনার একার।

তাহলে এই প্রজেক্টের মূল্য কোথায়? nginx-এর কনফিগারেশনে client_header_timeout, large_client_header_buffers বা keepalive_timeout দেখলে এখন আপনি জানেন প্রতিটি কোন সমস্যার উত্তর। 431, 408 বা 413 log-এ দেখলে বোঝেন কোন সীমা ভাঙল। আর reverse proxy-র পেছনে নিজের application লিখলে বোঝেন proxy কী দেয় আর কী দেয় না।

মূল উপলব্ধি: নিজে বানানো শেখার সবচেয়ে ভালো উপায়, কিন্তু ঝুঁকি নেওয়ার সবচেয়ে খারাপ জায়গা — production-এ পরীক্ষিত server সামনে রাখুন, আর নিজের বানানোর অভিজ্ঞতা দিয়ে তাকে ঠিকভাবে কনফিগার ও debug করুন।

প্র ০২Path traversal এত পুরোনো আর এত পরিচিত দুর্বলতা — তবু বড় বড় server-এ বারবার ফিরে আসে কেন?

কারণ সমস্যাটি দেখতে সরল, কিন্তু আসলে একই path-এর অসংখ্য বানান আছে। .. লেখা যায় %2e%2e, %2E%2e, .%2e হিসেবে; slash লেখা যায় %2f হিসেবে; Windows-এ backslash-ও path ভাগ করে; আর Unicode-এর যুগে একই অক্ষরের একাধিক বাইট-রূপও সমস্যা তৈরি করেছে। যাচাই যদি একটি রূপে হয় আর ফাইল খোলা হয় অন্য রূপে, মাঝের ফাঁক দিয়েই আক্রমণ ঢোকে।

ইতিহাসে দুটি বিখ্যাত উদাহরণ। ২০০০–২০০১ সালে Microsoft-এর IIS server-এ এমন ত্রুটি পাওয়া যায় যেখানে UTF-8-এর অবৈধ “overlong” রূপে লেখা slash যাচাই এড়িয়ে যেত, আর আরেকটি ত্রুটিতে URL দুবার decode হতো — প্রথম decode-এর পর যাচাই, দ্বিতীয় decode-এর পর ফাইল খোলা। ২০০১ সালের Nimda worm এ ধরনের IIS ত্রুটি কাজে লাগিয়ে দ্রুত ছড়িয়েছিল বলে জানা যায়। এই পর্বের stepper-এর %252e উদাহরণটি ঠিক এই “double decode” ভুলের শিক্ষা। দ্বিতীয় উদাহরণ অনেক নতুন: ২০২১ সালে Apache HTTP Server 2.4.49-এ path normalization-এর একটি পরিবর্তনে .%2e-এর মতো রূপ ফসকে যায় (CVE-2021-41773); প্রথম সংশোধনও অসম্পূর্ণ প্রমাণিত হয়, আর পরের সংস্করণে আবার ঠিক করতে হয়।

শিক্ষা কয়েকটি। এক, “খারাপ জিনিসের তালিকা” (blacklist) দিয়ে নয়, একটি নির্দিষ্ট রূপে আনার (canonicalize) পরে যাচাই করুন — ঠিক একবার decode. দুই, যাচাই আর ব্যবহার যেন একই রূপের ওপর হয়। তিন, শুধু নামের ওপর ভরসা না করে শেষে ফলাফল যাচাই করুন: realpath()-এর পর আসল path root-এর ভেতরে কি না। চার, kernel-এর সাহায্য নিন — Linux-এর openat2()-এর RESOLVE_BENEATH. আর পাঁচ, নিচে আরেকটি দেয়াল রাখুন: কম অনুমতির ব্যবহারকারী, chroot বা container, যাতে কোড ভুল করলেও ক্ষতি সীমিত থাকে।

মূল উপলব্ধি: নিরাপত্তার যাচাই তখনই নির্ভরযোগ্য, যখন যে রূপটি যাচাই হলো ঠিক সেই রূপটিই ব্যবহার হয় — decode, normalize, যাচাই, ব্যবহার: একবার করে, এই ক্রমে।

প্র ০৩Fork-per-connection, thread pool আর epoll — কোন মডেল কখন বেছে নেবেন? Apache কেন কয়েক রকম মডেল রাখে?

প্রতিটি মডেল আসলে একটি প্রশ্নের ভিন্ন উত্তর: “একটি সংযোগ অপেক্ষায় বসে থাকলে তার খরচ কে বহন করবে?” Fork-মডেলে প্রতিটি অপেক্ষারত সংযোগ একটি পুরো process — নিজের memory, নিজের kernel কাঠামো। Copy-on-write থাকায় fork যতটা ভারী শোনায় ততটা নয়, তবু হাজার হাজার অলস keep-alive সংযোগে memory আর scheduler-এর চাপ বাড়ে। বিনিময়ে পাওয়া যায় বিচ্ছিন্নতা: একটি worker-এ বাগ হলে বা crash করলে শুধু সেই সংযোগ যায়। এজন্যই পুরোনো এবং thread-নিরাপদ নয় এমন module-ওয়ালা সাইটে Apache-এর “prefork” মডেল বহু বছর জনপ্রিয় ছিল।

Thread-মডেল হালকা, কিন্তু সব thread একই memory ভাগ করে — এক thread-এর buffer overflow পুরো server-কে নষ্ট করতে পারে, আর ভাগ-করা ডেটায় lock লাগে। Thread pool সংখ্যাটা বেঁধে দেয়, ফলে চাপে server ভেঙে পড়ে না; কিন্তু সব thread অলস সংযোগে আটকে থাকলে নতুন client লাইনে দাঁড়ায় — Slowloris ঠিক এটাই কাজে লাগায়। Apache তাই পরে “worker” (process আর thread মিশিয়ে) আর “event” মডেল যোগ করে, যেখানে অলস keep-alive সংযোগ একটি আলাদা listener thread দেখে, worker thread-কে আটকে রাখে না।

Event loop-এ (epoll) এক thread হাজারো সংযোগ দেখে, কারণ অলস সংযোগ শুধু kernel-এর একটি তালিকায় একটি এন্ট্রি। কিন্তু কোড উল্টে যায়: “পড়ো, তারপর parse করো” আর একটানা লেখা যায় না, প্রতিটি সংযোগ কোন ধাপে আছে সেটা একটি state machine-এ রাখতে হয়, আর কোনো একটি কাজ (যেমন ধীর disk পড়া) loop-কে আটকালে সবাই আটকায়। nginx তাই কয়েকটি worker process চালায়, প্রতিটিতে একটি event loop — দুই জগতের মিশ্রণ।

বাছাইয়ের নিয়ম মোটামুটি এমন: শেখা, ছোট অভ্যন্তরীণ টুল বা বিচ্ছিন্নতা যেখানে মুখ্য — fork; CPU-ভারী কাজ আর মাঝারি সংযোগ — thread pool; অনেক অলস বা ধীর সংযোগ (chat, push, বড় ওয়েবসাইট) — event loop. আর প্রায় সবক্ষেত্রেই সামনে একটি event-driven proxy পেছনের মডেলের দুর্বলতা অনেকটা ঢেকে দেয়।

মূল উপলব্ধি: Concurrency মডেল বাছাই মানে অপেক্ষার খরচ, বিচ্ছিন্নতা আর কোডের সরলতার মধ্যে আপস — কোনো একটি “সেরা” নয়, কাজের ধরন ঠিক করে দেয়।

প্র ০৪Timeout কত হওয়া উচিত? খুব ছোট রাখলে আর খুব বড় রাখলে কার ক্ষতি?

Timeout হলো দুই পক্ষের স্বার্থের সীমারেখা। খুব বড় হলে server-এর ক্ষতি: প্রতিটি অলস বা ধীর সংযোগ একটি worker, একটি file descriptor আর কিছু memory আটকে রাখে, আর Slowloris-এর মতো আক্রমণ এই উদারতাকেই অস্ত্র বানায়। ২০০৯ সালে প্রকাশিত Slowloris দেখিয়েছিল, একটি সাধারণ কম্পিউটার থেকে কয়েকশো ধীর সংযোগই প্রতি-সংযোগে-thread ধরনের server-কে অচল করতে পারে — কোনো বড় bandwidth লাগে না। পরে Apache-এ request পড়ার সময়সীমার জন্য আলাদা module (mod_reqtimeout) এল, আর nginx-এ header ও body পড়ার আলাদা timeout আছে।

খুব ছোট হলে ক্ষতি client-এর, বিশেষ করে ধীর নেটওয়ার্কে। বাংলাদেশে গ্রামাঞ্চলে বা ভিড়ের সময় মোবাইল ডেটায় RTT কয়েকশো millisecond, কখনো আরও বেশি হতে পারে, আর packet loss-এ TCP-র পুনঃপ্রেরণ কয়েক সেকেন্ড পর্যন্ত দেরি করাতে পারে (পর্ব ৩০)। Head পড়ার সীমা দুই সেকেন্ড হলে এমন ব্যবহারকারী 408 পাবেন — সার্ভার “নিরাপদ”, কিন্তু মানুষটি সেবা পেলেন না।

তাই timeout একটি নয়, কয়েকটি, আর প্রতিটির উদ্দেশ্য আলাদা। Idle keep-alive timeout ছোট রাখা চলে (কয়েক সেকেন্ড), কারণ সংযোগ বন্ধ হলে client শুধু একটি নতুন handshake দেয়। Head পড়ার মোট সময়সীমা একটু উদার (দশ সেকেন্ডের ঘরে), যাতে ধীর client টিকে যায় কিন্তু অসীম ধীর client নয়। আর body বা বড় ফাইল পাঠানোর ক্ষেত্রে মোট সময় নয়, ন্যূনতম গতি দেখাই ভালো — যেমন “প্রতি কয়েক সেকেন্ডে অন্তত কিছু বাইট অগ্রগতি”।

সংখ্যাগুলো আন্দাজে নয়, মাপজোখ থেকে আসা উচিত: আপনার আসল ব্যবহারকারীদের request আসতে কত সময় লাগে, log থেকে তার বণ্টন দেখুন, আর ৯৯তম শতাংশের ওপরে একটু জায়গা রেখে সীমা বসান। তার সাথে প্রতি-IP সংযোগের সীমা আর সামনে একটি event-driven proxy থাকলে ছোট-বড় দুই দিকের ঝুঁকিই কমে।

মূল উপলব্ধি: Timeout নিরাপত্তা আর প্রাপ্যতার মধ্যে আপস — প্রতিটি পর্যায়ের জন্য আলাদা সীমা, আর সংখ্যাগুলো নিজের ব্যবহারকারীদের মাপজোখ থেকে।

প্র ০৫“যা পাঠাবেন তাতে কড়া হোন, যা গ্রহণ করবেন তাতে উদার হোন” — এই পুরোনো নীতি HTTP server-এর জন্য এখনো কি ঠিক?

নীতিটি Jon Postel-এর নামে পরিচিত — robustness principle, যা ১৯৮০ সালের TCP মানদণ্ডে (RFC 761) আর পরে ১৯৮১ সালের RFC 793-এ লেখা আছে। প্রাথমিক ইন্টারনেটে এর যুক্তি ছিল জোরালো: নানা প্রতিষ্ঠানের অসম্পূর্ণ বাস্তবায়ন যেন একে অপরের সাথে কথা বলতে পারে। ওয়েবও বহু বছর এই পথে চলেছে — browser আর server ভাঙা HTML, ভুল header, অদ্ভুত line ending সব মেনে নিয়েছে।

কিন্তু উদারতার একটি লুকানো দাম আছে। যখন একই বার্তা দুটি ভিন্ন প্রোগ্রাম দুইভাবে “উদারভাবে” বোঝে, তখন তাদের মাঝের ফাঁকে আক্রমণ জন্মায়। ২০০৫ সালে Watchfire-এর গবেষকরা “HTTP Request Smuggling” নামে দেখান: সামনের proxy আর পেছনের server যদি Content-Length আর Transfer-Encoding-এর দ্বন্দ্ব আলাদাভাবে মেটায়, একটি request-এর ভেতরে আরেকটি লুকিয়ে পাঠানো যায়। ২০১৯ সালে James Kettle এই শ্রেণিকে নতুন করে বড় বড় সাইটে দেখিয়ে আবার আলোচনায় আনেন। এই পর্বের Name : value (colon-এর আগে space) বা দুটি Host-কে 400 দেওয়ার নিয়ম এই অভিজ্ঞতারই ফল — RFC 9112 নিজেই বলে এমন বার্তা বাতিল করতে।

IETF-এর ভেতরেও চিন্তা বদলেছে। ২০২৩ সালের একটি তথ্যমূলক RFC (RFC 9413, “Maintaining Robust Protocols”) যুক্তি দেয় যে দীর্ঘমেয়াদে উদার গ্রহণ protocol-কে ক্ষয় করে: অন্যদের ভুল স্থায়ী হয়ে যায়, আর প্রত্যেক নতুন বাস্তবায়নকে সেই ভুলগুলো অনুকরণ করতে হয়। বিকল্প প্রস্তাব — নির্দিষ্ট, কড়া parse, আর অস্পষ্ট হলে স্পষ্টভাবে প্রত্যাখ্যান।

তবে কড়া হওয়া মানে অযথা নিষ্ঠুর হওয়া নয়। RFC নিজে যেখানে বিকল্প দেয় (যেমন header-এর মানের আগে-পরে space), সেটা মেনে নিন; আর বাতিল করলে সঠিক status code দিন, যাতে client বুঝতে পারে কী ভুল। নিয়ম: অর্থ নিয়ে সন্দেহ থাকলে বাতিল, শুধু সাজসজ্জার পার্থক্য হলে সহনশীল।

মূল উপলব্ধি: নিরাপত্তার জগতে “উদার গ্রহণ” প্রায়ই “দুই প্রোগ্রামের দুই ব্যাখ্যা” হয়ে দাঁড়ায় — নির্দিষ্ট ও কড়া parse, আর অস্পষ্টতায় স্পষ্ট প্রত্যাখ্যানই আধুনিক server-এর পথ।

অনুশীলন

প্রতিটি অনুশীলন mini_httpd-এর একটি সত্যিকারের এক্সটেনশন — সূত্র আর “কখন হয়েছে বলবেন” শর্তসহ।

  1. Range আর 206 — হাতে হিসাব, তারপর কোড: একটি ফাইলের আকার ১০০০ বাইট। নিচের প্রতিটি Range header-এর জন্য status code, Content-Range আর Content-Length লিখুন: (ক) bytes=200-499, (খ) bytes=900-, (গ) bytes=-100, (ঘ) bytes=950-2000, (ঙ) bytes=1200-1300. সূত্র: শেষ সীমা ফাইলের বাইরে গেলে কেটে ছোট করা হয়; শুরুই বাইরে হলে অংশটি অসন্তোষজনক। গ্রহণযোগ্যতা: curl -i -r 200-499 ঠিক ৩০০ বাইট আর সঠিক header দেয়; Range ছাড়া আগের মতো 200.
    • (ক) 206, Content-Range: bytes 200-499/1000, Content-Length: 300 (৪৯৯ − ২০০ + ১)।
    • (খ) 206, bytes 900-999/1000, দৈর্ঘ্য ১০০ — খোলা শেষ মানে ফাইলের শেষ পর্যন্ত।
    • (গ) 206, bytes 900-999/1000, দৈর্ঘ্য ১০০ — -100 মানে “শেষের ১০০ বাইট”, ১০০ নম্বর বাইট নয়।
    • (ঘ) 206, bytes 950-999/1000, দৈর্ঘ্য ৫০ — শেষ সীমা ৯৯৯-এ কাটা।
    • (ঙ) 416 Range Not Satisfiable, Content-Range: bytes */1000 — শুরু (১২০০) ফাইলের বাইরে।

    কোডে: parse()-এ Range ধরুন, শুধু একটি অংশ সমর্থন করুন (কমা থাকলে Range উপেক্ষা করে পুরো ফাইল 200-এ দেওয়া বৈধ)। সংখ্যা পড়তে content_length()-এর মতোই overflow-নিরাপদ পার্সার ব্যবহার করুন। serve_file()-এ lseek(fd, start, SEEK_SET) করে ঠিক end − start + 1 বাইট stream করুন, আর 200-এর উত্তরে Accept-Ranges: bytes যোগ করুন।

  2. আউটপুট অনুমান করুন: M2-এর route() (Host বাধ্যতামূলক, চেনা method GET HEAD POST PUT DELETE OPTIONS PATCH, শুধু / আছে) নিচের request-গুলোর জন্য কোন status line দেবে, আর body থাকবে কি? (১) HEAD /fees HTTP/1.1 + Host: x; (২) PUT / HTTP/1.1 + Host: x; (৩) get / HTTP/1.1 + Host: x; (৪) GET / HTTP/1.1 + host: x + HOST: y; (৫) GET /?lang=bn HTTP/1.1 + Host: x.
    • (১) 404 Not Found, header আছে কিন্তু body নেই — HEAD-এ error উত্তরেরও body যায় না।
    • (২) 405 Method Not Allowed + Allow: GET, HEAD — PUT চেনা method.
    • (৩) 501 Not Implemented — method বড়-ছোট হাতের অক্ষরে আলাদা; get আর GET এক নয় (header-এর নামের উল্টো!)।
    • (৪) 400 Bad Request — নাম বড়-ছোট মিলিয়ে দুটিই Host, অর্থাৎ দুটি Host.
    • (৫) 404 Not Found — M2 target-কে পুরোটা /-এর সাথে মেলায়, query বাদ দেয় না। এটা M2-র একটি সীমাবদ্ধতা; M3 আর পূর্ণ কোডে ?-এর পরের অংশ path থেকে বাদ যায়, তাই সেখানে 200.
  3. Directory listing — নিরাপদভাবে: কোনো ফোল্ডারে index.html না থাকলে ফাইলগুলোর একটি HTML তালিকা দিন। সূত্র: opendir/readdir; লুকানো (. দিয়ে শুরু) নাম বাদ; প্রতিটি নাম link-এ percent-encode আর লেখায় HTML-escape; /docs (শেষে slash নেই) এলে 301. গ্রহণযোগ্যতা: <b>x.txt নামের ফাইল তালিকায় আক্ষরিক লেখা হিসেবে দেখায়, মোটা অক্ষরে নয়; curl -i /docs দেয় 301 আর Location: /docs/.

    মূল অংশ: (১) serve_file()-এ S_ISDIR হলে আর path /-এ শেষ না হলে 301; Location-এ বসান যাচাই করা path আর একটি / — যেহেতু safe_path নিয়ন্ত্রণ-অক্ষর আগেই বাতিল করেছে, CRLF injection সম্ভব নয়, তবু Location-এর জন্য নামটি আবার percent-encode করুন। (২) Slash-সহ ফোল্ডারে index.html না থাকলে body একটি buffer-এ বানান (অথবা chunked encoding-এ পাঠান, কারণ আগে থেকে দৈর্ঘ্য জানা নেই)। (৩) HTML-escape: & → &amp;, < → &lt;, > → &gt;, " → &quot;, ' → &#39;. Escape না করলে কেউ <script> নামের ফাইল রেখে তালিকা-দেখা প্রত্যেকের browser-এ script চালাতে পারত (stored XSS). (৪) Symlink-এর ক্ষেত্রেও realpath-এর সীমানা-যাচাই আগের মতোই থাকবে।

  4. কোড লিখুন — If-Modified-Since আর সংযোগের সীমা: (ক) If-Modified-Since সমর্থন যোগ করুন: তারিখ ফাইলের mtime-এর সমান বা পরে হলে 304. (খ) একসাথে সর্বোচ্চ ৬৪টি worker; বেশি হলে নতুন সংযোগে সঙ্গে সঙ্গে 503 Service Unavailable আর Retry-After: 5. গ্রহণযোগ্যতা: curl -z দিয়ে পাঠানো তারিখে 304; একই request-এ If-None-Match থাকলে শুধু সেটাই বিচার হয়; ab -c 200 চালালে কিছু 503 আসে কিন্তু server বেঁচে থাকে।
    /* (a) add "char ims[64];" to struct req, then in parse():
           else if (!strcasecmp(p, "If-Modified-Since")) snprintf(r->ims, sizeof r->ims, "%s", v); */
    /* in serve_file(), after the ETag check: */
    struct tm tm = {0};
    if (!r->inm[0] && r->ims[0] && strptime(r->ims, "%a, %d %b %Y %H:%M:%S GMT", &tm)
        && st.st_mtime <= timegm(&tm)) not_mod = 1;          /* If-None-Match wins when both exist */
    
    /* (b) in main(): count live children; SIGCHLD handler does waitpid(-1, NULL, WNOHANG) in a loop */
    if (live >= 64) { dprintf(s, "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 5\r\n"
                                 "Content-Length: 0\r\nConnection: close\r\n\r\n"); close(s); continue; }

    নিয়মগুলো RFC 9110-এর: দুটি শর্ত একসাথে থাকলে If-None-Match অগ্রাধিকার পায়, If-Modified-Since উপেক্ষিত হয়; আর এটি শুধু GET ও HEAD-এ প্রযোজ্য। strptime আর timegm GNU/BSD সুবিধা — _GNU_SOURCE আগেই দেওয়া আছে। (খ)-তে SIGCHLD-কে আর SIG_IGN রাখা যাবে না, কারণ কতজন শেষ হলো তা গুনতে হবে; handler-এ শুধু async-signal-safe কাজ (waitpid আর একটি volatile sig_atomic_t কমানো)। Signal handler-এর নিয়ম দেখতে পারেন OS কোর্সের signal পর্বে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি static file HTTP server-এ একটি request-এর কাজগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. accept() দিয়ে নতুন সংযোগ নেওয়া
  2. ফাঁকা লাইন (\r\n\r\n) পর্যন্ত request head জমিয়ে পড়া
  3. Request line আর header parse করে যাচাই করা
  4. Path একবার decode করে document root-এর ভেতরে কি না দেখা
  5. Header পাঠিয়ে ফাইলটি টুকরো টুকরো পাঠানো
  6. Log লিখে keep-alive-এ পরের request-এর অপেক্ষা করা

একটি server-এ এই request এল: GET /index.html HTTP/1.1, তারপর সরাসরি ফাঁকা লাইন — কোনো header নেই। RFC 9112 অনুযায়ী server-এর উত্তর কী হওয়া উচিত?

  • 200 OK, ফাইলসহ
  • 400 Bad Request
  • 404 Not Found
  • 505 HTTP Version Not Supported

একটি server target একবার percent-decode করে, তারপর .. segment খোঁজে। Document root /www. Request target /%2e%2e/etc/passwd হলে কী হবে?

  • /www/%2e%2e/etc/passwd নামের ফাইল খোঁজে, না পেয়ে 404
  • Decode-এর পর /../etc/passwd হয়, .. ধরা পড়ে — প্রত্যাখ্যান
  • /etc/passwd পাঠিয়ে দেয়
  • 505 দেয়

একটি TCP-ভিত্তিক HTTP server প্রতিটি request-এর জন্য ঠিক একবার read() করে ধরে নেয় পুরো request এসে গেছে। এই নকশা কেন ভুল?

  • TCP বার্তার সীমানা রাখে না — request টুকরো হয়ে বা অন্যটির সাথে জোড়া লেগে আসতে পারে
  • read() একবারে শুধু একটি packet দিতে পারে
  • HTTP/1.1 প্রতিটি header আলাদা TCP segment-এ পাঠাতে বাধ্য করে
  • read() সবসময় non-blocking

একটি server প্রতিটি read()-এ ১০ সেকেন্ডের timeout দেয়। একজন attacker শত শত সংযোগ খুলে প্রতিটিতে প্রতি ৮ সেকেন্ডে একটি করে header লাইন পাঠায় (Slowloris). সবচেয়ে কার্যকর প্রতিরক্ষা কোনটি?

  • Head-এর buffer আকার বাড়ানো
  • শুধু প্রতি-read() timeout ১০ থেকে ২০ সেকেন্ডে বাড়ানো
  • পুরো request head আসার মোট সময়সীমা, সাথে প্রতি-IP সংযোগের সীমা
  • Firewall-এ port 80 বন্ধ করা

একটি static file server শুধু GET আর HEAD সমর্থন করে। DELETE /notes.txt HTTP/1.1 (Host-সহ) এলে সবচেয়ে সঠিক উত্তর কোনটি?

  • 501 Not Implemented
  • 403 Forbidden
  • 404 Not Found
  • 405 Method Not Allowed, সাথে Allow: GET, HEAD

একটি server request-এর Content-Length পড়তে atoi() ব্যবহার করে। Client পাঠাল Content-Length: 99999999999999999999999. সবচেয়ে নিরাপদ আচরণ কোনটি?

  • atoi-এর ফেরত মান মেনে নিয়ে সেই পরিমাণ body পড়া
  • সংখ্যাটি int-এ কেটে যা দাঁড়ায় সেটাকে body-র আকার ধরা
  • শুধু অঙ্ক আর overflow যাচাই করে পড়া; ব্যর্থ হলে 400 দিয়ে সংযোগ বন্ধ
  • Content-Length header উপেক্ষা করে পরের request পড়া

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Server-এর হৃদয় একটি লুপ: accept → \r\n\r\n পর্যন্ত জমিয়ে পড়া (সীমা ভাঙলে 431, ধীর হলে 408) → parse → সিদ্ধান্ত → উত্তর → log → keep-alive বা বন্ধ। বাড়তি বাইট পরের request-এর জন্য রাখুন।
  2. যাচাইয়ের ক্রম: বার্তা বৈধ ও ঠিক একটি Host (400) → method চেনা (501) → এখানে অনুমোদিত (405 + Allow) → resource আছে (404). HEAD-এ header একই, body নেই।
  3. Path: একবার decode → NUL, নিয়ন্ত্রণ-অক্ষর আর .. segment বাতিল → realpath() দিয়ে root-এর ভেতরে কি না যাচাই। কেটে-মুছে “ঠিক” নয়, বাতিল।
  4. Content-Type extension থেকে (অচেনা হলে octet-stream), ফাইল stream করে, Content-Length fstat থেকে; ETag মিললে body ছাড়া 304.
  5. প্রতিরক্ষা সীমা দিয়ে: buffer, Content-Length-এর overflow, idle ও মোট timeout, request-সংখ্যা, SIGPIPE; শুধু 127.0.0.1-এ, সাধারণ ব্যবহারকারী হিসেবে — আর বাস্তবে সামনে nginx বা Caddy.

পরের পর্বে: মডিউল ৮ শুরু — নেটওয়ার্ক নিরাপত্তার ভিত্তি: গোপনীয়তা, অখণ্ডতা আর প্রাপ্যতা (CIA), হুমকি-মডেল, আর এই পর্বে যে “স্তরে স্তরে প্রতিরক্ষা” দেখলেন, তার পুরো নেটওয়ার্ক-জোড়া রূপ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান