diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b3a3973..565eda4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,16 +1,14 @@ name: Build and Publish Docker Image on: - push: - branches: [ "main" ] - + release: + types: [created] env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: build: - runs-on: ubuntu-latest permissions: contents: read @@ -24,6 +22,14 @@ jobs: - name: Setup Docker buildx uses: docker/setup-buildx-action@79abd3f86f79a9d68a23c75a09a9a85889262adf + - name: Get Release Tag + run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV + + - name: Write Version + run: | + echo Building with version number $RELEASE_VERSION + echo $RELEASE_VERSION > src/build/version.txt + - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' uses: docker/login-action@28218f9b04b4f3f62068d7b6ce6ca5b26e35336c diff --git a/.gitignore b/.gitignore index 850d7f0..c8e9fe5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ anthracite src/error_pages/ +src/build/version.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index c150834..0bc2a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,69 @@ +# 0.2.0 Fifth Pre-Release +- Added true HTTP/1.1 support with persistent connections +- Added HTTP/1.0 vs HTTP/1.1 test to benchmarking suite +- Slight improvements to benchmarking +- Added version number information to binaries at build-time +- Added error page generation build step +- GitHub CI pipeline will now tag the version when publishing the container to the registry +- General system stability improvements were made to enhance the user's experience + +## HTTP/1.1 Speed improvements +The following benchmark shows the speed improvements created by implementing +persistent connections with HTTP/1.1. This test measures the time it takes to +request a 222 byte file 10,000 times by one user + +``` +=====[ Anthracite Benchmarking Tool ]===== +Requests : 10000 +Test : HTTP 1.0 vs HTTP 1.1 + + +HTTP/1.0 Total Time: 3.1160 seconds +HTTP/1.1 Total Time: 0.3621 seconds +``` + +## Benchmark Results +Each benchmark makes 1000 requests requesting a 50MB file using +100 users to the webserver running in a Docker container. + +This is a change from previous benchmarks which used a large html +file. + +``` +=====[ Anthracite Benchmarking Tool ]===== +Requests : 1000 +Users/Threads: 100 +Test : Load Test + + +====[ anthracite ]===== +Average Response Time: 19.5831 seconds +p995 Response Time : 38.9563 seconds +p99 Response Time : 37.1518 seconds +p90 Response Time : 27.5117 seconds +p75 Response Time : 21.4345 seconds +p50 Response Time : 17.7999 seconds +Total Response Time : 19583.1491 seconds +====[ nginx ]===== +Average Response Time: 19.5464 seconds +p995 Response Time : 49.9527 seconds +p99 Response Time : 47.5037 seconds +p90 Response Time : 29.7642 seconds +p75 Response Time : 21.4559 seconds +p50 Response Time : 17.1338 seconds +Total Response Time : 19546.4399 seconds +====[ apache ]===== +Average Response Time: 20.8133 seconds +p995 Response Time : 42.5797 seconds +p99 Response Time : 39.8580 seconds +p90 Response Time : 30.1892 seconds +p75 Response Time : 22.3492 seconds +p50 Response Time : 19.0437 seconds +Total Response Time : 20813.3035 seconds +========== +Total Test Time : 612.3112 seconds +``` + # 0.1.2 Fourth Pre-Release - Fixed bug with mapping / to index.html diff --git a/README.md b/README.md index 7565e61..d53b17a 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,28 @@ # Anthracite -A simple web server written in C++ +A simple web server written in C++. Supports HTTP 1.0 & 1.1. -## Module-Based Backends -Anthracite includes (read: will include) system for allowing different "backend modules" to handle requests. -This allows for anthracite to be extended for additional use-cases. For example, the following -backends could be implemented: +## Developing -- File: Return files from a directory -- Reverse Proxy: Pass the request to another server -- Web Framework: Pass the request into an application built on your favorite web framework +To build/develop Anthracite, you must have C++20, Make, and Python3 installed. -## Building & Debugging - -Once you have the repository cloned, you can run the following command to build Anthracite: - -```shell -make build -``` - -It will create a binary file named `./anthracite` - -To save time, you can use the following command to build and run anthracite on port `8080`: - -```shell -make run -``` - -To save time again, you can use the following command to build and debug anthracite in gdb: - -```shell -make debug -``` - -## Usage - -Run the following commands to serve all files located in `./www/`: - -```shell -./anthracite [PORT_NUMBER] -``` +You can run Anthracite with: `make run` ## Todo +- [x] HTTP/1.0 - [x] Serve HTML Pages - [x] Properly parse HTTP requests - [x] Add module-based backend system for handling requests - [x] Multithreading -- [ ] Cleanup (this one will never truly be done) -- [ ] Proper error handling -- [ ] Build out module-based backend system for handling requests -- [ ] Fix glaring security issues +- [x] HTTP/1.1 +- [ ] Improve benchmarking infrastructure - [ ] Faster parsing -- [ ] Speed optimizations such as keeping the most visited html pages in memory -- [ ] Cleanup codebase -- [ ] Enable cache support -- [ ] Support newer HTTP versions +- [ ] Fix glaring security issues +- [ ] Proper error handling +- [ ] User configuration +- [ ] Build out module-based backend system for handling requests +- [ ] HTTP/2 +- [ ] Enhance logging +- [ ] Cleanup (this one will never truly be done) ## Screenshots diff --git a/benchmark/rebuild-container.sh b/benchmark/rebuild-container.sh deleted file mode 100755 index 9cac615..0000000 --- a/benchmark/rebuild-container.sh +++ /dev/null @@ -1,5 +0,0 @@ -cd .. -docker build . -t anthracite:latest -cd benchmark -docker build . -t benchmark-anthracite -f anthracite.Dockerfile -docker compose up -d diff --git a/benchmark/www/large.html b/benchmark/www/large.html deleted file mode 100644 index cb6730d..0000000 --- a/benchmark/www/large.html +++ /dev/null @@ -1,1020251 +0,0 @@ - - diff --git a/benchmark/anthracite.Dockerfile b/benchmarks/http_1_v_11/anthracite.Dockerfile similarity index 100% rename from benchmark/anthracite.Dockerfile rename to benchmarks/http_1_v_11/anthracite.Dockerfile diff --git a/benchmarks/http_1_v_11/benchmark.py b/benchmarks/http_1_v_11/benchmark.py new file mode 100644 index 0000000..fdb468f --- /dev/null +++ b/benchmarks/http_1_v_11/benchmark.py @@ -0,0 +1,36 @@ +import socket +import time + +num_requests = 10000 + +http_1_times = [] +http_11_times = [] + +print('=====[ Anthracite Benchmarking Tool ]=====') +print(f'Requests : {num_requests}') +print(f'Test : HTTP 1.0 vs HTTP 1.1\n\n') + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect(("localhost" , 8091)) + for i in range(num_requests): + start_time = time.time() + s.sendall(b"GET /test.html HTTP/1.1\r\nAccept: text/html\r\nConnection: keep-alive\r\n\r\n") + data = s.recv(220) + end_time = time.time() + http_11_times.append((end_time - start_time)) + s.close() + +for i in range(num_requests): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + start_time = time.time() + s.connect(("localhost" , 8091)) + s.sendall(b"GET /test.html HTTP/1.0\r\nAccept: text/html\r\n\r\n") + data = s.recv(220) + end_time = time.time() + http_1_times.append((end_time - start_time)) + s.close() + +run_time_1 = sum(http_1_times) +run_time_11 = sum(http_11_times) +print(f'HTTP/1.0 Total Time: {run_time_1:.4f} seconds') +print(f'HTTP/1.1 Total Time: {run_time_11:.4f} seconds') diff --git a/benchmarks/http_1_v_11/docker-compose.yaml b/benchmarks/http_1_v_11/docker-compose.yaml new file mode 100644 index 0000000..b9b748c --- /dev/null +++ b/benchmarks/http_1_v_11/docker-compose.yaml @@ -0,0 +1,7 @@ +services: + anthracite: + build: + context: . + dockerfile: anthracite.Dockerfile + ports: + - "8091:80" diff --git a/benchmarks/http_1_v_11/rebuild-container.sh b/benchmarks/http_1_v_11/rebuild-container.sh new file mode 100755 index 0000000..f066005 --- /dev/null +++ b/benchmarks/http_1_v_11/rebuild-container.sh @@ -0,0 +1,4 @@ +cd ../.. +docker build . -t anthracite:latest +cd benchmarks/http_1_v_11 +docker compose build diff --git a/benchmarks/http_1_v_11/run.sh b/benchmarks/http_1_v_11/run.sh new file mode 100755 index 0000000..82cff58 --- /dev/null +++ b/benchmarks/http_1_v_11/run.sh @@ -0,0 +1 @@ +docker-compose stop && ./rebuild-container.sh && docker compose up -d && clear && python3 benchmark.py && docker-compose stop diff --git a/benchmarks/http_1_v_11/www/test.html b/benchmarks/http_1_v_11/www/test.html new file mode 100644 index 0000000..3a5086d --- /dev/null +++ b/benchmarks/http_1_v_11/www/test.html @@ -0,0 +1,2 @@ +

