Skip to content

BenaliOssama/localserver

Β 
Β 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

118 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

localserver

A production-grade HTTP/1.1 server written from scratch in Rust. Built without any async runtimes or HTTP frameworks β€” just raw sockets, epoll, and the standard library.

cargo run config.conf

Why this exists

Most web servers are black boxes. This one isn't. Every line β€” from the TCP handshake to the CGI fork β€” is written by hand and explained. The goal was to understand HTTP at the systems level, not just use it.


Architecture

src/
β”œβ”€β”€ main.rs         # Entry point β€” reads config, spawns one thread per port group
β”œβ”€β”€ lib.rs          # Crate root β€” exposes modules for integration tests
β”œβ”€β”€ server.rs       # epoll event loop β€” accepts connections, dispatches reads/writes
β”œβ”€β”€ request.rs      # HTTP/1.1 parser β€” request line, headers, body, chunked encoding
β”œβ”€β”€ response.rs     # HTTP response builder β€” status codes, headers, cookies
β”œβ”€β”€ handler.rs      # Routing logic β€” matches locations, serves files, runs CGI
β”œβ”€β”€ cgi.rs          # CGI runner β€” forks processes, pipes I/O, enforces timeouts
β”œβ”€β”€ epoll.rs        # epoll wrapper β€” edge-triggered, non-blocking I/O
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ mod.rs          # Config structs + validation (duplicate detection)
β”‚   β”œβ”€β”€ tokenizer.rs    # Lexer β€” raw text β†’ tokens with line numbers
β”‚   β”œβ”€β”€ parser.rs       # Recursive descent parser β†’ ServerConfig structs
β”‚   β”œβ”€β”€ errors.rs       # ParseError with line numbers for debugging
β”‚   └── tests/
β”‚       β”œβ”€β”€ mod.rs
β”‚       β”œβ”€β”€ tokenizer_tests.rs
β”‚       └── parser_tests.rs
└── utils/
    β”œβ”€β”€ mod.rs
    └── session.rs      # Session store β€” in-memory, cookie-backed, with expiry

The data flow is intentionally linear:

TCP connection
  └── epoll detects readable fd
        └── request.rs parses raw bytes β†’ Request struct
              └── handler.rs matches location β†’ builds Response
                    └── epoll detects writable fd β†’ response bytes sent

How it works

I/O multiplexing with epoll

The server uses a single epoll instance per port to watch hundreds of connections simultaneously without threads. This is edge-triggered (EPOLLET) β€” the kernel fires once per new data arrival, so the server must drain the entire socket buffer before returning to epoll_wait.

epoll_wait() β†’ fires on fd N
  if fd == listener  β†’ accept all pending connections (loop until EAGAIN)
  if EPOLLIN         β†’ read all available bytes (loop until EAGAIN)
  if EPOLLOUT        β†’ write response bytes (loop until EAGAIN or done)

All reads and writes go through epoll. Nothing blocks.

Request parsing

HTTP/1.1 requests arrive as raw bytes. The parser splits on \r\n\r\n to separate headers from body, then reads exactly Content-Length bytes for the body. Chunked transfer encoding (Transfer-Encoding: chunked) is decoded before the body reaches the handler.

Config-driven routing

Routes are defined in config.conf. The handler uses longest-prefix matching β€” /images/photo.jpg matches /images before /. Each location specifies its root directory, allowed methods, and optional CGI.

CGI

When a request matches a CGI location, the server forks a child process, sets environment variables (REQUEST_METHOD, PATH_INFO, QUERY_STRING, CONTENT_LENGTH), pipes the request body to stdin, and reads the response from stdout. Scripts that exceed 10 seconds are killed.


Configuration

server {
    host        127.0.0.1;
    port        8080;
    server_name mysite.com;
    client_max_body_size 20MB;

    error_page 404 ./error_pages/404.html;
    error_page 500 ./error_pages/500.html;
    error_page 403 ./error_pages/403.html;

    location / {
        root    ./www;
        index   index.html;
        methods GET;
        autoindex off;
    }

    location /uploads {
        root    ./www/uploads;
        methods GET POST DELETE;
    }

    location /files {
        root      ./files;
        autoindex on;
        methods   GET;
    }

    location /cgi {
        root    ./cgi-bin;
        methods GET POST;
        cgi     .py python3;
    }

    location /old-page {
        redirect /new-page;
    }
}

Multiple server blocks share the same port when server_name differs β€” the server dispatches by Host header.

Validation at startup:

  • Duplicate host:port:server_name β†’ rejected with a clear error
  • Invalid port number, unknown directive, unclosed block β†’ rejected with line number
  • Missing required fields (host, port) β†’ rejected

Features

Feature Status
HTTP/1.1 GET / POST / DELETE βœ…
Static file serving βœ…
Directory listing (autoindex) βœ…
File uploads βœ…
Custom error pages βœ…
Chunked transfer encoding βœ…
HTTP redirects (301) βœ…
CGI (Python) βœ…
Cookies and sessions βœ…
Multiple servers on multiple ports βœ…
Virtual hosting (server_name) βœ…
Client body size limit βœ…
Connection timeouts (30s) βœ…
Edge-triggered epoll βœ…
Non-blocking I/O throughout βœ…
Config file validation βœ…

Getting started

Requirements: Rust 1.70+, Python 3 (for CGI tests)

# Build
cargo build --release

# Run
cargo run config.conf

# Run with release build
./target/release/localserver config.conf

Create the directory structure:

