diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72d650c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd10823 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.DS_Store + +# Files created by POST /upload at runtime +resources/uploads/* +!resources/uploads/.gitkeep diff --git a/403 b/403 deleted file mode 100644 index e69de29..0000000 diff --git a/404 b/404 deleted file mode 100644 index e69de29..0000000 diff --git a/README.md b/README.md index ac3a2a4..62e514d 100644 --- a/README.md +++ b/README.md @@ -1,135 +1,99 @@ -# Multi-threaded HTTP Server (My Step-by-Step Build) - -Hi sir, Im Nipun Thumu . Roll no - 10207 . this is my own HTTP/1.1 server built from scratch using Python sockets and threads. Below there's explanation on how I built it, why I made certain choices, and how to run and test it. - -I used AI to help with wording, comments, and some boilerplate code. I tested, and finalized all the code myself. - - - -I wanted a simple but correct HTTP server that: -- serves static files (HTML in browser, PNG/JPEG/TXT as downloads), -- accepts JSON uploads via POST /upload and saves them, -- handles multiple clients at the same time using multithreading. -- keeps connections alive (HTTP/1.1) with a timeout and a max-requests limit - - -I kept it minimal without any complex features. - -## How I built it - -1) Project setup -- I created a resources/ folder (with index.html, about.html, contact.html, images, and text files) and a resources/uploads/ folder for JSON uploads. - - -2) Command-line args and startup defaults -- I parse three arguments: port (default 8080), host (default 127.0.0.1), and max threads (default 10). - - -3) TCP socket and listen queue -- I created a TCP socket, set SO_REUSEADDR, bind to host:port, and listen(backlog=50). -- If bind fails, I made it such that it prints a clear error and exit. - -4) Thread pool and a bounded queue -- I spin up a fixed number of worker threads. -- The main thread will accept connections and enqueues them. -- If the queue is full, I immediately reply 503 Service Unavailable and close the socket so the server doesn’t crash - -5) Reading and parsing requests (up to 8192 bytes) -- I read incoming data up to 8192 bytes and split headers from the body using the CRLF marker. -- I’m strict enough to reject malformed requests with 400. - -6) Host header validation (security) -- I only accept Host values that match my server (localhost:PORT or 127.0.0.1:PORT). -- Missing Host → 400. Mismatch → 403. I log these clearly for understanding. +# Python HTTP/1.1 Server + +[![CI](https://github.com/nipun172006/CnFinalProject/actions/workflows/ci.yml/badge.svg)](https://github.com/nipun172006/CnFinalProject/actions/workflows/ci.yml) + +A dependency-free HTTP/1.1 server built directly on Python sockets. The project +demonstrates request parsing, persistent connections, bounded concurrency, +static-file streaming, JSON uploads, and basic path/host validation without a +web framework. + +## Highlights + +- Fixed worker pool with a bounded queue and `503 Service Unavailable` under + saturation +- HTTP/1.0 and HTTP/1.1 connection handling with keep-alive time and request + limits +- Static HTML, CSS, JavaScript, text, and image responses streamed in 8 KB + chunks +- `POST /upload` validation for UTF-8 JSON bodies +- Canonical path checks that keep file access inside `resources/` +- Host-header validation for the configured local address and port +- Dependency-free automated unit tests for parsing, path safety, response + headers, and connection policy + +## Architecture + +```text +TCP listener + -> bounded connection queue + -> fixed worker threads + -> HTTP request parser + -> host/path/content validation + -> static-file response or JSON upload +``` -7) Safe path resolution (security) -- I map “/” to resources/index.html. -- I reject paths with “..”, “./”, absolute paths, etc. -- I canonicalize the final path and ensure it stays under resources/. Violations → 403. +## Run locally -8) GET handling (HTML render + binary download) -- .html → text/html; charset=utf-8 (render in the browser). -- .txt/.png/.jpg/.jpeg → application/octet-stream with Content-Disposition: attachment to force download. -- I set Content-Length and Date/Server headers properly. -- Unsupported extensions → 415. +Requires Python 3.10 or newer. From the repository root: -9) POST /upload (JSON only) -- I accept only Content-Type: application/json. -- I parse/validate the JSON. Invalid → 400. Wrong type → 415. -- I save the raw body into resources/uploads/upload_YYYYMMDD_HHMMSS_xxxx.json. -- I return 201 with a small JSON body { status, message, filepath }. +```bash +python3 server.py +``` -10) Connection management (keep-alive) -- HTTP/1.1: keep-alive by default unless the request says Connection: close. -- HTTP/1.0: close by default unless Connection: keep-alive. -- I enforce Keep-Alive: timeout=30 and max=100 requests/connection. -- After the limit or on timeout, I close the connection gracefully. +Optional positional arguments set the port, host, and worker count: -11) Errors and logging -- I return the required errors: 400/403/404/405/415/500/503. -- I log startup, connections, requests, security checks, file sends, and when clients are queued or served. +```bash +python3 server.py 8080 127.0.0.1 10 +``` +Then open [http://127.0.0.1:8080](http://127.0.0.1:8080). +## API examples -## U can manually test these +Fetch the home page: -Basic functionality ```bash -# Home page (HTML) curl -i http://127.0.0.1:8080/ +``` -# About page (HTML) -curl -i http://127.0.0.1:8080/about.html - -# Download text file (will save as binary) -curl -i http://127.0.0.1:8080/sample.txt -o /tmp/sample.txt - -# Download PNG/JPEG (forced download via attachment) -curl -OJ http://127.0.0.1:8080/logo.png -curl -OJ http://127.0.0.1:8080/photo.jpg +Upload JSON: -# Upload JSON +```bash curl -i -X POST http://127.0.0.1:8080/upload \ -H 'Content-Type: application/json' \ -d '{"hello":"world"}' ``` -Error and security checks -```bash -# Missing file -> 404 -curl -i http://127.0.0.1:8080/nope.png - -# Wrong method -> 405 -curl -i -X PUT http://127.0.0.1:8080/index.html - -# Wrong Content-Type -> 415 -curl -i -X POST http://127.0.0.1:8080/upload -d 'not-json' - -# Path traversal (use --path-as-is to avoid client normalization) -> 403 -curl -i --path-as-is 'http://127.0.0.1:8080/../etc/passwd' +Check traversal protection (the client must preserve the path): -# Missing Host header (raw HTTP) -> 400 -printf "GET / HTTP/1.1\r\n\r\n" | nc 127.0.0.1 8080 +```bash +curl -i --path-as-is http://127.0.0.1:8080/../etc/passwd ``` -Concurrency sanity checks (macOS) -```bash -# 5 parallel downloads (expect all 200) -jot 5 | xargs -P 5 -I{} curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/sample.txt +## Tests -# To see 503 under saturation, run server with few threads: -# python3 server.py 8080 127.0.0.1 2 -jot 100 | xargs -P 50 -I{} curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/large.png | sort | uniq -c +```bash +python3 -m unittest discover -s tests -v ``` -## Files I included -- HTML: index.html, about.html, contact.html -- Images: logo.png, photo.jpg, photo2.jpg, large.png (>1MB for big transfer tests) -- Text: sample.txt, sample2.txt -- Sample JSON: sample_upload.json (you can add more) -- uploads/ directory where the server writes POST results +## Project structure +```text +server.py Socket server, worker pool, parser, and handlers +resources/ Static demo files +resources/uploads/ Runtime JSON uploads (ignored by Git) +tests/ Dependency-free unit tests +``` +## Scope and limitations +- This is an educational server, not a production replacement for a hardened + HTTP server or reverse proxy. +- Request headers and bodies are capped at 8 KB; chunked transfer encoding, + TLS, range requests, and HTTP/2 are intentionally out of scope. +- Uploads are stored on the local filesystem and are not authenticated. +- The thread pool is process-local and has no graceful drain or multi-process + coordination. -That’s it, sir. I built it step by step, kept it clean and minimal, and verified each requirement with tests. +The original coursework attribution is preserved in Git history; this README +focuses on the engineering decisions a reviewer can verify in the repository. diff --git a/large.png b/large.png deleted file mode 100644 index 976002a..0000000 Binary files a/large.png and /dev/null differ diff --git a/out.txt b/out.txt deleted file mode 100644 index e69de29..0000000 diff --git a/photo.jpg b/photo.jpg deleted file mode 100644 index 68fc528..0000000 Binary files a/photo.jpg and /dev/null differ diff --git a/resources/uploads/.gitkeep b/resources/uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/resources/uploads/.gitkeep @@ -0,0 +1 @@ + diff --git a/resources/uploads/upload_20251010_011333_fb2d.json b/resources/uploads/upload_20251010_011333_fb2d.json deleted file mode 100644 index 3f3571f..0000000 --- a/resources/uploads/upload_20251010_011333_fb2d.json +++ /dev/null @@ -1 +0,0 @@ -{"hello":"world"} \ No newline at end of file diff --git a/resources/uploads/upload_20251010_013741_5ccc.json b/resources/uploads/upload_20251010_013741_5ccc.json deleted file mode 100644 index 13345ab..0000000 --- a/resources/uploads/upload_20251010_013741_5ccc.json +++ /dev/null @@ -1 +0,0 @@ -{"hello":"world123"} \ No newline at end of file diff --git a/resources/uploads/upload_20251010_014350_ac52.json b/resources/uploads/upload_20251010_014350_ac52.json deleted file mode 100644 index 2b776ad..0000000 --- a/resources/uploads/upload_20251010_014350_ac52.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "username": "john_doe", - "password": "securePass123", - "email": "john@example.com" -} \ No newline at end of file diff --git a/resources/uploads/upload_20251010_014500_89ff.json b/resources/uploads/upload_20251010_014500_89ff.json deleted file mode 100644 index 9d6113f..0000000 --- a/resources/uploads/upload_20251010_014500_89ff.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "username": "john_doe", - "password": "securePass123", - "email": "nis@example.com" - - -} \ No newline at end of file diff --git a/resources/uploads/upload_20251010_100721_ac95.json b/resources/uploads/upload_20251010_100721_ac95.json deleted file mode 100644 index b60e00d..0000000 --- a/resources/uploads/upload_20251010_100721_ac95.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "name": "Aarav Sharma", - "email": "aarav.sharma@example.com", - "password": "StrongPass123!", - "age": 25, - "phone": "+91-9876543210", - "address": { - "street": "12 MG Road", - "city": "Bangalore", - "state": "Karnataka", - "pincode": "560001" - } -} \ No newline at end of file diff --git a/resources/uploads/upload_20251010_121109_c6ef.json b/resources/uploads/upload_20251010_121109_c6ef.json deleted file mode 100644 index d1f1e12..0000000 --- a/resources/uploads/upload_20251010_121109_c6ef.json +++ /dev/null @@ -1 +0,0 @@ -{"nipun":"eifjoejf"} \ No newline at end of file diff --git a/resources/uploads/upload_20251010_144254_f5ad.json b/resources/uploads/upload_20251010_144254_f5ad.json deleted file mode 100644 index 914217e..0000000 --- a/resources/uploads/upload_20251010_144254_f5ad.json +++ /dev/null @@ -1 +0,0 @@ -{"nipun":"123"} \ No newline at end of file diff --git a/resources/uploads/upload_20251010_144751_d1c8.json b/resources/uploads/upload_20251010_144751_d1c8.json deleted file mode 100644 index a40d1a4..0000000 --- a/resources/uploads/upload_20251010_144751_d1c8.json +++ /dev/null @@ -1 +0,0 @@ -{"hello": "annnananaanan"} \ No newline at end of file diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..ba8eb7f --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,103 @@ +import os +import socket +import unittest + +import server + + +class RequestParsingTests(unittest.TestCase): + def test_parses_request_line_headers_and_body(self): + request = ( + b"POST /upload HTTP/1.1\r\n" + b"Host: 127.0.0.1:8080\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: 7\r\n\r\n" + b'{"x":1}' + ) + + method, path, version, headers, body = server.parse_request(request) + + self.assertEqual((method, path, version), ("POST", "/upload", "HTTP/1.1")) + self.assertEqual(headers["Content-Type"], "application/json") + self.assertEqual(body, b'{"x":1}') + + def test_rejects_request_without_header_terminator(self): + with self.assertRaises(ValueError): + server.parse_request(b"GET / HTTP/1.1\r\nHost: localhost") + + +class RoutingAndHeaderTests(unittest.TestCase): + def test_resolves_home_page_inside_resource_root(self): + path, error = server.resolve_request_path("/") + + self.assertIsNone(error) + self.assertEqual(os.path.basename(path), "index.html") + + def test_rejects_parent_directory_traversal(self): + path, error = server.resolve_request_path("/../server.py") + + self.assertIsNone(path) + self.assertEqual(error, 403) + + def test_missing_resource_returns_not_found(self): + path, error = server.resolve_request_path("/does-not-exist.html") + + self.assertIsNone(path) + self.assertEqual(error, 404) + + def test_html_is_rendered_and_images_are_downloaded(self): + html_headers, html_error = server.get_content_headers_for_path("index.html") + image_headers, image_error = server.get_content_headers_for_path("logo.png") + + self.assertIsNone(html_error) + self.assertEqual(html_headers["Content-Type"], "text/html; charset=utf-8") + self.assertIsNone(image_error) + self.assertEqual(image_headers["Content-Type"], "application/octet-stream") + self.assertIn("attachment", image_headers["Content-Disposition"]) + + def test_host_validation_accepts_configured_local_host(self): + original_host, original_port = server.CURRENT_HOST, server.CURRENT_PORT + try: + server.CURRENT_HOST = "127.0.0.1" + server.CURRENT_PORT = 8080 + valid, code, _ = server.validate_host_header({"Host": "localhost:8080"}) + finally: + server.CURRENT_HOST, server.CURRENT_PORT = original_host, original_port + + self.assertTrue(valid) + self.assertEqual(code, 200) + + +class ConnectionPolicyTests(unittest.TestCase): + def test_http_11_defaults_to_keep_alive(self): + self.assertEqual(server.choose_connection("HTTP/1.1", {}, 1), "keep-alive") + + def test_explicit_close_and_request_limit_close_connection(self): + self.assertEqual( + server.choose_connection("HTTP/1.1", {"Connection": "close"}, 1), + "close", + ) + self.assertEqual(server.choose_connection("HTTP/1.1", {}, server.KEEPALIVE_MAX), "close") + + def test_response_headers_include_length_and_status(self): + sender, receiver = socket.socketpair() + try: + server.send_headers( + sender, + 200, + {"Content-Type": "text/plain"}, + content_length=4, + connection="close", + ) + response = receiver.recv(4096).decode("utf-8") + finally: + sender.close() + receiver.close() + + self.assertIn("HTTP/1.1 200 OK", response) + self.assertIn("Content-Length: 4", response) + self.assertIn("Connection: close", response) + + +if __name__ == "__main__": + unittest.main()