Anthracite Benchmarking

+

Test document to test the speed of HTTP/1.0 and HTTP/1.1

diff --git a/benchmarks/load_test/anthracite.Dockerfile b/benchmarks/load_test/anthracite.Dockerfile new file mode 100644 index 0000000..69bb295 --- /dev/null +++ b/benchmarks/load_test/anthracite.Dockerfile @@ -0,0 +1,2 @@ +FROM anthracite:latest +COPY ./www/ /www/ diff --git a/benchmark/apache.Dockerfile b/benchmarks/load_test/apache.Dockerfile similarity index 100% rename from benchmark/apache.Dockerfile rename to benchmarks/load_test/apache.Dockerfile diff --git a/benchmark/benchmark.py b/benchmarks/load_test/benchmark.py similarity index 91% rename from benchmark/benchmark.py rename to benchmarks/load_test/benchmark.py index d5a6fdd..c502dea 100644 --- a/benchmark/benchmark.py +++ b/benchmarks/load_test/benchmark.py @@ -5,7 +5,7 @@ from concurrent.futures import ThreadPoolExecutor from http.client import HTTPConnection HTTPConnection._http_vsn_str = 'HTTP/1.0' -urls = { 'anthracite': 'http://localhost:8081/large.html','nginx': 'http://localhost:8082/large.html', 'apache': 'http://localhost:8083/large.html' } +urls = { 'anthracite': 'http://localhost:8081/50MB.zip','nginx': 'http://localhost:8082/50MB.zip', 'apache': 'http://localhost:8083/50MB.zip' } num_requests = 1000 num_users = 100 # number of threads response_times = {} @@ -37,7 +37,8 @@ def make_request(request_number, server_name): print('=====[ Anthracite Benchmarking Tool ]=====') print(f'Requests : {num_requests}') -print(f'Users/Threads: {num_users}\n\n') +print(f'Users/Threads: {num_users}') +print(f'Test : Load Test\n\n') start_all_time = time.time() futures = [] diff --git a/benchmark/docker-compose.yaml b/benchmarks/load_test/docker-compose.yaml similarity index 100% rename from benchmark/docker-compose.yaml rename to benchmarks/load_test/docker-compose.yaml diff --git a/benchmark/nginx.Dockerfile b/benchmarks/load_test/nginx.Dockerfile similarity index 100% rename from benchmark/nginx.Dockerfile rename to benchmarks/load_test/nginx.Dockerfile diff --git a/benchmarks/load_test/rebuild-container.sh b/benchmarks/load_test/rebuild-container.sh new file mode 100755 index 0000000..311ccff --- /dev/null +++ b/benchmarks/load_test/rebuild-container.sh @@ -0,0 +1,5 @@ +cd ../.. +docker build . -t anthracite:latest +cd benchmarks/load_test +docker compose build +docker compose up -d diff --git a/benchmarks/load_test/run.sh b/benchmarks/load_test/run.sh new file mode 100755 index 0000000..82cff58 --- /dev/null +++ b/benchmarks/load_test/run.sh @@ -0,0 +1 @@ +docker-compose stop && ./rebuild-container.sh && docker compose up -d && clear && python3 benchmark.py && docker-compose stop diff --git a/benchmarks/load_test/www/50MB.zip b/benchmarks/load_test/www/50MB.zip new file mode 100644 index 0000000..a03466a Binary files /dev/null and b/benchmarks/load_test/www/50MB.zip differ diff --git a/src/Makefile b/src/Makefile index d4409a7..f22c205 100644 --- a/src/Makefile +++ b/src/Makefile @@ -1,7 +1,7 @@ .PHONY: format lint build build-release build-docker run debug build: - python3 ./error_gen.py + cd ./build && ./version.sh && python3 ./error_gen.py g++ main.cpp --std=c++20 -g -o ./anthracite build-release: @@ -19,6 +19,9 @@ run-test: build debug: build gdb --args ./anthracite 8080 +debug-test: build + gdb --args ./anthracite 8080 ./test_www + format: clang-format *.cpp -i diff --git a/src/backends/file_backend.cpp b/src/backends/file_backend.cpp index 9deeae2..e20ae8c 100644 --- a/src/backends/file_backend.cpp +++ b/src/backends/file_backend.cpp @@ -1,11 +1,12 @@ #include +#include #include "backend.cpp" class file_backend : public backend { private: std::unordered_map file_cache; std::string file_dir; - + std::unique_ptr handle_request_cache(http_request& req) { std::string filename = req.path() == "/" ? "/index.html" : req.path(); filename = file_dir + filename; @@ -13,9 +14,7 @@ private: int status = http_status_codes::OK; if (file_info == file_cache.end()) { - status = http_status_codes::NOT_FOUND; - filename = "./error_pages/404.html"; - file_info = file_cache.find(filename); + return handle_error(http_status_codes::NOT_FOUND); } return std::make_unique(file_info->second, filename, status); @@ -47,9 +46,22 @@ public: populate_cache(); } - ~file_backend() = default; std::unique_ptr handle_request(http_request& req) override { return handle_request_cache(req); } + + std::unique_ptr handle_error(const http_status_codes& error) { + std::string filename = "./error_pages/" + std::to_string(error) + ".html"; + auto file_info = file_cache.find(filename); + + http_status_codes status = error; + if (file_info == file_cache.end()) { + status = http_status_codes::NOT_FOUND; + filename = "./error_pages/404.html"; + file_info = file_cache.find(filename); + } + + return std::make_unique(file_info->second, filename, status); + } }; diff --git a/src/error_gen.py b/src/build/error_gen.py similarity index 87% rename from src/error_gen.py rename to src/build/error_gen.py index 31a8d99..e1ece89 100644 --- a/src/error_gen.py +++ b/src/build/error_gen.py @@ -3,16 +3,18 @@ import os -version = "0.1.5" +version = "Unknown" -def generate_error_page(error_code, error_title, error_description): +with open("version.txt", "r") as file: + version = file.read().strip() + +def generate_error_page(error_code, error_title): html = f""" {error_title}