mkdir -p www www/uploads files cgi-bin error_pages
echo "<html><body><h1>Hello</h1></body></html>" > www/index.html
echo "<html><body><h1>404</h1></body></html>"   > error_pages/404.html
echo "<html><body><h1>500</h1></body></html>"   > error_pages/500.html
echo "<html><body><h1>403</h1></body></html>"   > error_pages/403.html

Testing

Unit tests

Test individual modules in isolation β€” no network, no disk where possible.

cargo test
Module Tests What's covered
request.rs 25 Parsing, headers, cookies, chunked encoding, malformed input
response.rs 12 Wire format, status codes, cookie headers, binary bodies
handler.rs 20 File serving, method routing, 404/403/405, upload/delete
epoll.rs 9 fd lifecycle, non-blocking verification, drop behavior
config/ 41 Tokenizer, parser, validation, error cases
cgi.rs 14 Output parsing, status forwarding, timeout, real scripts

107 tests total. All passing.

End-to-end tests

Spin up real servers on random ports, send real HTTP over TCP.

cargo test --test e2e_server
cargo test --test e2e_cgi
Test file Tests What's covered
e2e_server.rs 13 Full HTTP cycle, upload/delete, concurrency, crash survival
e2e_cgi.rs 13 CGI execution, query strings, POST bodies, timeouts, crashes

Makefile tests

make test-all       # Upload β†’ retrieve β†’ delete β†’ verify
make test-cgi       # GET, POST, query string via real CGI script
make test-login     # Login β†’ whoami β†’ logout β†’ 403

Stress tests

# Full stress test β€” 7 phases, ~5 minutes
./scripts/stress_test.sh

# Audit checklist β€” covers every evaluation requirement
./scripts/audit_test.sh

Stress test phases:

Phase What it does
1 β€” Health check GET/404/405 baseline
2 β€” Concurrent GETs 10 / 50 / 100 simultaneous requests
3 β€” Concurrent POSTs 100 uploads + disk verification + round-trip integrity
4 β€” DELETE + mixed 50 concurrent deletes + 150 mixed method requests
5 β€” Bad input Garbage, empty connections, partial requests, oversized bodies
6 β€” Siege 10 β†’ 25 β†’ 50 β†’ 100 β†’ 255 concurrent users, 60s sustained
7 β€” Cleanup Removes all test artifacts

Benchmarks

Tested on a mid-range laptop (Intel i5, 8GB RAM):

siege -b -t 30s -c 255 http://127.0.0.1:8080/

Transactions:          ~180,000
Availability:            100.00%
Transaction rate:        ~6,000 req/s
Response time:            0.04s avg
Failed transactions:           0

Availability stays above 99.5% at all concurrency levels tested (10 β†’ 255 concurrent users).


Answering the audit questions

How does an HTTP server work? A server binds a TCP socket to a port and calls accept() in a loop. For each connection, it reads bytes, parses them as an HTTP request, builds a response, and writes it back. The connection is then closed (HTTP/1.0) or reused (HTTP/1.1 keep-alive).

Which function for I/O multiplexing and how does it work? epoll (Linux). You create an epoll instance with epoll_create1(), register file descriptors with epoll_ctl(), and call epoll_wait() to block until one or more fds are ready. The kernel maintains the watch list in the kernel space β€” unlike select, it doesn't scan all fds on every call, making it O(1) per event rather than O(n).

Is there only one epoll for reads and writes? Yes β€” src/server.rs creates one Epoll instance per port. The same instance watches both the listening socket and all client sockets. When a client socket becomes readable, EPOLLIN fires. When it becomes writable, EPOLLOUT fires. We switch between them with EPOLL_CTL_MOD.

Why only one epoll? Multiple epoll instances would require coordinating which instance owns which fd, introducing complexity and potential race conditions. One instance per port gives a complete, consistent view of all connections on that port.

Is there only one read/write per client per epoll event? In edge-triggered mode, each epoll_wait event means new data has arrived since the last check. We drain the entire socket buffer in a loop (until EAGAIN), but this counts as one logical read per epoll event β€” it's the only correct approach with EPOLLET.

Are return values checked? Yes. Every read(), write(), epoll_ctl(), and epoll_wait() call checks its return value. Errors on client sockets remove the client from epoll and close the fd. Errors on the epoll fd itself propagate up to main and exit cleanly.

Is writing always done through epoll? Yes. When a response is ready, it's stored in a write buffer. We call epoll.watch_write(fd) to register EPOLLOUT. When epoll_wait fires with EPOLLOUT, we write from the buffer. We never call write() outside of an epoll event.


Project structure at a glance

localserver/
β”œβ”€β”€ src/                    # Server source
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ common/mod.rs       # Shared test helpers and server factories
β”‚   β”œβ”€β”€ e2e_server.rs       # End-to-end server tests
β”‚   └── e2e_cgi.rs          # End-to-end CGI tests
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ stress_test.sh      # 7-phase stress test
β”‚   └── audit_test.sh       # Audit checklist (covers evaluation sheet)
β”œβ”€β”€ cgi-bin/
β”‚   └── hello.py            # Example CGI script
β”œβ”€β”€ www/                    # Default document root
β”œβ”€β”€ error_pages/            # Custom error pages
└── config.conf             # Server configuration

What's not implemented

  • HTTPS / TLS
  • HTTP/2
  • Keep-alive connection reuse
  • Gzip compression
  • Virtual file system
  • Authentication beyond the demo session system

These are outside the project scope but the architecture supports adding them β€” the request/response pipeline is cleanly separated from the transport layer.

About

πŸ¦€ A crash-proof HTTP/1.1 web server built from scratch in Rust β€” single-threaded, event-driven, no frameworks.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages

  • Rust 85.6%
  • Shell 11.2%
  • Makefile 2.2%
  • Other 1.0%