Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.DS_Store

# Files created by POST /upload at runtime
resources/uploads/*
!resources/uploads/.gitkeep
Empty file removed 403
Empty file.
Empty file removed 404
Empty file.
178 changes: 71 additions & 107 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Binary file removed large.png
Binary file not shown.
Empty file removed out.txt
Empty file.
Binary file removed photo.jpg
Binary file not shown.
1 change: 1 addition & 0 deletions resources/uploads/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

1 change: 0 additions & 1 deletion resources/uploads/upload_20251010_011333_fb2d.json

This file was deleted.

1 change: 0 additions & 1 deletion resources/uploads/upload_20251010_013741_5ccc.json

This file was deleted.

5 changes: 0 additions & 5 deletions resources/uploads/upload_20251010_014350_ac52.json

This file was deleted.

7 changes: 0 additions & 7 deletions resources/uploads/upload_20251010_014500_89ff.json

This file was deleted.

13 changes: 0 additions & 13 deletions resources/uploads/upload_20251010_100721_ac95.json

This file was deleted.

1 change: 0 additions & 1 deletion resources/uploads/upload_20251010_121109_c6ef.json

This file was deleted.

1 change: 0 additions & 1 deletion resources/uploads/upload_20251010_144254_f5ad.json

This file was deleted.

1 change: 0 additions & 1 deletion resources/uploads/upload_20251010_144751_d1c8.json

This file was deleted.

103 changes: 103 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
@@ -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()
Loading