{error_code} - {error_title}


-

{error_description}

Anthracite/{version}

This is Open Source Software

@@ -64,12 +66,11 @@ error_codes = { } -error_dir = './error_pages' +error_dir = '../error_pages' os.makedirs(error_dir, exist_ok=True) for code, title in error_codes.items(): - error_description = error_codes[code] - error_page = generate_error_page(code, title, error_description) + error_page = generate_error_page(code, title) file_path = os.path.join(error_dir, f"{code}.html") with open(file_path, "w") as file: file.write(error_page) diff --git a/src/build/version.sh b/src/build/version.sh new file mode 100755 index 0000000..36c2f17 --- /dev/null +++ b/src/build/version.sh @@ -0,0 +1,3 @@ +echo "#include " > version.cpp +echo "const std::string ANTHRACITE_VERSION_STRING = \"$(cat version.txt)\";" >> version.cpp +echo "const std::string ANTHRACITE_FULL_VERSION_STRING = \"Anthracite/$(cat version.txt)\";" >> version.cpp diff --git a/src/build/version.txt b/src/build/version.txt new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/src/build/version.txt @@ -0,0 +1 @@ +0.2.0 diff --git a/src/http/constants.cpp b/src/http/constants.cpp index 647df48..43880fe 100644 --- a/src/http/constants.cpp +++ b/src/http/constants.cpp @@ -219,6 +219,8 @@ enum http_version { HTTP_0_9, HTTP_3_0 }; static std::unordered_map const http_version_map = { + // This is because HTTP 0.9 didn't specify version in the header + { "", HTTP_0_9 }, { "HTTP/0.9", HTTP_0_9 }, { "HTTP/1.0", HTTP_1_0 }, { "HTTP/1.1", HTTP_1_1 }, diff --git a/src/http/header_query.cpp b/src/http/header_query.cpp index aa530e1..eb37f5e 100644 --- a/src/http/header_query.cpp +++ b/src/http/header_query.cpp @@ -20,8 +20,8 @@ public: name_value(name_value&&) = default; name_value& operator=(name_value&&) = default; - std::string name() { return _name; } - std::string value() { return _value; } + std::string& name() { return _name; } + std::string& value() { return _value; } virtual std::string to_string() { return ""; } }; diff --git a/src/http/http.hpp b/src/http/http.hpp index 9ae4652..bba2f32 100644 --- a/src/http/http.hpp +++ b/src/http/http.hpp @@ -5,3 +5,4 @@ #include "constants.cpp" #include "header_query.cpp" #include "../socket.cpp" +#include "../build/version.cpp" diff --git a/src/http/http_request.cpp b/src/http/http_request.cpp index ce4ff67..1e9fd71 100644 --- a/src/http/http_request.cpp +++ b/src/http/http_request.cpp @@ -19,10 +19,9 @@ private: std::unordered_map _query_params; // kinda goofy, whatever public: - http_request(anthracite_socket& s) - : _path(""), _client_ipaddr(s.get_client_ip()) + http_request(std::string& raw_data, std::string client_ip) + : _path(""), _client_ipaddr(client_ip) { - std::string raw_data = s.recv_message(HTTP_HEADER_BYTES); parser_state state = METHOD; @@ -136,6 +135,25 @@ public: std::string client_ip() { return _client_ipaddr; } + http_version get_http_version() { + return _http_version; + } + + bool is_supported_version() { + return _http_version == HTTP_1_1 || _http_version == HTTP_1_0; + } + + bool close_connection() { + const auto& header = _headers.find("Connection"); + const bool found = header != _headers.end(); + + if(found && header->second.value() == "keep-alive") { + return false; + } + + return true; + } + std::string to_string() { std::string response = ""; diff --git a/src/http/http_response.cpp b/src/http/http_response.cpp index 423cc1f..eaa1cf8 100644 --- a/src/http/http_response.cpp +++ b/src/http/http_response.cpp @@ -32,17 +32,18 @@ public: std::string header_to_string() { std::string response = ""; - response += "HTTP/1.0 " + std::to_string(_status_code) + " " + http_status_map.find(_status_code)->second + "\r\n"; + response += "HTTP/1.1 " + std::to_string(_status_code) + " " + http_status_map.find(_status_code)->second + "\r\n"; std::string content_type = "text/html"; std::string file_extension = _filename.substr(_filename.rfind('.') + 1); auto mime_type = mime_types.find(file_extension); if (mime_type != mime_types.end()) { content_type = mime_type->second; } + add_header(http_header("Content-Type", content_type), false); add_header(http_header("Content-Length", std::to_string(_content.length())), false); - add_header(http_header("Server", "Anthracite/0.0.1"), false); - add_header(http_header("Origin-Server", "Anthracite/0.0.1"), false); + add_header(http_header("Server", ANTHRACITE_FULL_VERSION_STRING), false); + add_header(http_header("Origin-Server", ANTHRACITE_FULL_VERSION_STRING), false); for (auto header : _headers) { response += header.second.to_string(); diff --git a/src/main.cpp b/src/main.cpp index b109e85..b58ee00 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -17,15 +17,24 @@ void log_request_and_response(http_request& req, std::unique_ptr& constexpr int default_port = 80; constexpr int max_worker_threads = 128; -void handle_client(anthracite_socket s, backend& b, std::mutex& thread_wait_mutex, std::condition_variable& thread_wait_condvar, int& active_threads) +void handle_client(anthracite_socket s, backend& b, file_backend& fb, std::mutex& thread_wait_mutex, std::condition_variable& thread_wait_condvar, int& active_threads) { - http_request req(s); - std::unique_ptr resp = b.handle_request(req); - log_request_and_response(req, resp); - std::string header = resp->header_to_string(); - s.send_message(header); - s.send_message(resp->content()); - resp.reset(); + while(true) { + std::string raw_request = s.recv_message(HTTP_HEADER_BYTES); + if(raw_request == "") { + break; + } + http_request req(raw_request, s.get_client_ip()); + std::unique_ptr resp = req.is_supported_version() ? b.handle_request(req) : fb.handle_error(http_status_codes::HTTP_VERSION_NOT_SUPPORTED); + log_request_and_response(req, resp); + std::string header = resp->header_to_string(); + s.send_message(header); + s.send_message(resp->content()); + resp.reset(); + if(req.close_connection()) { + break; + } + } s.close_conn(); { std::lock_guard lock(thread_wait_mutex); @@ -58,7 +67,7 @@ int main(int argc, char** argv) std::unique_lock lock(thread_wait_mutex); thread_wait_condvar.wait(lock, [active_threads] { return active_threads < max_worker_threads; }); active_threads++; - std::thread(handle_client, s, std::ref(fb), std::ref(thread_wait_mutex), std::ref(thread_wait_condvar), std::ref(active_threads)).detach(); + std::thread(handle_client, s, std::ref(fb), std::ref(fb), std::ref(thread_wait_mutex), std::ref(thread_wait_condvar), std::ref(active_threads)).detach(); } exit(0); diff --git a/src/socket.cpp b/src/socket.cpp index a41d80a..b19b24c 100644 --- a/src/socket.cpp +++ b/src/socket.cpp @@ -8,6 +8,7 @@ #include #include #include +#include constexpr int MAX_QUEUE_LENGTH = 100; @@ -70,8 +71,17 @@ public: return ""; } + struct timeval tv; + tv.tv_sec = 5; + tv.tv_usec = 0; + setsockopt(client_socket, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof tv); char response[buffer_size + 1]; - recv(client_socket, response, sizeof(response), 0); + int result = recv(client_socket, response, sizeof(response), 0); + + if (result < 1) { + return ""; + } + response[buffer_size] = '\0'; return std::string(response); }