├── .clang-format ├── .gitignore ├── Dockerfile ├── README.md ├── configs ├── acme.joml ├── environment-vars.joml ├── header-editing.joml ├── limits.joml ├── minimal.joml ├── multisite.joml ├── routes.joml └── tls.joml ├── meson.build ├── meson_options.txt ├── notes └── async_certificate_loading │ ├── .gitignore │ ├── async.sh │ ├── load_cert_async.cpp │ ├── load_cert_sync.cpp │ ├── notes.md │ └── sync.sh ├── scripts ├── bench.sh ├── cert.cfg ├── create_cert.sh └── docker-run.sh ├── src ├── acme.cpp ├── acme.hpp ├── client.cpp ├── client.hpp ├── config.cpp ├── config.hpp ├── events.cpp ├── events.hpp ├── fd.cpp ├── fd.hpp ├── filecache.cpp ├── filecache.hpp ├── filewatcher.cpp ├── filewatcher.hpp ├── function.hpp ├── hosthandler.cpp ├── hosthandler.hpp ├── htcpp.cpp ├── http.cpp ├── http.hpp ├── ioqueue.cpp ├── ioqueue.hpp ├── libexample.cpp ├── log.cpp ├── log.hpp ├── lrucache.hpp ├── metrics.cpp ├── metrics.hpp ├── mpscqueue.hpp ├── pattern.cpp ├── pattern.hpp ├── result.hpp ├── router.cpp ├── router.hpp ├── server.cpp ├── server.hpp ├── slotmap.hpp ├── ssl.cpp ├── ssl.hpp ├── string.cpp ├── string.hpp ├── tcp.cpp ├── tcp.hpp ├── time.cpp ├── time.hpp ├── tokenbucket.cpp ├── tokenbucket.hpp ├── util.cpp ├── util.hpp └── vectormap.hpp ├── subprojects ├── clipp.wrap ├── cpprom.wrap ├── joml-cpp.wrap ├── liburingpp.wrap └── minijson.wrap ├── testfiles ├── cat.jpg ├── lorem_ipsum.txt └── lorem_ipsum_large.txt ├── tests └── rate_limiting │ ├── config.joml │ └── test_rate_limiting.py └── unittests ├── main.cpp ├── test.hpp └── time.cpp /.clang-format: -------------------------------------------------------------------------------- 1 | BasedOnStyle: WebKit # [LLVM, Google, Chromium, Mozilla, WebKit, Microsoft] 2 | ColumnLimit: 100 3 | Standard: c++17 4 | AllowShortFunctionsOnASingleLine: Inline 5 | AlwaysBreakTemplateDeclarations: true 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .clangd/ 2 | .DS_Store 3 | .vscode/ 4 | build/ 5 | compile_commands.json 6 | untracked/ 7 | subprojects/* 8 | !subprojects/*.wrap 9 | *.pem 10 | benchmarks/ 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:22.04 as builder 2 | 3 | RUN apt-get update && apt-get install --yes \ 4 | clang \ 5 | git \ 6 | libssl-dev \ 7 | meson \ 8 | ninja-build \ 9 | pkg-config \ 10 | && true 11 | WORKDIR /build/ 12 | COPY src /build/src/ 13 | COPY meson.build meson_options.txt /build/ 14 | COPY subprojects /build/subprojects/ 15 | RUN meson setup -Dbuild_libexample=false -Dbuild_unittests=false build/ 16 | RUN meson configure \ 17 | -Dbuildtype=release \ 18 | -Db_lto=true \ 19 | -Dclipp:default_library=static \ 20 | -Dcpprom:default_library=static \ 21 | -Djoml-cpp:default_library=static \ 22 | -Dliburingpp:default_library=static \ 23 | build/ 24 | RUN meson compile -C build/ 25 | 26 | FROM ubuntu:22.04 AS runtime 27 | COPY --from=builder /build/build/htcpp /usr/local/bin/ 28 | ENTRYPOINT ["htcpp"] 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # htcpp 2 | 3 | A HTTP/1.1 server using [io_uring](https://en.wikipedia.org/wiki/Io_uring) built with C++17. It's single-threaded and all network IO and inotify usage is asynchronous. 4 | 5 | Currently it has the following features: 6 | * The `htcpp` executable is a file server that serves a specified directory (or multiple) 7 | * Can also be used as a library with a [Router](src/router.hpp) like many popular web frameworks (see example in [libexample.cpp](src/libexample.cpp)) 8 | * Host multiple sites on different ports or for different `Host` headers 9 | * Persistent Connections (it doesn't support pipelining though, because no one does) 10 | * Caches files and watches them using inotify to reload them automatically if they change on disk 11 | * TLS with automatic reloading of certificate chain or private key if they change on disk 12 | * A built-in ACME client and semi-automatic (some configuration required) HTTPS via [Let's Encrypt](https://letsencrypt.org), like [Caddy](https://caddyserver.com) 13 | * Built-in [Prometheus](https://prometheus.io/)-compatible metrics using [cpprom](https://github.com/pfirsich/cpprom/) 14 | * The only dependency that is not another project of mine is OpenSSL (of course exclusing the Linux Kernel, glibc and the standard library). 15 | * [JOML](https://github.com/pfirsich/joml) configuration files ([examples](./configs)) 16 | * `ETag` and `Last-Modified` headers and support for `If-None-Match` and `If-Modified-Since` 17 | * Header Editing Rules ([header-editing.joml](./configs/header-editing.joml)) 18 | * IP rate limiting and limiting the number of concurrent connections ([limits.joml](./configs/limits.joml)) 19 | 20 | It requires io_uring features that are available since kernel 5.11, so it will exit immediately on earlier kernels. 21 | 22 | Also if submission queue polling (config: `io_submission_queue_polling` (boolean)) is enabled, which it is by default, htcpp needs to run as root or it needs the `CAP_SYS_NICE` capability. 23 | 24 | ## Building 25 | Install [meson](https://mesonbuild.com/). 26 | 27 | Execute the following commands: 28 | ```shell 29 | meson setup build/ 30 | meson compile -C build 31 | ``` 32 | 33 | If OpenSSL can be found during the build, TLS support is automatically enabled. The build will fail for OpenSSL versions earlier than `1.1.1`. 34 | 35 | ## Docker 36 | Alternatively you can build a Docker container: 37 | ```shell 38 | meson subprojects download # initial build 39 | meson subprojects update # subsequent builds 40 | docker build --tag htcpp . 41 | ``` 42 | Adjust the tag to whatever you prefer. 43 | 44 | Then run it like this: 45 | ``` 46 | docker run --init --network=host htcpp 47 | ``` 48 | The `--init` is necessary for the container to terminate gracefully. You can replace `--network=host` with an explicit port forwarding, but host networking gives better performance. 49 | 50 | If you wish to use the ACME client, make sure to install root certificates in your image to allow HTTPS requests to the ACME directory (e.g. Let's Encrypt). On Ubuntu for example the corresponding package is called `ca-certificates`. 51 | 52 | ## To Do (Must) 53 | * Finish todos in [aiopp](https://github.com/pfirsich/aiopp) and then remove stuff from this repository and use aiopp instead. 54 | 55 | ## To Do (Should) 56 | * Try to implement as much as possible described in this document: https://github.com/axboe/liburing/wiki/io_uring-and-networking-in-2023. I need to wait a while for most of it to arrive in my distro kernel. 57 | * Improve behaviour in case of DDos (esp. in conjunction with Cloudflare DDoS protection) - from here: https://fasterthanli.me/articles/i-won-free-load-testing (great post!) 58 | - Only parse request line to determine if a handler exists then respond 404/405 and close as soon as possible (avoid big bogus POSTs eating up bandwidth). 59 | * TLS SNI (then move `tls` object into `hosts`) 60 | * Currently the response body is copied from the response object (argument to respond) to the responseBuffer before sending. Somehow avoid this copy. (send header and body separately?). 61 | * Split off the library part better, so htcpp can actually be used as a library cleanly 62 | * If no metrics are defined, do not pay for it at all (no .labels(), not counting - mock it?) 63 | * URL percent decoding (since I only save Url::path and saving a decoded path component in there would simply make it incorrect, it is the router that has to be percent-encoding aware) 64 | * Directory Listings 65 | * Optionally use MD5/SHA1 for ETag 66 | * Add some tests 😬 (maybe have a Python script run the server with certain configs and test responses) 67 | * Test with certbot: Now that I have reloading of certificates and I can configure multiple sites (to host `.well-known/acme-challenge` on port 80), I think I have everything that I need. 68 | 69 | ## To Do (Could) 70 | * Large file transfer (with `sendfile` or `slice`) 71 | - Partial Content ([Range](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)) 72 | * Reverse proxy mode 73 | - Load-Balancing 74 | - Maybe pull load-balancing too! (see discussion here: https://news.ycombinator.com/item?id=35588797) 75 | * IPv6 76 | * Use coroutines instead of callbacks! 77 | * Customizable access log: Have the option to include some request headers, like Referer or User-Agent 78 | * LuaJIT for scripting dynamic websites 79 | * Request pool/arena allocator (only allocate a big buffer once per request and use it as the backing memory for an arena allocator) 80 | * Signal handling so it works better in Docker (just use `--init` for now) 81 | * Make file reading asynchronous (there are a bunch of problem with this though) 82 | * Include hosts from other files 83 | * Configure MIME Types in config 84 | 85 | ## Won't Do (for now?) 86 | * Compression: Afaik you are supposed to disable it for images and other already compressed assets (obviously), but since I only plan to serve small HTML pages with this, there is not much use. 87 | * Support for kTLS: It's probably a good performance improvement, but quite involved and I don't need it. 88 | * Dispatch HTTP sessions to a thread pool (to increase TLS performance): I will likely only deploy this on very small single vCPU VMs. Note for the future: have a ring per thread and `accept` on the same listening socket on all threads. 89 | * chroot "jail": According to the man page you should not use these for security, so if you want filesystem isolation, use Docker 90 | * Dropping privileges: Not hard to do, but the same applies. If you want to deploy it securely, use Docker. 91 | -------------------------------------------------------------------------------- /configs/acme.joml: -------------------------------------------------------------------------------- 1 | acme: { 2 | "theshoemaker.de": { 3 | url: "letsencrypt-staging" # magic 4 | alt_names: ["www.theshoemaker.de"] # empty by default 5 | } 6 | } 7 | 8 | services: { 9 | "0.0.0.0:80": { 10 | hosts: { 11 | "theshoemaker.de": { 12 | acme_challenges: "theshoemaker.de" 13 | redirects: { 14 | "/*": "https://theshoemaker.de/$1" 15 | } 16 | } 17 | 18 | "www.theshoemaker.de": { 19 | redirects: { 20 | "/*": "http://theshoemaker.de/$1" 21 | } 22 | } 23 | } 24 | } 25 | 26 | "0.0.0.0:443": { 27 | tls: { 28 | acme: "theshoemaker.de" # reference to acme object 29 | } 30 | 31 | hosts: { 32 | "theshoemaker.de": { 33 | files: "." # everything is missing here 34 | } 35 | 36 | "www.theshoemaker.de": { 37 | redirects: { 38 | "/*": "https://theshoemaker.de/$1" 39 | } 40 | } 41 | } 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /configs/environment-vars.joml: -------------------------------------------------------------------------------- 1 | services: { 2 | "0.0.0.0:${PORT:6969}": { 3 | hosts: { 4 | "*": { 5 | files: "." 6 | 7 | headers: { 8 | "*": { 9 | # If no default value is specified, htcpp will log an error 10 | # and exit if no corresponding environment value is set 11 | "Cache-Control": "${CACHE_CONTROL}" 12 | } 13 | } 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /configs/header-editing.joml: -------------------------------------------------------------------------------- 1 | services: { 2 | "0.0.0.0:6969": { 3 | hosts: { 4 | "*": { 5 | files: "." 6 | 7 | headers: { 8 | "*": { 9 | # An empty header is the same as a non-existant header, so an empty string will remove it. 10 | # By default, the "Server" header is included (it's value is "htcpp") 11 | "Server": "" 12 | } 13 | # There is a fast path for checking a suffix, which only works with "*{.html,.txt}" and not 14 | # with "*.{html,txt}", which is why it is used here. 15 | "*{.html,.txt}": { 16 | "Cache-Control": "no-store" 17 | } 18 | "*{.jpg}": { 19 | "Cache-Control": "public, max-age=1" 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /configs/limits.joml: -------------------------------------------------------------------------------- 1 | services: { 2 | "0.0.0.0:6969": { 3 | access_log: true 4 | limit_connections: 512 5 | limit_requests_by_ip: { 6 | steady_rate: 5 7 | burst_size: 50 8 | max_num_entries: 8192 9 | } 10 | hosts: { 11 | "*": { 12 | files: "." 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /configs/minimal.joml: -------------------------------------------------------------------------------- 1 | services: { 2 | "0.0.0.0:6969": { 3 | hosts: { 4 | "*": { 5 | files: "." 6 | } 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /configs/multisite.joml: -------------------------------------------------------------------------------- 1 | services: { 2 | "0.0.0.0:6969": { 3 | hosts: { 4 | "src.localhost": { 5 | files: "src/" 6 | } 7 | "scripts.localhost": { 8 | files: "scripts/" 9 | } 10 | } 11 | } 12 | 13 | "0.0.0.0:6970": { 14 | hosts: { 15 | "*": { 16 | metrics: "/" 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /configs/routes.joml: -------------------------------------------------------------------------------- 1 | services: { 2 | "0.0.0.0:6969": { 3 | hosts: { 4 | "*": { 5 | files: { 6 | # These are matched top to bottom 7 | "/": "testfiles/index.html" 8 | "/txt/*": "testfiles/$1.txt" 9 | "/cat": "testfiles/cat.jpg" 10 | # The rule below is equivalent to: "/*": "testfiles/$1" and will be internally translated 11 | # as such. You can see this by passing --debug. 12 | "/": "testfiles/" 13 | } 14 | } 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /configs/tls.joml: -------------------------------------------------------------------------------- 1 | services: { 2 | "0.0.0.0:6969": { 3 | tls: { 4 | chain: "cert.pem" 5 | key: "key.pem" 6 | } 7 | 8 | hosts: { 9 | "*": { 10 | files: "." 11 | } 12 | } 13 | } 14 | 15 | "0.0.0.0:6970": { 16 | hosts: { 17 | "*": { 18 | metrics: "/" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('htcpp', 'cpp', default_options : ['warning_level=3', 'cpp_std=c++20']) 2 | 3 | threads_dep = dependency('threads') 4 | openssl_dep = dependency('openssl', required : false) 5 | 6 | clipp_dep = dependency('clipp', fallback : ['clipp', 'clipp_dep']) 7 | cpprom_dep = dependency('cpprom', fallback : ['cpprom', 'cpprom_dep'], default_options : [ 'single_threaded=true' ]) 8 | joml_cpp_dep = dependency('joml-cpp', fallback : ['joml-cpp', 'joml_cpp_dep']) 9 | liburingpp_dep = dependency('liburingpp', fallback : ['liburingpp', 'liburingpp_dep']) 10 | minijson_dep = dependency('minijson', fallback : ['minijson', 'minijson_dep']) 11 | 12 | flags = [] 13 | 14 | lib_src = [ 15 | 'src/client.cpp', 16 | 'src/events.cpp', 17 | 'src/fd.cpp', 18 | 'src/filecache.cpp', 19 | 'src/filewatcher.cpp', 20 | 'src/http.cpp', 21 | 'src/ioqueue.cpp', 22 | 'src/log.cpp', 23 | 'src/metrics.cpp', 24 | 'src/pattern.cpp', 25 | 'src/router.cpp', 26 | 'src/server.cpp', 27 | 'src/string.cpp', 28 | 'src/tcp.cpp', 29 | 'src/tokenbucket.cpp', 30 | 'src/time.cpp', 31 | 'src/util.cpp', 32 | ] 33 | 34 | if openssl_dep.found() 35 | lib_src += [ 36 | 'src/acme.cpp', 37 | 'src/ssl.cpp', 38 | ] 39 | flags += '-DTLS_SUPPORT_ENABLED' 40 | endif 41 | 42 | htcpp_lib_deps = [ 43 | threads_dep, 44 | openssl_dep, 45 | cpprom_dep, 46 | liburingpp_dep, 47 | minijson_dep, 48 | ] 49 | 50 | # This is not really clean, but it's a start 51 | htcpp_inc = include_directories('src') 52 | htcpp_lib = static_library('htcpp', lib_src, 53 | cpp_args : flags, 54 | include_directories : htcpp_inc, 55 | dependencies : htcpp_lib_deps, 56 | ) 57 | htcpp_dep = declare_dependency( 58 | compile_args : flags, 59 | include_directories : htcpp_inc, 60 | link_with : htcpp_lib, 61 | # Not quite sure why I need this: https://github.com/mesonbuild/meson/issues/10543 62 | dependencies : htcpp_lib_deps, 63 | ) 64 | 65 | bin_src = [ 66 | 'src/config.cpp', 67 | 'src/hosthandler.cpp', 68 | 'src/htcpp.cpp', 69 | ] 70 | 71 | executable('htcpp', bin_src, 72 | cpp_args : flags, 73 | include_directories : ['src'], 74 | dependencies : [ 75 | htcpp_dep, 76 | clipp_dep, 77 | joml_cpp_dep, 78 | ], 79 | ) 80 | 81 | if get_option('build_libexample') 82 | executable('libexample', 'src/libexample.cpp', 83 | cpp_args : flags, 84 | include_directories : ['src'], 85 | dependencies : [ 86 | htcpp_dep, 87 | ], 88 | ) 89 | endif 90 | 91 | if get_option('build_unittests') 92 | unittests_src = [ 93 | 'unittests/main.cpp', 94 | 'unittests/time.cpp', 95 | ] 96 | 97 | executable('unittests', unittests_src, 98 | cpp_args : flags, 99 | include_directories : ['src'], 100 | dependencies : [ 101 | htcpp_dep, 102 | ], 103 | ) 104 | endif 105 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option('build_unittests', type : 'boolean', value : true) 2 | option('build_libexample', type : 'boolean', value : true) 3 | -------------------------------------------------------------------------------- /notes/async_certificate_loading/.gitignore: -------------------------------------------------------------------------------- 1 | load_cert_sync 2 | load_cert_async 3 | -------------------------------------------------------------------------------- /notes/async_certificate_loading/async.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | g++ load_cert_async.cpp -lssl -lcrypto -o load_cert_async && ./load_cert_async "$1" "$2" 3 | -------------------------------------------------------------------------------- /notes/async_certificate_loading/load_cert_async.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | #include 10 | 11 | std::optional readFile(const std::string& path) 12 | { 13 | auto f = std::unique_ptr( 14 | std::fopen(path.c_str(), "rb"), &std::fclose); 15 | if (!f) { 16 | std::cerr << "Could not open file: '" << path << "'" << std::endl; 17 | return std::nullopt; 18 | } 19 | if (std::fseek(f.get(), 0, SEEK_END) != 0) { 20 | std::cerr << "Error seeking to end of file: '" << path << "'" << std::endl; 21 | return std::nullopt; 22 | } 23 | const auto size = std::ftell(f.get()); 24 | if (size < 0) { 25 | std::cerr << "Error getting size of file: '" << path << "'" << std::endl; 26 | return std::nullopt; 27 | } 28 | if (std::fseek(f.get(), 0, SEEK_SET) != 0) { 29 | std::cerr << "Error seeking to start of file: '" << path << "'" << std::endl; 30 | return std::nullopt; 31 | } 32 | std::string buf(size, '\0'); 33 | if (std::fread(buf.data(), 1, size, f.get()) != static_cast(size)) { 34 | std::cerr << "Error reading file: '" << path << "'" << std::endl; 35 | return std::nullopt; 36 | } 37 | return buf; 38 | } 39 | 40 | int main(int argc, char** argv) 41 | { 42 | if (argc < 3) { 43 | std::cerr << "Usage: load_cert_sync " << std::endl; 44 | return 1; 45 | } 46 | 47 | SSL_CTX* ctx = SSL_CTX_new(TLS_server_method()); 48 | assert(ctx); 49 | 50 | auto start = std::chrono::high_resolution_clock::now(); 51 | const auto chainFileData = readFile(argv[1]).value(); 52 | const auto keyFileData = readFile(argv[2]).value(); 53 | std::cout << "File read duration: " 54 | << (std::chrono::high_resolution_clock::now() - start).count() / 1000 << std::endl; 55 | 56 | start = std::chrono::high_resolution_clock::now(); 57 | 58 | // Certificate Chain 59 | // https://github.com/openssl/openssl/blob/8aaca20cf9996257d1ce2e6f4d3059b3698dde3d/ssl/ssl_rsa.c#L570 60 | auto chainBio = BIO_new_mem_buf(chainFileData.data(), chainFileData.size()); 61 | assert(chainBio); 62 | auto cert = PEM_read_bio_X509_AUX(chainBio, nullptr, nullptr, nullptr); 63 | assert(cert); 64 | if (SSL_CTX_use_certificate(ctx, cert) != 1) { 65 | std::cerr << "Could not load certificate" << std::endl; 66 | return 1; 67 | } 68 | // SSL_CTX_clear_chain_certs vs. SSL_CTX_clear_extra_chain_certs? 69 | if (SSL_CTX_clear_chain_certs(ctx) != 1) { 70 | std::cerr << "Could not clear chain certs" << std::endl; 71 | return 1; 72 | } 73 | X509* ca; 74 | while ((ca = PEM_read_bio_X509(chainBio, nullptr, nullptr, nullptr))) { 75 | // SSL_CTX_add0_chain_cert vs. SSL_CTX_add_extra_chain_cert? 76 | if (SSL_CTX_add0_chain_cert(ctx, ca) != 1) { 77 | X509_free(ca); 78 | std::cerr << "Could not add certificate to chain" << std::endl; 79 | return 1; 80 | } 81 | // We must not delete the ca certs if they were successfully added to the chain. 82 | // We DO have to delete the main certificate though because SSL_CTX_use_certificate has 83 | // increased it reference count. 84 | // I am not sure how that makes sense (why not use SSL_CTX_add1_chain_cert and free either 85 | // way?). It seems to me using add0 might leak here? 86 | } 87 | const auto err = ERR_peek_last_error(); 88 | if (ERR_GET_LIB(err) != ERR_LIB_PEM || ERR_GET_REASON(err) != PEM_R_NO_START_LINE) { 89 | std::cerr << "Could not read chain" << std::endl; 90 | return 1; 91 | } 92 | X509_free(cert); 93 | BIO_free(chainBio); 94 | 95 | // Private Key 96 | // https://github.com/openssl/openssl/blob/8aaca20cf9996257d1ce2e6f4d3059b3698dde3d/ssl/ssl_rsa.c#L235 97 | auto keyBio = BIO_new_mem_buf(keyFileData.data(), keyFileData.size()); 98 | assert(keyBio); 99 | auto key = PEM_read_bio_PrivateKey(keyBio, nullptr, nullptr, nullptr); 100 | assert(key); 101 | if (SSL_CTX_use_PrivateKey(ctx, key) != 1) { 102 | std::cerr << "Could not load private key" << std::endl; 103 | return 1; 104 | } 105 | EVP_PKEY_free(key); 106 | BIO_free(keyBio); 107 | 108 | if (SSL_CTX_check_private_key(ctx) != 1) { 109 | std::cerr << "Certificate and private key do not match" << std::endl; 110 | return 1; 111 | } 112 | std::cout << "Load duration: " 113 | << (std::chrono::high_resolution_clock::now() - start).count() / 1000 << std::endl; 114 | return 0; 115 | } 116 | -------------------------------------------------------------------------------- /notes/async_certificate_loading/load_cert_sync.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | #include 6 | 7 | int main(int argc, char** argv) 8 | { 9 | if (argc < 3) { 10 | std::cerr << "Usage: load_cert_sync " << std::endl; 11 | return 1; 12 | } 13 | 14 | SSL_CTX* ctx_ = SSL_CTX_new(TLS_server_method()); 15 | assert(ctx_); 16 | const auto start = std::chrono::high_resolution_clock::now(); 17 | if (SSL_CTX_use_certificate_chain_file(ctx_, argv[1]) != 1) { 18 | std::cerr << "Could not load certificate" << std::endl; 19 | return false; 20 | } 21 | 22 | if (SSL_CTX_use_PrivateKey_file(ctx_, argv[2], SSL_FILETYPE_PEM) != 1) { 23 | std::cerr << "Could not load private key file" << std::endl; 24 | return false; 25 | } 26 | 27 | if (SSL_CTX_check_private_key(ctx_) != 1) { 28 | std::cerr << "Certificate and private key do not match" << std::endl; 29 | return false; 30 | } 31 | std::cout << "Duration: " << (std::chrono::high_resolution_clock::now() - start).count() / 1000 32 | << std::endl; 33 | return true; 34 | } 35 | -------------------------------------------------------------------------------- /notes/async_certificate_loading/notes.md: -------------------------------------------------------------------------------- 1 | I want to reload the certificate chain and the private key when they change on disk (certbot renews both), but I wasn't sure if simply doing file IO asynchronously and then just using the OpenSSL certificate/key loading functions would be good enough. 2 | There was a suspicion that reading the files will take a small percentage of the overall loading time, so that I might not end up decreasing the blocking time noticably at all. 3 | As it turns out my suspicions were correct. I did these benchmarks on a DigitalOcean 5$ VM, which I am going to use for hosting my website anyways. It does use an SSD, which makes it even more likely that doing only file IO asynchronously will not do much. 4 | 5 | A test certificate without a chain. Times are in microseconds. 6 | ``` 7 | root@tunnel:~# ./load_cert_sync 8 | Duration: 2432 9 | root@tunnel:~# ./load_cert_sync 10 | Duration: 2835 11 | root@tunnel:~# ./load_cert_sync 12 | Duration: 2828 13 | root@tunnel:~# ./load_cert_async 14 | File read duration: 91 15 | Load duration: 2438 16 | root@tunnel:~# ./load_cert_async 17 | File read duration: 102 18 | Load duration: 2007 19 | root@tunnel:~# ./load_cert_async 20 | File read duration: 115 21 | Load duration: 2642 22 | ``` 23 | 24 | A certificate that I actually use to host a website of mine right now 25 | ``` 26 | cert=/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/REDACTED/REDACTED.crt 27 | key=/caddy-data/caddy/certificates/acme-v02.api.letsencrypt.org-directory/REDACTED/REDACTED.key 28 | 29 | ./load_cert_sync "$cert" "$key" 30 | Duration: 5037 31 | root@tunnel:~# ./load_cert_sync "$cert" "$key" 32 | Duration: 5213 33 | root@tunnel:~# ./load_cert_sync "$cert" "$key" 34 | Duration: 5213 35 | 36 | root@tunnel:~# ./load_cert_async "$cert" "$key" 37 | File read duration: 119 38 | Load duration: 5085 39 | root@tunnel:~# ./load_cert_async "$cert" "$key" 40 | File read duration: 117 41 | Load duration: 4808 42 | root@tunnel:~# ./load_cert_async "$cert" "$key" 43 | File read duration: 134 44 | Load duration: 5395 45 | ``` 46 | 47 | In both cases loading the file takes a very small amount of time while the whole process takes a few milliseconds. You could argue that it's fine to block for 5ms every 2 months, but this whole project is just an exercise, so I will instead offload the certificate reloading to a thread. 48 | 49 | I need to implement this feature anyways, because I need it for process metrics as well. 50 | -------------------------------------------------------------------------------- /notes/async_certificate_loading/sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | g++ load_cert_sync.cpp -lssl -o load_cert_sync && ./load_cert_sync "$1" "$2" 3 | -------------------------------------------------------------------------------- /scripts/bench.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eou pipefail # strict mode 3 | commit_hash="$(git rev-parse HEAD)" 4 | outdir="benchmarks/$commit_hash" 5 | mkdir -p "$outdir" 6 | 7 | concurrency="512" 8 | duration="10s" 9 | url="file/src/config.hpp" 10 | 11 | HTCPP_ACCESS_LOG=0 build/htcpp --listen 127.0.0.1:6969 & 12 | http_pid=$! 13 | 14 | HTCPP_ACCESS_LOG=0 build/htcpp --listen 127.0.0.1:6970 --tls cert.pem key.pem & 15 | https_pid=$! 16 | 17 | echo "Warmup HTTP" # file cache, grow some buffers, allocate things 18 | hey -c "$concurrency" -z 3s "http://localhost:6969/$url" > /dev/null 19 | 20 | echo "http ${concurrency}" 21 | outfile="$outdir/http_c${concurrency}_${duration}" 22 | hey -c "$concurrency" -z "$duration" "http://localhost:6969/$url" > "$outfile" 23 | grep "Requests/sec" "$outfile" 24 | 25 | echo "http ${concurrency} close" 26 | outfile="$outdir/http_c${concurrency}_${duration}_close" 27 | hey -c "$concurrency" -z "$duration" -disable-keepalive "http://localhost:6969/$url" > "$outfile" 28 | grep "Requests/sec" "$outfile" 29 | 30 | echo "Warmup HTTPS" 31 | hey -c "$concurrency" -z 3s "https://localhost:6970/$url" > /dev/null 32 | 33 | echo "https ${concurrency}" 34 | outfile="$outdir/https_c${concurrency}_${duration}" 35 | hey -c "$concurrency" -z "$duration" "https://localhost:6970/$url" > "$outfile" 36 | grep "Requests/sec" "$outfile" 37 | 38 | echo "https ${concurrency} close" 39 | outfile="$outdir/https_c${concurrency}_${duration}_close" 40 | hey -c "$concurrency" -z "$duration" -disable-keepalive "https://localhost:6970/$url" > "$outfile" 41 | grep "Requests/sec" "$outfile" 42 | 43 | kill "$http_pid" 44 | kill "$https_pid" 45 | -------------------------------------------------------------------------------- /scripts/cert.cfg: -------------------------------------------------------------------------------- 1 | cn = localhost 2 | expiration_days = 10000 3 | 4 | # Not actually necessary, but should be there 5 | dns_name = localhost 6 | 7 | # necessary for curl 8 | ca 9 | 10 | # Key Usage 11 | # digitalSignature flag, needed for TLS DHE cipher suites 12 | signing_key 13 | # keyEncipherment flag, needed for TLS RSA cipher suites 14 | encryption_key 15 | # keyCertSign flag, necessary for openssl 16 | cert_signing_key 17 | 18 | # Also not necessary but nice to have 19 | tls_www_server 20 | -------------------------------------------------------------------------------- /scripts/create_cert.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | # Execute this script from the root of the repository 3 | 4 | # Test like this: 5 | # Server: openssl s_server -key key.pem -cert cert.pem -accept 5890 -www 6 | # Client: curl --cacert cert.pem https://localhost:6969/ 7 | 8 | certtool --generate-privkey --outfile key.pem 9 | certtool --generate-self-signed --load-privkey key.pem --template scripts/cert.cfg --outfile cert.pem 10 | 11 | # The following openssl command will not generate a CA certificate, which it needs to be for curl 12 | # openssl req -newkey rsa:4096 -x509 -sha256 -days 3650 -nodes -out cert.pem -keyout key.pem 13 | -------------------------------------------------------------------------------- /scripts/docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker run --init --workdir /app -v "$(pwd):/app" --network=host htcpp "$@" 3 | -------------------------------------------------------------------------------- /src/acme.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include "client.hpp" 6 | #include "config.hpp" 7 | #include "events.hpp" 8 | #include "result.hpp" 9 | #include "ssl.hpp" 10 | 11 | class AcmeClient { 12 | public: 13 | struct Challenge { 14 | std::string path; 15 | std::string content; 16 | }; 17 | 18 | AcmeClient(IoQueue& io, Config::Acme config); 19 | 20 | std::shared_ptr getCurrentContext() const; 21 | std::shared_ptr> getChallenges() const; 22 | 23 | private: 24 | void threadFunc(); 25 | bool needIssueCertificate() const; 26 | bool updateContext(); 27 | bool issueCertificate( 28 | const std::string& jwk, const std::string& jwkThumbprint, EVP_PKEY* accountPkey); 29 | 30 | IoQueue& io_; 31 | Config::Acme config_; 32 | ThreadRequester requester_; 33 | std::shared_ptr> challenges_; 34 | // These listeners are sent events from the threadFunc and simply set the corresponding member 35 | // variables from the main thread 36 | EventListener challengesListener_; 37 | std::shared_ptr currentContext_; 38 | EventListener currentContextListener_; 39 | std::thread thread_; 40 | }; 41 | 42 | // The host handlers have to get these by name somehow, so this is how it is. 43 | // I have been working on this feature for weeks and I don't want to think too much about how to do 44 | // this properly, so I just do this silly shit. 45 | AcmeClient* registerAcmeClient(const std::string& name, IoQueue& io, Config::Acme config); 46 | AcmeClient* getAcmeClient(const std::string& name); 47 | 48 | struct AcmeSslConnectionFactory { 49 | using Connection = SslConnection; 50 | 51 | AcmeClient* acmeClient; 52 | 53 | std::unique_ptr create(IoQueue& io, int fd) 54 | { 55 | auto context = acmeClient->getCurrentContext(); 56 | return context ? std::make_unique(io, fd, std::move(context)) : nullptr; 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/client.cpp: -------------------------------------------------------------------------------- 1 | #include "client.hpp" 2 | 3 | void request(IoQueue& io, Method method, const std::string_view urlStr, const HeaderMap<>& headers, 4 | const std::string& requestBody, std::function cb) 5 | { 6 | const auto url = Url::parse(urlStr); 7 | if (!url) { 8 | slog::error("Could not parse URL for request"); 9 | cb(std::make_error_code(std::errc::invalid_argument), Response()); 10 | return; 11 | } 12 | if (url->scheme == "http") { 13 | auto session = ClientSession::create(io, url->host, url->port); 14 | session->request(method, url->targetRaw, headers, requestBody, std::move(cb)); 15 | #ifdef TLS_SUPPORT_ENABLED 16 | } else if (url->scheme == "https") { 17 | auto session = ClientSession::create(io, url->host, url->port); 18 | session->request(method, url->targetRaw, headers, requestBody, std::move(cb)); 19 | #endif 20 | } else { 21 | cb(std::make_error_code(std::errc::invalid_argument), Response()); 22 | slog::error("Invalid scheme in request url"); 23 | return; 24 | } 25 | } 26 | 27 | ThreadRequester::ThreadRequester(IoQueue& io) 28 | : io_(io) 29 | , eventListener_(io, [this](Event&& event) { eventHandler(std::move(event)); }) 30 | { 31 | } 32 | 33 | std::future ThreadRequester::request( 34 | Method method, std::string url, HeaderMap<> headers, std::string body) 35 | { 36 | auto prom = std::make_shared>(); 37 | auto fut = prom->get_future(); 38 | eventListener_.emit( 39 | Event { std::move(prom), method, std::move(url), std::move(headers), std::move(body) }); 40 | return fut; 41 | } 42 | 43 | void ThreadRequester::eventHandler(ThreadRequester::Event&& event) 44 | { 45 | ::request(io_, event.method, event.url, event.headers, event.body, 46 | [prom = std::move(event.promise)](std::error_code ec, Response&& resp) mutable { 47 | if (ec) { 48 | prom->set_value(Result(error(ec))); 49 | } else { 50 | prom->set_value(Result(std::move(resp))); 51 | } 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /src/client.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #include "http.hpp" 12 | #include "ioqueue.hpp" 13 | #include "result.hpp" 14 | #include "tcp.hpp" 15 | #include "util.hpp" 16 | 17 | #ifdef TLS_SUPPORT_ENABLED 18 | #include "ssl.hpp" 19 | #endif 20 | 21 | template 22 | constexpr uint16_t defaultPort = 0; 23 | 24 | template <> 25 | constexpr uint16_t defaultPort = 80; 26 | 27 | #ifdef TLS_SUPPORT_ENABLED 28 | template <> 29 | constexpr uint16_t defaultPort = 443; 30 | #endif 31 | 32 | template 33 | struct ClientSession : public std::enable_shared_from_this> { 34 | public: 35 | static std::shared_ptr create(IoQueue& io, std::string_view host, uint16_t port) 36 | { 37 | auto session = std::shared_ptr(new ClientSession(io, host, port)); 38 | return session; 39 | } 40 | 41 | using Connection = typename ConnectionFactory::Connection; 42 | using Callback = std::function; 43 | 44 | bool request(Method method, std::string_view target, const HeaderMap<>& headers, 45 | const std::string& requestBody, Callback cb) 46 | { 47 | if (callback_) { 48 | // Request already in progress. Pipelining is not supported. 49 | return false; 50 | } 51 | callback_ = std::move(cb); 52 | requestBuffer_ = serializeRequest(method, target, headers, requestBody); 53 | if (!connection_) { 54 | connect(); 55 | } 56 | return true; 57 | } 58 | 59 | private: 60 | static ConnectionFactory& getConnectionFactory() 61 | { 62 | static ConnectionFactory factory; 63 | return factory; 64 | } 65 | 66 | ClientSession(IoQueue& io, std::string_view host, uint16_t port = 0) 67 | : io_(io) 68 | , host_(host) 69 | , port_(port ? port : defaultPort) 70 | { 71 | connectAddr_.ss_family = AF_UNSPEC; 72 | } 73 | 74 | void resolve() 75 | { 76 | io_.async>( 77 | [this, self = this->shared_from_this()]() { 78 | struct ::addrinfo hints; 79 | std::memset(&hints, 0, sizeof(hints)); 80 | hints.ai_family = AF_UNSPEC; 81 | hints.ai_socktype = SOCK_STREAM; 82 | 83 | std::vector<::sockaddr_storage> addrs; 84 | ::addrinfo* result; 85 | const auto port = std::to_string(port_); 86 | const auto res = ::getaddrinfo(host_.c_str(), port.c_str(), &hints, &result); 87 | if (res != 0) { 88 | slog::error("getaddrinfo: ", gai_strerror(res)); 89 | return addrs; 90 | } 91 | for (::addrinfo* ai = result; ai != nullptr; ai = ai->ai_next) { 92 | slog::debug("addr: family = ", ai->ai_family, ", socktype = ", ai->ai_socktype, 93 | ", protocol = ", ai->ai_protocol); 94 | std::memcpy(&addrs.emplace_back(), ai->ai_addr, ai->ai_addrlen); 95 | } 96 | return addrs; 97 | }, 98 | [this, self = this->shared_from_this()]( 99 | std::error_code ec, std::vector<::sockaddr_storage>&& addrs) { 100 | if (ec) { 101 | slog::error("Error doing async resolve: ", ec.message()); 102 | callback_(ec, Response()); 103 | return; 104 | } 105 | if (addrs.empty()) { 106 | slog::error("Empty address list"); 107 | callback_(std::make_error_code(std::errc::host_unreachable), Response()); 108 | return; 109 | } 110 | // Just use the first one? 111 | std::memcpy(&connectAddr_, &addrs[0], sizeof(::sockaddr_storage)); 112 | connect(); 113 | }); 114 | } 115 | 116 | void connect() 117 | { 118 | assert(!connection_); 119 | if (connectAddr_.ss_family == AF_UNSPEC) { 120 | resolve(); 121 | } else { 122 | const auto sock = ::socket(connectAddr_.ss_family, SOCK_STREAM, 0); 123 | if (sock == -1) { 124 | slog::error("Error creating socket: ", errnoToString(errno)); 125 | callback_(std::make_error_code(static_cast(errno)), Response()); 126 | } 127 | assert(connectAddr_.ss_family == AF_INET || connectAddr_.ss_family == AF_INET6); 128 | const auto addrLen = connectAddr_.ss_family == AF_INET ? sizeof(::sockaddr_in) 129 | : sizeof(::sockaddr_in6); 130 | io_.connect(sock, reinterpret_cast(&connectAddr_), addrLen, 131 | [this, self = this->shared_from_this(), sock](std::error_code ec) { 132 | if (ec) { 133 | slog::error("Error connecting: ", ec.message()); 134 | callback_(ec, Response()); 135 | } 136 | connection_ = getConnectionFactory().create(io_, sock); 137 | if constexpr (std::is_same_v) { 138 | connection_->setHostname(host_); 139 | } 140 | send(); 141 | }); 142 | } 143 | } 144 | 145 | void send() 146 | { 147 | assert(sendCursor_ < requestBuffer_.size()); 148 | connection_->send(requestBuffer_.data() + sendCursor_, requestBuffer_.size() - sendCursor_, 149 | [this, self = this->shared_from_this()](std::error_code ec, int sentBytes) { 150 | if (ec) { 151 | slog::error("Error sending request: ", ec.message()); 152 | callback_(ec, Response()); 153 | connection_->close(); 154 | return; 155 | } 156 | 157 | if (sentBytes == 0) { 158 | slog::error("0 bytes sent"); 159 | callback_(std::make_error_code(std::errc::no_message_available), Response()); 160 | connection_->close(); 161 | return; 162 | } 163 | 164 | assert(sentBytes > 0); 165 | if (sendCursor_ + sentBytes < requestBuffer_.size()) { 166 | sendCursor_ += sentBytes; 167 | send(); 168 | return; 169 | } 170 | 171 | recvHeader(); 172 | }); 173 | } 174 | 175 | void recvHeader() 176 | { 177 | const auto recvLen = 1024; 178 | recvBuffer_.resize(recvLen, '\0'); 179 | connection_->recv(recvBuffer_.data(), recvLen, 180 | [this, self = this->shared_from_this(), recvLen](std::error_code ec, int readBytes) { 181 | if (ec) { 182 | slog::error("Error in recv (headers): ", ec.message()); 183 | callback_(ec, Response()); 184 | connection_->close(); 185 | return; 186 | } 187 | 188 | if (readBytes == 0) { 189 | slog::error("Connection closed"); 190 | callback_(std::make_error_code(std::errc::host_unreachable), Response()); 191 | connection_->close(); 192 | return; 193 | } 194 | 195 | recvBuffer_.resize(recvBuffer_.size() - recvLen + readBytes); 196 | 197 | auto response = Response::parse(recvBuffer_); 198 | if (!response) { 199 | slog::error("Could not parse response"); 200 | callback_(std::make_error_code(std::errc::invalid_argument), Response()); 201 | connection_->close(); 202 | return; 203 | } 204 | response_ = std::move(*response); 205 | 206 | const auto contentLength = response_.headers.get("Content-Length"); 207 | if (contentLength) { 208 | const auto length = parseInt(*contentLength); 209 | if (!length) { 210 | slog::error("Invalid Content-Length"); 211 | callback_(std::make_error_code(std::errc::invalid_argument), Response()); 212 | connection_->close(); 213 | return; 214 | } 215 | 216 | if (response_.body.size() < *length) { 217 | recvBody(*length); 218 | } else { 219 | response_.body = response_.body.substr(0, *length); 220 | processResponse(); 221 | } 222 | } else { 223 | processResponse(); 224 | } 225 | }); 226 | } 227 | 228 | void recvBody(size_t contentLength) 229 | { 230 | const auto sizeBeforeRead = response_.body.size(); 231 | assert(sizeBeforeRead < contentLength); 232 | const auto recvLen = contentLength - sizeBeforeRead; 233 | response_.body.append(recvLen, '\0'); 234 | const auto buffer = response_.body.data() + sizeBeforeRead; 235 | connection_->recv(buffer, recvLen, 236 | [this, self = this->shared_from_this(), recvLen, contentLength]( 237 | std::error_code ec, int readBytes) { 238 | if (ec) { 239 | slog::error("Error in recv (body): ", ec.message()); 240 | callback_(ec, Response()); 241 | connection_->close(); 242 | return; 243 | } 244 | 245 | if (readBytes == 0) { 246 | slog::error("Connection closed"); 247 | callback_(std::make_error_code(std::errc::host_unreachable), Response()); 248 | connection_->close(); 249 | return; 250 | } 251 | 252 | response_.body.resize(response_.body.size() - recvLen + readBytes); 253 | 254 | if (response_.body.size() < contentLength) { 255 | recvBody(contentLength); 256 | } else { 257 | assert(response_.body.size() == contentLength); 258 | processResponse(); 259 | } 260 | }); 261 | } 262 | 263 | void processResponse() 264 | { 265 | callback_(std::error_code(), std::move(response_)); 266 | callback_ = nullptr; 267 | connection_->close(); 268 | } 269 | 270 | std::string serializeRequest( 271 | Method method, std::string_view target, const HeaderMap<>& headers, std::string_view body) 272 | { 273 | std::string req; 274 | req.reserve(512); 275 | req.append(toString(method)); 276 | req.append(" "); 277 | req.append(target); 278 | req.append(" HTTP/1.1\r\n"); 279 | if (!headers.contains("Host")) { 280 | req.append("Host: "); 281 | req.append(host_); 282 | if (port_ != defaultPort) { 283 | req.append(":"); 284 | req.append(std::to_string(port_)); 285 | } 286 | req.append("\r\n"); 287 | } 288 | headers.serialize(req); 289 | if (body.size() && !headers.contains("Content-Length")) { 290 | req.append("Content-Length: " + std::to_string(body.size()) + "\r\n"); 291 | } 292 | req.append("\r\n"); 293 | req.append(body); 294 | return req; 295 | } 296 | 297 | IoQueue& io_; 298 | std::string host_; 299 | uint16_t port_ = 0; 300 | sockaddr_storage connectAddr_; 301 | Callback callback_ = nullptr; 302 | std::string requestBuffer_; 303 | Response response_; 304 | std::string recvBuffer_; 305 | size_t sendCursor_ = 0; 306 | std::unique_ptr connection_; 307 | }; 308 | 309 | // This must be called from the main thread! You can tell by the IoQueue& parameter 310 | void request(IoQueue& io, Method method, const std::string_view urlStr, const HeaderMap<>& headers, 311 | const std::string& requestBody, std::function cb); 312 | 313 | class ThreadRequester { 314 | public: 315 | using RequestResult = Result; 316 | 317 | // This must be constructed from the main thread 318 | ThreadRequester(IoQueue& io); 319 | 320 | // This can be called from any thread 321 | std::future request( 322 | Method method, std::string url, HeaderMap<> headers = {}, std::string body = {}); 323 | 324 | private: 325 | struct Event { 326 | std::shared_ptr> promise; 327 | Method method; 328 | std::string url; 329 | HeaderMap<> headers; 330 | std::string body; 331 | }; 332 | 333 | void eventHandler(Event&& event); 334 | 335 | IoQueue& io_; 336 | EventListener eventListener_; 337 | }; 338 | -------------------------------------------------------------------------------- /src/config.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | 10 | #include "pattern.hpp" 11 | #include "time.hpp" 12 | 13 | struct Config { 14 | struct Server { 15 | uint32_t listenAddress = INADDR_ANY; 16 | uint16_t listenPort = 6969; 17 | 18 | bool accesLog = true; 19 | 20 | size_t listenBacklog = SOMAXCONN; 21 | uint32_t fullReadTimeoutMs = 2000; 22 | size_t maxUrlLength = 512; 23 | // maxRequestHeaderSize is actually the max size of request line + all headers 24 | // It would be cool if this was less than MTU, but on Firefox the request header 25 | // is 566 bytes long at the time of writing this. If I host my website on localhost 26 | // the Cookie header pushes this over 1Ki, so I'll go with 2Ki. 27 | size_t maxRequestHeaderSize = 2048; 28 | size_t maxRequestBodySize = 1024; 29 | 30 | std::optional limitConnections; 31 | 32 | struct LimitRequestsByIp { 33 | uint32_t steadyRate = 5; 34 | uint32_t burstSize = 50; 35 | size_t maxNumEntries = 16384; 36 | }; 37 | 38 | // Rate limiting by transferred data is an option, but weird. 39 | // Global Rate limiting (regardless of IP) is probably just worse. 40 | std::optional limitRequestsByIp; 41 | }; 42 | 43 | struct Service : public Server { 44 | struct Host { 45 | struct PatternEntry { 46 | Pattern pattern; 47 | std::string replacement; 48 | }; 49 | 50 | struct HeadersEntry { 51 | Pattern pattern; 52 | std::unordered_map headers; 53 | }; 54 | 55 | std::vector files; 56 | std::optional metrics; 57 | std::vector headers = {}; 58 | std::vector redirects; 59 | #ifdef TLS_SUPPORT_ENABLED 60 | std::optional acmeChallenges; 61 | #endif 62 | }; 63 | 64 | #ifdef TLS_SUPPORT_ENABLED 65 | struct Tls { 66 | // Do a variant here! 67 | std::optional chain; 68 | std::optional key; 69 | std::optional acme; 70 | }; 71 | 72 | std::optional tls; 73 | #endif 74 | 75 | std::unordered_map hosts; 76 | }; 77 | 78 | #ifdef TLS_SUPPORT_ENABLED 79 | struct Acme { 80 | // directoryUrl would be more descriptive, but it might be confusing with "directory" below 81 | std::string url = "letsencrypt"; 82 | std::string domain; // the key of the object 83 | std::vector altNames; // Subject Alternative Names 84 | std::string directory; // $XDG_DATA_HOME/htcpp/acme, XDG_DATA_HOME=$HOME/.local/share 85 | std::string accountPrivateKeyPath; // /accountkey.pem 86 | std::string certPrivateKeyPath; // //privkey.pem 87 | std::string certPath; // //fullchain.pem 88 | // certbot uses 2048 by default 89 | // also: https://danielpocock.com/rsa-key-sizes-2048-or-4096-bits/ 90 | uint32_t rsaKeyLength = 2048; 91 | std::vector renewCheckTimes = { { 3, 0 }, { 15, 0 } }; 92 | // We jitter the time a bit (actually "wander") to avoid repeated 93 | // retries at inopportune times 94 | Duration renewCheckJitter = Duration::fromHours(3); 95 | // Lets Encrypt certificates are valid for 90 days by default, so we give ourselves 96 | // 60 days to attempt renewal (same as certbot) 97 | Duration renewBeforeExpiry = Duration::fromDays(30); 98 | }; 99 | 100 | std::unordered_map acme; // the key is the domain 101 | #endif 102 | 103 | uint32_t ioQueueSize = 2048; // power of two, >= 1, <= 4096 104 | bool ioSubmissionQueuePolling = true; 105 | 106 | std::vector services; 107 | 108 | bool loadFromFile(const std::string& path); 109 | 110 | static Config& get(); 111 | }; 112 | -------------------------------------------------------------------------------- /src/events.cpp: -------------------------------------------------------------------------------- 1 | #include "events.hpp" 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | #include "ioqueue.hpp" 9 | 10 | EventFd::EventFd(IoQueue& io) 11 | : io_(io) 12 | , fd_(::eventfd(0, 0)) 13 | { 14 | } 15 | 16 | bool EventFd::read(Function cb) 17 | { 18 | assert(fd_ != -1); 19 | return io_.read(fd_, &readBuf_, sizeof(readBuf_), 20 | [this, cb = std::move(cb)](std::error_code ec, int readBytes) { 21 | if (ec) { 22 | cb(ec, 0); 23 | return; 24 | } 25 | // man 2 eventfd: Each successful read(2) returns an 8-byte integer. 26 | // The example does handle the case of res != 8, but I don't really know 27 | // what I am not sure what I should do in that case, so I assert for now. 28 | assert(readBytes == sizeof(uint64_t)); 29 | cb(std::error_code(), readBuf_); 30 | }); 31 | } 32 | 33 | void EventFd::write(uint64_t v) 34 | { 35 | assert(fd_ != -1); 36 | if (::write(fd_, &v, sizeof(uint64_t)) != sizeof(uint64_t)) { 37 | // We cannot call the read handler (can't reach it). 38 | // We cannot cancel or terminate the read somehow (no functionality like that yet). 39 | // If we close fd_, the read will be stuck forever (tried it out). 40 | // This is used for certificate reloading, so if this fails here, we will never update 41 | // the certificate, when we should. It's also used for expensive async operations while 42 | // handling HTTP requests and if we fail here those requests would hang forever. I think 43 | // the right thing to do here is exit. 44 | slog::fatal("Error writing to eventfd: ", 45 | std::make_error_code(static_cast(errno)).message()); 46 | std::exit(1); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/events.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include "fd.hpp" 6 | #include "function.hpp" 7 | #include "log.hpp" 8 | #include "mpscqueue.hpp" 9 | 10 | class IoQueue; 11 | 12 | class EventFd { 13 | public: 14 | EventFd(IoQueue& io); 15 | 16 | // I do not close fd_ asynchronously in ~EventFd here, because EventFd might be destroyed 17 | // from another thread (from which async IO operations are not allowed). 18 | 19 | // The read will complete once the counter stored in the eventfd is > 0. 20 | // Then it will read the current value and reset the counter to 0. 21 | bool read(Function cb); 22 | 23 | // This will increase the counter stored in the eventfd by `v`. 24 | // Note that this function writes SYNCHRONOUSLY, so it can be used from other threads, but it 25 | // also means that it will not be as fast and it might block (unlikely though). This means you 26 | // need to be careful about using it from the main thread, because it might block the IoQueue. 27 | void write(uint64_t v); 28 | 29 | private: 30 | IoQueue& io_; 31 | Fd fd_; 32 | uint64_t readBuf_; 33 | }; 34 | 35 | // This class provides a way send messages to the main thread where they can be handled 36 | // asynchronously. It's main purpose is to provide a way to have other threads to IO through the IO 37 | // Queue (e.g. the ACME client). 38 | // For something like that use an Event class that contains some parameters and a promise and use an 39 | // eventHandler that uses the parameters to start an asynchronous IO operation that fulfills the 40 | // promise when it completes. 41 | template 42 | class EventListener { 43 | public: 44 | // The class needs to be constructed from the main thread 45 | EventListener(IoQueue& io, Function eventHandler) 46 | : eventHandler_(std::move(eventHandler)) 47 | , eventFd_(io) 48 | { 49 | pollQueue(); 50 | } 51 | 52 | // This can be called from any thread! 53 | void emit(Event&& event) 54 | { 55 | queue_.produce(std::move(event)); 56 | eventFd_.write(1); 57 | } 58 | 59 | private: 60 | void pollQueue() 61 | { 62 | eventFd_.read([this](std::error_code ec, uint64_t) { 63 | if (ec) { 64 | slog::error("Error reading eventfd: ", ec.message()); 65 | } else { 66 | while (true) { 67 | auto event = queue_.consume(); 68 | if (!event) { 69 | break; 70 | } 71 | slog::debug("consume cb"); 72 | eventHandler_(std::move(*event)); 73 | } 74 | } 75 | pollQueue(); 76 | }); 77 | } 78 | 79 | Function eventHandler_; 80 | MpscQueue queue_; 81 | EventFd eventFd_; 82 | }; 83 | -------------------------------------------------------------------------------- /src/fd.cpp: -------------------------------------------------------------------------------- 1 | #include "fd.hpp" 2 | 3 | #include 4 | #include 5 | 6 | Fd::Fd() 7 | : fd_(-1) 8 | { 9 | } 10 | 11 | Fd::Fd(int fd) 12 | : fd_(fd) 13 | { 14 | } 15 | 16 | Fd::Fd(Fd&& other) 17 | : fd_(other.release()) 18 | { 19 | } 20 | 21 | Fd::~Fd() 22 | { 23 | close(); 24 | } 25 | 26 | Fd& Fd::operator=(Fd&& other) 27 | { 28 | fd_ = other.release(); 29 | return *this; 30 | } 31 | 32 | Fd::operator int() const 33 | { 34 | return fd_; 35 | } 36 | 37 | void Fd::close() 38 | { 39 | if (fd_ != -1) 40 | ::close(fd_); 41 | fd_ = -1; 42 | } 43 | 44 | void Fd::reset(int fd) 45 | { 46 | close(); 47 | fd_ = fd; 48 | } 49 | 50 | int Fd::release() 51 | { 52 | const int fd = fd_; 53 | fd_ = -1; 54 | return fd; 55 | } 56 | 57 | Pipe::Pipe() 58 | { 59 | int fds[2]; 60 | assert(pipe(fds) != -1); 61 | read.reset(fds[0]); 62 | write.reset(fds[1]); 63 | } 64 | 65 | void Pipe::close() 66 | { 67 | read.close(); 68 | write.close(); 69 | } 70 | -------------------------------------------------------------------------------- /src/fd.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | class Fd { 4 | public: 5 | Fd(); 6 | Fd(int fd); 7 | Fd(Fd&& other); 8 | Fd(const Fd& other) = delete; 9 | ~Fd(); 10 | 11 | Fd& operator=(const Fd& other) = delete; 12 | Fd& operator=(Fd&& other); 13 | 14 | operator int() const; 15 | 16 | void close(); 17 | void reset(int fd = -1); // close current fd and set new one 18 | int release(); // return the fd without closing 19 | 20 | private: 21 | int fd_ = -1; 22 | }; 23 | 24 | struct Pipe { 25 | Fd read; 26 | Fd write; 27 | 28 | Pipe(); 29 | 30 | void close(); 31 | }; 32 | -------------------------------------------------------------------------------- /src/filecache.cpp: -------------------------------------------------------------------------------- 1 | #include "filecache.hpp" 2 | 3 | #include 4 | 5 | #include 6 | 7 | #include "log.hpp" 8 | #include "metrics.hpp" 9 | #include "util.hpp" 10 | 11 | FileCache::FileCache(IoQueue& io) 12 | : io_(io) 13 | , fileWatcher_(io) 14 | { 15 | } 16 | 17 | // As this server is fully single-threaded, we can get away with returning a reference 18 | // because the reference might only be invalidated after the handler that is using it 19 | // has finished. If this server was multi-threaded we should return shared_ptr here instead. 20 | // If std::optional was a thing, I would return that instead. 21 | const FileCache::Entry* FileCache::get(const std::string& path) 22 | { 23 | Metrics::get().fileCacheQueries.labels(path).inc(); 24 | auto it = entries_.find(path); 25 | if (it == entries_.end()) { 26 | Entry entry { path }; 27 | // In the past I added an entry and a watch in every case, even if the file does not exist, 28 | // so if the file is created at some point, I get a notification to simply mark it outdated 29 | // and reload it. 30 | // But I don't want it to be possible to create an unbounded number of file cache entries 31 | // for files that don't even exist. Also I don't want to waste inotify watches (which are 32 | // limited) on non-existent files (the file cache should figure this out, but still). 33 | if (!entry.reload()) { 34 | Metrics::get().fileCacheFailures.labels(path).inc(); 35 | return nullptr; 36 | } 37 | entry.state = Entry::State::UpToDate; 38 | it = entries_.emplace(path, std::move(entry)).first; 39 | 40 | fileWatcher_.watch(path, [this](std::error_code ec, std::string_view path) { 41 | if (ec) { 42 | entries_.erase(std::string(path)); 43 | return; 44 | } 45 | slog::info("file changed: '", path, "'"); 46 | entries_.at(std::string(path)).state = Entry::State::Outdated; 47 | }); 48 | } 49 | 50 | if (it->second.state == Entry::State::Outdated) { 51 | it->second.reload(); 52 | // Set up-to-date either way (error or not), so that we don't repeatedly try to load a file 53 | // that was deleted for example. 54 | // We wait for another modification before we try again. 55 | it->second.state = Entry::State::UpToDate; 56 | } else { 57 | Metrics::get().fileCacheHits.labels(path).inc(); 58 | } 59 | 60 | if (!it->second.contents) { 61 | Metrics::get().fileCacheFailures.labels(path).inc(); 62 | return nullptr; 63 | } 64 | return &it->second; 65 | } 66 | 67 | namespace { 68 | // I do this myself, because I don't want to worry about locales 69 | std::optional formatTm(const std::tm* tm) 70 | { 71 | constexpr std::array weekDays = { "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun" }; 72 | constexpr std::array months 73 | = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; 74 | if (tm->tm_wday < 0 || tm->tm_wday > 6) { 75 | slog::error("Weekday is out of range: ", tm->tm_wday); 76 | return std::nullopt; 77 | } 78 | if (tm->tm_mon < 0 || tm->tm_mon > 11) { 79 | slog::error("Month is out of range: ", tm->tm_mon); 80 | return std::nullopt; 81 | } 82 | // https://www.rfc-editor.org/rfc/rfc7231#section-7.1.1.1 83 | // example: Sat, 23 Apr 2022 23:22:48 GMT 84 | char buf[32]; 85 | const auto res = std::snprintf(buf, sizeof(buf), "%s, %02d %s %d %02d:%02d:%02d GMT", 86 | weekDays[tm->tm_wday], tm->tm_mday, months[tm->tm_mon], tm->tm_year + 1900, tm->tm_hour, 87 | tm->tm_min, tm->tm_sec); 88 | if (res < 0) { 89 | slog::error("Could not format time"); 90 | return std::nullopt; 91 | } 92 | return std::string(buf); 93 | } 94 | } 95 | 96 | bool FileCache::Entry::reload() 97 | { 98 | slog::info("reload file: '", path, "'"); 99 | const auto cont = readFile(path); 100 | if (!cont) { 101 | // Error already logged 102 | return false; 103 | } 104 | 105 | // Using mtime and size is very popular. This is used by Apache, binserve, Caddy, lighthttpd, 106 | // and nginx. Sometimes the inode is included, but I don't think it's very necessary and can 107 | // lead to problems if the files are served from multiple instances of a server (e.g. behind a 108 | // load balancer): 109 | // https://github.com/caddyserver/caddy/pull/1435/files 110 | // https://serverfault.com/a/690374 111 | 112 | // Also there is a very improbable vulnerabilty in including the inode, which I have no trouble 113 | // ignoring, but I don't want anyone to *ever* open an issue for this, so I just leave it out 114 | // from the start: 115 | // https://www.pentestpartners.com/security-blog/vulnerabilities-that-arent-etag-headers/ 116 | 117 | // https://www.rfc-editor.org/rfc/rfc7232#section-2.1 distinguishes between strong and weak 118 | // validators and this is not actually a strong validator, but it is still specified as a strong 119 | // validator, because weak don't do anything for partial content (which I do not support *yet*). 120 | // Nginx and Caddy also do this. 121 | // There is a TODO item for optionally using a cryptographic hash for the ETag. 122 | 123 | // This is kind of race-ey, but I don't think there is much we can do reasonably. 124 | // One way would be to read the file multiple times to check if the content changed after the 125 | // stat, which I consider unreasonable. 126 | struct ::stat st; 127 | const auto statRes = ::stat(path.c_str(), &st); 128 | if (statRes != 0) { 129 | slog::error("Could not stat '", path, "': ", errnoToString(errno)); 130 | return false; 131 | } 132 | 133 | // https://www.rfc-editor.org/rfc/rfc7232#section-2.3 134 | // The ETag can be any number of double quoted characters in {0x21, 0x23-0x7E, 0x80-0xFF} 135 | char eTagBuf[64] = { 0 }; // at most 32 chars (8 bytes and 8 bytes with 2 chars per byte) 136 | // long st_size, long int st_mtime 137 | if (std::snprintf(eTagBuf, sizeof(eTagBuf), "\"%lx-%lx\"", st.st_mtime, st.st_size) < 0) { 138 | slog::error("Could not format ETag"); 139 | return false; 140 | } 141 | 142 | const auto tm = std::gmtime(&st.st_mtime); 143 | const auto lm = formatTm(tm); 144 | if (!lm) { 145 | // Already logged 146 | return false; 147 | } 148 | 149 | contents = *cont; 150 | eTag = eTagBuf; 151 | lastModified = *lm; 152 | return true; 153 | } 154 | -------------------------------------------------------------------------------- /src/filecache.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include "filewatcher.hpp" 8 | #include "ioqueue.hpp" 9 | 10 | // TODO: I should probably reload files in another thread, so it doesn't slow down the server itself 11 | // but block on the first load. 12 | class FileCache { 13 | public: 14 | struct Entry { 15 | enum class State { Outdated, UpToDate }; 16 | 17 | std::string path; 18 | std::optional contents = std::nullopt; 19 | std::string eTag = ""; 20 | std::string lastModified = ""; 21 | State state = State::Outdated; 22 | 23 | bool reload(); 24 | }; 25 | 26 | FileCache(IoQueue& io); 27 | 28 | // As this server is fully single-threaded, we can get away with returning a reference 29 | // because the reference might only be invalidated after the handler that is using it 30 | // has finished. If this server was multi-threaded we should return shared_ptr here instead. 31 | // If std::optional was a thing, I would return that instead. 32 | const Entry* get(const std::string& path); 33 | 34 | private: 35 | IoQueue& io_; 36 | FileWatcher fileWatcher_; 37 | std::unordered_map entries_; 38 | }; 39 | -------------------------------------------------------------------------------- /src/filewatcher.cpp: -------------------------------------------------------------------------------- 1 | #include "filewatcher.hpp" 2 | 3 | #include 4 | #include 5 | 6 | #include "log.hpp" 7 | #include "util.hpp" 8 | 9 | FileWatcher::FileWatcher(IoQueue& io) 10 | : io_(io) 11 | , inotifyFd_(::inotify_init()) 12 | { 13 | if (inotifyFd_ < 0) { 14 | slog::fatal("inotify_init failed: ", errnoToString(errno)); 15 | std::exit(1); 16 | } 17 | 18 | read(); 19 | } 20 | 21 | FileWatcher::~FileWatcher() 22 | { 23 | for (const auto& [path, watch] : dirWatches_) { 24 | ::inotify_rm_watch(inotifyFd_, watch.wd); 25 | } 26 | dirWatches_.clear(); 27 | } 28 | 29 | bool FileWatcher::watch( 30 | std::string_view path, std::function callback) 31 | { 32 | const auto lastSep = path.rfind("/"); 33 | const auto dirPath = lastSep == std::string_view::npos ? std::string(".") 34 | : std::string(path.substr(0, lastSep)); 35 | auto it = dirWatches_.find(dirPath); 36 | if (it == dirWatches_.end()) { 37 | const auto wd = ::inotify_add_watch(inotifyFd_, dirPath.c_str(), IN_CLOSE_WRITE); 38 | if (wd < 0) { 39 | slog::error("Could not watch directory '", dirPath, "': ", errnoToString(errno)); 40 | return false; 41 | } 42 | it = dirWatches_.emplace(dirPath, DirWatch { dirPath, wd }).first; 43 | } 44 | auto& dirWatch = it->second; 45 | const auto filename = lastSep == std::string_view::npos ? std::string(path) 46 | : std::string(path.substr(lastSep + 1)); 47 | if (dirWatch.fileWatches.count(filename)) { 48 | slog::error("Already watching ", path); 49 | return false; 50 | } 51 | dirWatch.fileWatches.emplace( 52 | filename, FileWatch { std::string(path), filename, std::move(callback) }); 53 | return true; 54 | } 55 | 56 | void FileWatcher::read() 57 | { 58 | io_.read(inotifyFd_, eventBuffer_, eventBufferLen, 59 | [this](std::error_code ec, int readBytes) { onRead(ec, readBytes); }); 60 | } 61 | 62 | void FileWatcher::onRead(std::error_code ec, int readBytes) 63 | { 64 | if (ec) { 65 | slog::error("Error reading inotify fd: ", ec.message()); 66 | read(); 67 | return; 68 | } 69 | 70 | long i = 0; 71 | while (i < readBytes) { 72 | const auto event = reinterpret_cast(&eventBuffer_[i]); 73 | 74 | const auto dit = std::find_if(dirWatches_.begin(), dirWatches_.end(), 75 | [event](const auto& entry) { return entry.second.wd == event->wd; }); 76 | assert(dit != dirWatches_.end()); 77 | auto& dirWatch = dit->second; 78 | 79 | if (event->mask & IN_IGNORED) { 80 | // rewatch 81 | slog::debug("Rewatch '", dirWatch.path, "'"); 82 | dirWatch.wd = ::inotify_add_watch(inotifyFd_, dirWatch.path.c_str(), IN_CLOSE_WRITE); 83 | if (dirWatch.wd < 0) { 84 | slog::error( 85 | "Could not rewatch directory '", dirWatch.path, "': ", errnoToString(errno)); 86 | for (const auto& [filename, fileWatch] : dirWatch.fileWatches) { 87 | fileWatch.callback( 88 | std::make_error_code(static_cast(errno)), fileWatch.path); 89 | } 90 | dirWatches_.erase(dirWatch.path); 91 | } 92 | } else if (event->len > 0) { 93 | assert(event->mask & IN_CLOSE_WRITE); 94 | const auto filename = std::string(event->name); 95 | const auto fit = dirWatch.fileWatches.find(filename); 96 | if (fit != dirWatch.fileWatches.end()) { 97 | fit->second.callback(std::error_code(), fit->second.path); 98 | } 99 | } 100 | i += sizeof(inotify_event) + event->len; 101 | } 102 | // If the following assert fails, we have read an event partially. This should not happen. 103 | assert(i == readBytes); 104 | 105 | read(); 106 | } 107 | -------------------------------------------------------------------------------- /src/filewatcher.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "fd.hpp" 10 | #include "ioqueue.hpp" 11 | 12 | class FileWatcher { 13 | public: 14 | FileWatcher(IoQueue& io); 15 | 16 | ~FileWatcher(); 17 | 18 | bool watch(std::string_view path, 19 | std::function callback); 20 | 21 | private: 22 | static constexpr auto eventBufferLen = 8 * (sizeof(inotify_event) + NAME_MAX + 1); 23 | 24 | struct FileWatch { 25 | std::string path; 26 | std::string filename; 27 | std::function callback; 28 | }; 29 | 30 | struct DirWatch { 31 | std::string path; 32 | int wd; 33 | std::unordered_map fileWatches = {}; 34 | }; 35 | 36 | void read(); 37 | void onRead(std::error_code ec, int readBytes); 38 | 39 | IoQueue& io_; 40 | Fd inotifyFd_; 41 | std::unordered_map dirWatches_; 42 | 43 | char eventBuffer_[eventBufferLen]; 44 | }; 45 | -------------------------------------------------------------------------------- /src/function.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | template 6 | class Function; 7 | 8 | template 9 | class Function { 10 | public: 11 | Function() = default; 12 | 13 | Function(nullptr_t) { } 14 | 15 | template 16 | Function(Func&& func) 17 | { 18 | callable_.reset(new Callable { std::forward(func) }); 19 | } 20 | 21 | Function(const Function&) = delete; 22 | 23 | Function(Function&& other) 24 | : callable_(std::move(other.callable_)) 25 | { 26 | } 27 | 28 | template 29 | Function& operator=(Func&& func) 30 | { 31 | callable_.reset(new Callable { std::forward(func) }); 32 | return *this; 33 | } 34 | 35 | Function& operator=(Function&& other) 36 | { 37 | callable_ = std::move(other.callable_); 38 | return *this; 39 | } 40 | 41 | Function& operator=(nullptr_t) { callable_.reset(); } 42 | 43 | Function& operator=(const Function&) = delete; 44 | 45 | explicit operator bool() const { return callable_; } 46 | 47 | Ret operator()(Args... args) const 48 | { 49 | return callable_->operator()(std::forward(args)...); 50 | } 51 | 52 | private: 53 | struct CallableBase { 54 | virtual Ret operator()(Args...) const = 0; 55 | virtual ~CallableBase() = default; 56 | }; 57 | 58 | template 59 | struct Callable : public CallableBase { 60 | // Afaik std::function employs the same "trick" of making the stored function mutable, so we 61 | // can a define single const operator(). The problem is that even const-overloading 62 | // operator() would fail to compile for mutable functors, because the definition of 63 | // operator() const has to be valid, which it would not be. 64 | // std::move_only_function instead allows to inject the cv qualifier through the template 65 | // parameter, but I think I would have to specialize for each combination, which is a pain, 66 | // so I don't want to do that. 67 | mutable std::decay_t func; 68 | 69 | Callable(Func&& func) 70 | : func(std::forward(func)) 71 | { 72 | } 73 | 74 | Ret operator()(Args... args) const override { return func(std::forward(args)...); } 75 | }; 76 | 77 | std::unique_ptr callable_; 78 | }; 79 | 80 | /*int test(int x) 81 | { 82 | return x * 2; 83 | } 84 | 85 | struct ConstFunctor { 86 | int operator()(int x) const { return x * 7; } 87 | }; 88 | 89 | struct Functor { 90 | int value = 0; 91 | 92 | void operator()(int v) { value = v; } 93 | }; 94 | 95 | #include 96 | #include 97 | 98 | int main() 99 | { 100 | Function f = [](int x) { return x * 3; }; 101 | std::cout << f(7) << std::endl; 102 | f = test; 103 | std::cout << f(7) << std::endl; 104 | auto g = std::move(f); 105 | std::cout << g(9) << std::endl; 106 | Function n = ConstFunctor {}; 107 | n(4); 108 | Function m = Functor {}; 109 | m(6); 110 | int a = 0; 111 | Function h = [a](int x) mutable { a += x; }; 112 | h(12); 113 | }*/ -------------------------------------------------------------------------------- /src/hosthandler.cpp: -------------------------------------------------------------------------------- 1 | #include "hosthandler.hpp" 2 | 3 | #include 4 | 5 | #include 6 | 7 | #include "log.hpp" 8 | #include "string.hpp" 9 | 10 | namespace { 11 | std::string_view toString(std::filesystem::file_type status) 12 | { 13 | switch (status) { 14 | case std::filesystem::file_type::none: 15 | return "none"; 16 | case std::filesystem::file_type::not_found: 17 | return "not found"; 18 | case std::filesystem::file_type::regular: 19 | return "file"; 20 | case std::filesystem::file_type::directory: 21 | return "directory"; 22 | case std::filesystem::file_type::symlink: 23 | return "symlink"; 24 | case std::filesystem::file_type::block: 25 | return "block device"; 26 | case std::filesystem::file_type::character: 27 | return "character device"; 28 | case std::filesystem::file_type::fifo: 29 | return "fifo"; 30 | case std::filesystem::file_type::socket: 31 | return "socket"; 32 | case std::filesystem::file_type::unknown: 33 | return "unknown"; 34 | default: 35 | return "invalid"; 36 | } 37 | } 38 | } 39 | 40 | void HostHandler::Host::addHeaders(std::string_view requestPath, Response& response) const 41 | { 42 | for (const auto& rule : headers) { 43 | if (rule.pattern.match(requestPath).match) { 44 | for (const auto& [name, value] : rule.headers) { 45 | if (value.empty()) { 46 | response.headers.remove(name); 47 | } else { 48 | response.headers.set(name, value); 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | HostHandler::HostHandler(IoQueue& io, FileCache& fileCache, 56 | const std::unordered_map& config) 57 | : io_(io) 58 | , fileCache_(fileCache) 59 | { 60 | for (const auto& [name, host] : config) { 61 | hosts_.emplace_back(); 62 | hosts_.back().name = name; 63 | for (const auto& [urlPattern, fsPath] : host.files) { 64 | std::error_code ec; 65 | if (urlPattern.isLiteral()) { 66 | const auto canonical = std::filesystem::canonical(fsPath, ec); // Follow symlinks 67 | if (ec) { 68 | slog::error("Could not canonicalize '", fsPath, "': ", ec.message()); 69 | std::exit(1); 70 | } 71 | 72 | const auto status = std::filesystem::status(canonical, ec); 73 | if (ec) { 74 | slog::error("Could not stat '", canonical.string(), "': ", ec.message()); 75 | std::exit(1); 76 | } 77 | 78 | if (std::filesystem::is_directory(status)) { 79 | auto pattern = Pattern::create(pathJoin(urlPattern.raw(), "*")).value(); 80 | auto path = pathJoin(fsPath, "$1"); 81 | hosts_.back().files.push_back(FilesEntry { pattern, path, true }); 82 | slog::debug(name, ": '", pattern.raw(), "' -> '", path, "' (directory)"); 83 | } else { 84 | hosts_.back().files.push_back(FilesEntry { urlPattern, fsPath, false }); 85 | const auto severity = status.type() == std::filesystem::file_type::regular 86 | ? slog::Severity::Debug 87 | : slog::Severity::Warning; 88 | slog::log(severity, name, ": '", urlPattern.raw(), "' -> '", canonical, "' (", 89 | toString(status.type()), ")"); 90 | } 91 | } else { 92 | hosts_.back().files.push_back( 93 | FilesEntry { urlPattern, fsPath, Pattern::hasGroupReferences(fsPath) }); 94 | slog::debug(name, ": '", urlPattern.raw(), "' -> '", fsPath, "'"); 95 | } 96 | } 97 | hosts_.back().metrics = host.metrics; 98 | hosts_.back().headers = host.headers; 99 | hosts_.back().redirects = host.redirects; 100 | #ifdef TLS_SUPPORT_ENABLED 101 | if (host.acmeChallenges) { 102 | hosts_.back().acmeChallenges.push_back(getAcmeClient(*host.acmeChallenges)); 103 | } 104 | #endif 105 | } 106 | } 107 | 108 | HostHandler::HostHandler(const HostHandler& other) 109 | : io_(other.io_) 110 | , fileCache_(other.fileCache_) 111 | , hosts_(other.hosts_) 112 | { 113 | } 114 | 115 | void HostHandler::operator()(const Request& request, std::unique_ptr responder) const 116 | { 117 | const Host* host = nullptr; 118 | const auto hostHeader = request.headers.get("Host"); 119 | for (const auto& h : hosts_) { 120 | if (hostHeader && h.name == *hostHeader) { 121 | host = &h; 122 | break; 123 | } else if (h.name == "*") { 124 | host = &h; 125 | } 126 | } 127 | 128 | if (!host) { 129 | // RFC 2616: 130 | // All Internet-based HTTP/1.1 servers MUST respond with a 400 (Bad Request) status code 131 | // to any HTTP/1.1 request message which lacks a Host header field. 132 | // and: 133 | // If the host as determined by rule 1 or 2 is not a valid host on the server, the 134 | // response MUST be a 400 (Bad Request) error message. 135 | slog::debug("No matching host for '", hostHeader.value_or("(none)"), "'"); 136 | responder->respond(Response(StatusCode::BadRequest, "Bad Request")); 137 | return; 138 | } 139 | 140 | if (isMetricsRoute(*host, request)) { 141 | respondMetrics(*host, request, std::move(responder)); 142 | #ifdef TLS_SUPPORT_ENABLED 143 | } else if (const auto challenge = getAcmeChallenge(*host, request)) { 144 | respondAcmeChallenge(*challenge, request, std::move(responder)); 145 | #endif 146 | } else if (const auto redirect = getRedirect(*host, request)) { 147 | respondRedirect(*redirect, request, std::move(responder)); 148 | } else if (const auto file = getFile(*host, request)) { 149 | respondFile(*host, *file, request, std::move(responder)); 150 | } else { 151 | responder->respond(Response(StatusCode::NotFound, "Not Found")); 152 | } 153 | } 154 | 155 | bool HostHandler::isMetricsRoute(const HostHandler::Host& host, const Request& request) const 156 | { 157 | return host.metrics && request.url.path == *host.metrics; 158 | } 159 | 160 | void HostHandler::respondMetrics(const HostHandler::Host& host, const Request& request, 161 | std::unique_ptr responder) const 162 | { 163 | if (request.method != Method::Get) { 164 | responder->respond(Response(StatusCode::MethodNotAllowed)); 165 | return; 166 | } 167 | 168 | io_.async( 169 | []() { 170 | return Response( 171 | cpprom::Registry::getDefault().serialize(), "text/plain; version=0.0.4"); 172 | }, 173 | [&host, requestPath = request.url.path, responder = std::move(responder)]( 174 | std::error_code ec, Response&& response) mutable { 175 | assert(!ec); 176 | host.addHeaders(requestPath, response); 177 | responder->respond(std::move(response)); 178 | }); 179 | } 180 | 181 | #ifdef TLS_SUPPORT_ENABLED 182 | std::optional HostHandler::getAcmeChallenge( 183 | const Host& host, const Request& request) const 184 | { 185 | for (const auto& client : host.acmeChallenges) { 186 | const auto challenges = client->getChallenges(); 187 | for (const auto& challenge : *challenges) { 188 | if (challenge.path == request.url.path) { 189 | return challenge.content; 190 | } 191 | } 192 | } 193 | return std::nullopt; 194 | } 195 | 196 | void HostHandler::respondAcmeChallenge(const std::string& challengeContent, const Request& request, 197 | std::unique_ptr responder) const 198 | { 199 | if (request.method != Method::Get) { 200 | responder->respond(Response(StatusCode::MethodNotAllowed)); 201 | } 202 | // The example in RFC8555 also uses application/octet-stream: 203 | // https://www.rfc-editor.org/rfc/rfc8555#section-8.3 204 | responder->respond(Response(StatusCode::Ok, challengeContent, "application/octet-stream")); 205 | } 206 | #endif 207 | 208 | constexpr std::string_view redirectBody = R"( 209 | 210 | 211 | 301 Moved 212 | 213 | 214 |

301 Moved

215 | The document has moved here. 216 | 217 | )"; 218 | 219 | std::optional HostHandler::getRedirect( 220 | const HostHandler::Host& host, const Request& request) const 221 | { 222 | for (const auto& entry : host.redirects) { 223 | const auto res = entry.pattern.match(request.url.path); 224 | if (res.match) { 225 | return Pattern::replaceGroupReferences(entry.replacement, res.groups); 226 | } 227 | } 228 | return std::nullopt; 229 | } 230 | 231 | void HostHandler::respondRedirect( 232 | const std::string& target, const Request& request, std::unique_ptr responder) const 233 | { 234 | static constexpr auto locationStart = redirectBody.find("LOCATION"); 235 | static const auto redirectBodyPrefix = std::string(redirectBody.substr(0, locationStart)); 236 | static const auto redirectBodySuffix 237 | = std::string(redirectBody.substr(locationStart + std::string_view("LOCATION").size())); 238 | 239 | auto resp = Response(StatusCode::MovedPermanently); 240 | resp.headers.add("Location", target); 241 | if (request.method == Method::Get) { 242 | // RFC2616 says the response body SHOULD contain a note with a hyperlink to the new 243 | // URL, but if I don't add it, both curl and Firefox stall on the response forever 244 | // and never actually follow the redirect. 245 | resp.body = redirectBodyPrefix + target + redirectBodySuffix; 246 | resp.headers.add("Content-Type", "text/html"); 247 | } else if (request.method != Method::Head) { 248 | responder->respond(Response(StatusCode::MethodNotAllowed)); 249 | return; 250 | } 251 | responder->respond(std::move(resp)); 252 | } 253 | 254 | std::optional HostHandler::getFile( 255 | const HostHandler::Host& host, const Request& request) const 256 | { 257 | for (const auto& entry : host.files) { 258 | const auto res = entry.urlPattern.match(request.url.path); 259 | if (res.match) { 260 | if (entry.needsGroupReplacement) { 261 | return Pattern::replaceGroupReferences(entry.fsPath, res.groups); 262 | } else { 263 | return entry.fsPath; 264 | } 265 | } 266 | } 267 | return std::nullopt; 268 | } 269 | 270 | void HostHandler::respondFile(const HostHandler::Host& host, const std::string& path, 271 | const Request& request, std::unique_ptr responder) const 272 | { 273 | if (request.method != Method::Get && request.method != Method::Head) { 274 | responder->respond(Response(StatusCode::MethodNotAllowed)); 275 | return; 276 | } 277 | 278 | const auto f = fileCache_.get(path); 279 | if (!f) { 280 | responder->respond(Response(StatusCode::NotFound, "Not Found")); 281 | return; 282 | } 283 | 284 | const auto ifNoneMatch = request.headers.get("If-None-Match"); 285 | if (ifNoneMatch && ifNoneMatch->find(f->eTag) != std::string_view::npos) { 286 | // It seems to me I don't have to include ETag and Last-Modified here, but I am not sure. 287 | responder->respond(Response(StatusCode::NotModified)); 288 | return; 289 | } 290 | 291 | const auto ifModifiedSince = request.headers.get("If-Modified-Since"); 292 | if (ifModifiedSince && f->lastModified == *ifModifiedSince) { 293 | responder->respond(Response(StatusCode::NotModified)); 294 | return; 295 | } 296 | 297 | const auto extDelim = path.find_last_of('.'); 298 | const auto ext = path.substr(std::min(extDelim + 1, path.size())); 299 | auto resp = Response(StatusCode::Ok); 300 | resp.headers.add("ETag", f->eTag); 301 | resp.headers.add("Last-Modified", f->lastModified); 302 | resp.headers.add("Content-Type", getMimeType(std::string(ext))); 303 | if (request.method == Method::Get) { 304 | resp.body = f->contents.value(); 305 | } else { 306 | assert(request.method == Method::Head); 307 | resp.headers.add("Content-Length", std::to_string(f->contents->size())); 308 | } 309 | host.addHeaders(request.url.path, resp); 310 | responder->respond(std::move(resp)); 311 | } 312 | 313 | std::string HostHandler::getMimeType(const std::string& fileExt) 314 | { 315 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types 316 | static std::unordered_map mimeTypes { 317 | { "aac", "audio/aac" }, 318 | { "abw", "application/x-abiword" }, 319 | { "arc", "application/x-freearc" }, 320 | { "avif", "image/avif" }, 321 | { "avi", "video/x-msvideo" }, 322 | { "azw", "application/vnd.amazon.ebook" }, 323 | { "bin", "application/octet-stream" }, 324 | { "bmp", "image/bmp" }, 325 | { "bz", "application/x-bzip" }, 326 | { "bz2", "application/x-bzip2" }, 327 | { "cda", "application/x-cdf" }, 328 | { "csh", "application/x-csh" }, 329 | { "css", "text/css" }, 330 | { "csv", "text/csv" }, 331 | { "doc", "application/msword" }, 332 | { "docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, 333 | { "eot", "application/vnd.ms-fontobject" }, 334 | { "epub", "application/epub+zip" }, 335 | { "gz", "application/gzip" }, 336 | { "gif", "image/gif" }, 337 | { "htm", "text/html" }, 338 | { "html", "text/html" }, 339 | { "ico", "image/vnd.microsoft.icon" }, 340 | { "ics", "text/calendar" }, 341 | { "jar", "application/java-archive" }, 342 | { "jpeg", "image/jpeg" }, 343 | { "jpg", "image/jpeg" }, 344 | { "js", "text/javascript" }, 345 | { "json", "application/json" }, 346 | { "jsonld", "application/ld+json" }, 347 | { "mid", "audio/midi" }, 348 | { "midi", "audio/midi" }, 349 | { "mjs", "text/javascript" }, 350 | { "mp3", "audio/mpeg" }, 351 | { "mp4", "video/mp4" }, 352 | { "mpeg", "video/mpeg" }, 353 | { "mpkg", "application/vnd.apple.installer+xml" }, 354 | { "odp", "application/vnd.oasis.opendocument.presentation" }, 355 | { "ods", "application/vnd.oasis.opendocument.spreadsheet" }, 356 | { "odt", "application/vnd.oasis.opendocument.text" }, 357 | { "oga", "audio/ogg" }, 358 | { "ogv", "video/ogg" }, 359 | { "ogx", "application/ogg" }, 360 | { "opus", "audio/opus" }, 361 | { "otf", "font/otf" }, 362 | { "png", "image/png" }, 363 | { "pdf", "application/pdf" }, 364 | { "php", "application/x-httpd-php" }, 365 | { "ppt", "application/vnd.ms-powerpoint" }, 366 | { "pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, 367 | { "rar", "application/vnd.rar" }, 368 | { "rtf", "application/rtf" }, 369 | { "sh", "application/x-sh" }, 370 | { "svg", "image/svg+xml" }, 371 | { "swf", "application/x-shockwave-flash" }, 372 | { "tar", "application/x-tar" }, 373 | { "tif", "image/tiff" }, 374 | { "tiff", "image/tiff" }, 375 | { "ts", "video/mp2t" }, 376 | { "ttf", "font/ttf" }, 377 | { "txt", "text/plain" }, 378 | { "vsd", "application/vnd.visio" }, 379 | { "wav", "audio/wav" }, 380 | { "weba", "audio/webm" }, 381 | { "webm", "video/webm" }, 382 | { "webp", "image/webp" }, 383 | { "woff", "font/woff" }, 384 | { "woff2", "font/woff2" }, 385 | { "xhtml", "application/xhtml+xml" }, 386 | { "xls", "application/vnd.ms-excel" }, 387 | { "xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, 388 | { "xml", "application/xml" }, 389 | { "xul", "application/vnd.mozilla.xul+xml" }, 390 | { "zip", "application/zip" }, 391 | { "3gp", "video/3gpp" }, // could be audio/3gpp as well 392 | { "3g2", "video/3gpp2" }, // could be audio/3gpp2 as well 393 | { "7z", "application/x-7z-compressed" }, 394 | }; 395 | const auto it = mimeTypes.find(fileExt); 396 | if (it == mimeTypes.end()) { 397 | return "application/octet-stream"; 398 | } 399 | return it->second; 400 | } 401 | -------------------------------------------------------------------------------- /src/hosthandler.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "config.hpp" 4 | #include "filecache.hpp" 5 | #include "http.hpp" 6 | #include "pattern.hpp" 7 | #include "server.hpp" 8 | 9 | #ifdef TLS_SUPPORT_ENABLED 10 | #include "acme.hpp" 11 | #endif 12 | 13 | class HostHandler { 14 | public: 15 | HostHandler(IoQueue& io, FileCache& fileCache, 16 | const std::unordered_map& config); 17 | 18 | HostHandler(const HostHandler& other); 19 | 20 | void operator()(const Request& request, std::unique_ptr responder) const; 21 | 22 | private: 23 | struct FilesEntry { 24 | Pattern urlPattern; 25 | std::string fsPath; 26 | bool needsGroupReplacement; 27 | }; 28 | 29 | struct Host { 30 | std::string name; 31 | std::vector files; 32 | std::optional metrics; 33 | std::vector headers; 34 | #ifdef TLS_SUPPORT_ENABLED 35 | // weak_ptr would probably be better, but I don't want to pay for it. 36 | // The connection factory owns the AcmeClient. 37 | std::vector acmeChallenges; 38 | #endif 39 | std::vector redirects; 40 | 41 | void addHeaders(std::string_view requestPath, Response& response) const; 42 | }; 43 | 44 | static std::string getMimeType(const std::string& fileExt); 45 | 46 | bool isMetricsRoute(const Host& host, const Request& request) const; 47 | void respondMetrics( 48 | const Host& host, const Request&, std::unique_ptr responder) const; 49 | 50 | #ifdef TLS_SUPPORT_ENABLED 51 | std::optional getAcmeChallenge(const Host& host, const Request& request) const; 52 | void respondAcmeChallenge(const std::string& challengeContent, const Request& request, 53 | std::unique_ptr responder) const; 54 | #endif 55 | 56 | std::optional getRedirect(const Host& host, const Request& request) const; 57 | void respondRedirect(const std::string& target, const Request& request, 58 | std::unique_ptr responder) const; 59 | 60 | std::optional getFile(const Host& host, const Request& request) const; 61 | void respondFile(const Host& host, const std::string& path, const Request& request, 62 | std::unique_ptr responder) const; 63 | 64 | IoQueue& io_; 65 | FileCache& fileCache_; 66 | std::vector hosts_; 67 | }; 68 | -------------------------------------------------------------------------------- /src/htcpp.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include 4 | 5 | #include "hosthandler.hpp" 6 | #include "log.hpp" 7 | #include "tcp.hpp" 8 | 9 | #ifdef TLS_SUPPORT_ENABLED 10 | #include "ssl.hpp" 11 | #endif 12 | 13 | using namespace std::literals; 14 | 15 | template <> 16 | struct clipp::Value { 17 | static constexpr std::string_view typeName = "[address:]port"; 18 | 19 | static std::optional parse(std::string_view str) { return IpPort::parse(str); } 20 | }; 21 | 22 | struct Args : clipp::ArgsBase { 23 | std::optional listen; 24 | bool debug = false; 25 | bool checkConfig = false; 26 | // bool followSymlinks; 27 | // bool browse; 28 | std::optional metrics; 29 | std::optional arg = "."; 30 | 31 | void args() 32 | { 33 | flag(listen, "listen", 'l').valueNames("IPPORT").help("ip:port or port"); 34 | flag(debug, "debug").help("Enable debug logging"); 35 | flag(checkConfig, "check-config").help("Check the configuration and exit"); 36 | // flag(followSymlinks, "follow", 'f').help("Follow symlinks"); 37 | // flag(browse, "browse", 'b'); 38 | flag(metrics, "metrics", 'm') 39 | .valueNames("ENDPOINT") 40 | .help("Endpoint for Prometheus-compatible metrics"); 41 | positional(arg, "arg"); 42 | } 43 | }; 44 | 45 | int main(int argc, char** argv) 46 | { 47 | auto parser = clipp::Parser(argv[0]); 48 | parser.version("1.1.0"); 49 | const Args args = parser.parse(argc, argv).value(); 50 | slog::init(args.debug ? slog::Severity::Debug : slog::Severity::Info); 51 | 52 | auto& config = Config::get(); 53 | if (std::filesystem::is_regular_file(args.arg.value())) { 54 | if (!config.loadFromFile(*args.arg)) { 55 | return 1; 56 | } 57 | } else if (std::filesystem::is_directory(args.arg.value())) { 58 | auto& service = config.services.emplace_back(); 59 | Config::Service::Host host; 60 | host.files.push_back({ Pattern::create("/*").value(), pathJoin(*args.arg, "$1") }); 61 | host.metrics = args.metrics; 62 | host.headers.push_back( 63 | { Pattern::create("*").value(), { { "Cache-Control", "no-store" } } }); 64 | service.hosts.emplace("*", std::move(host)); 65 | } else { 66 | slog::error("Invalid argument. Must either be a config file or a directory to serve"); 67 | return 1; 68 | } 69 | 70 | if (args.checkConfig) { 71 | return 0; 72 | } 73 | 74 | if (args.listen) { 75 | if (args.listen->ip) { 76 | config.services.back().listenAddress = *args.listen->ip; 77 | } 78 | config.services.back().listenPort = args.listen->port; 79 | } 80 | 81 | IoQueue io(config.ioQueueSize, config.ioSubmissionQueuePolling); 82 | 83 | // We share a file cache, because we don't need to separate them per host (in fact we might risk 84 | // duplication otherwise) 85 | FileCache fileCache(io); 86 | 87 | std::vector>> tcpServers; 88 | 89 | #ifdef TLS_SUPPORT_ENABLED 90 | std::vector>> sslServers; 91 | std::vector>> acmeSslServers; 92 | 93 | for (const auto& [name, config] : config.acme) { 94 | registerAcmeClient(name, io, config); 95 | } 96 | #endif 97 | 98 | for (const auto& service : config.services) { 99 | HostHandler handler(io, fileCache, service.hosts); 100 | 101 | #ifdef TLS_SUPPORT_ENABLED 102 | if (service.tls) { 103 | if (service.tls->acme) { 104 | auto factory = AcmeSslConnectionFactory { getAcmeClient(*service.tls->acme) }; 105 | auto server = std::make_unique>( 106 | io, std::move(factory), std::move(handler), service); 107 | server->start(); 108 | acmeSslServers.push_back(std::move(server)); 109 | } else { 110 | assert(service.tls->chain && service.tls->key); 111 | auto factory 112 | = SslServerConnectionFactory(io, *service.tls->chain, *service.tls->key); 113 | if (!factory.contextManager->getCurrentContext()) { 114 | return 1; 115 | } 116 | 117 | auto server = std::make_unique>( 118 | io, std::move(factory), std::move(handler), service); 119 | server->start(); 120 | sslServers.push_back(std::move(server)); 121 | } 122 | } else { 123 | auto server = std::make_unique>( 124 | io, TcpConnectionFactory {}, std::move(handler), service); 125 | server->start(); 126 | tcpServers.push_back(std::move(server)); 127 | } 128 | #else 129 | auto server = std::make_unique>( 130 | io, TcpConnectionFactory {}, std::move(handler), service); 131 | server->start(); 132 | tcpServers.push_back(std::move(server)); 133 | #endif 134 | 135 | for (const auto& [name, host] : service.hosts) { 136 | std::vector hosting; 137 | if (host.files.size()) { 138 | hosting.push_back("files"); 139 | } 140 | if (host.metrics) { 141 | hosting.push_back("metrics"); 142 | } 143 | if (host.acmeChallenges) { 144 | hosting.push_back("acme-challenges"); 145 | } 146 | if (host.redirects.size()) { 147 | hosting.push_back("redirects"); 148 | } 149 | slog::info("Host '", name, "': ", join(hosting)); 150 | } 151 | } 152 | io.run(); 153 | return 0; 154 | } 155 | -------------------------------------------------------------------------------- /src/http.cpp: -------------------------------------------------------------------------------- 1 | #include "http.hpp" 2 | 3 | #include 4 | 5 | #include "config.hpp" 6 | #include "log.hpp" 7 | 8 | std::optional parseMethod(std::string_view method) 9 | { 10 | // RFC2616, 5.1.1: "The method is case-sensitive" 11 | if (method == "GET") { 12 | return Method::Get; 13 | } else if (method == "HEAD") { 14 | return Method::Head; 15 | } else if (method == "POST") { 16 | return Method::Post; 17 | } else if (method == "PUT") { 18 | return Method::Put; 19 | } else if (method == "DELETE") { 20 | return Method::Delete; 21 | } else if (method == "CONNECT") { 22 | return Method::Connect; 23 | } else if (method == "OPTIONS") { 24 | return Method::Options; 25 | } else if (method == "TRACE") { 26 | return Method::Trace; 27 | } else if (method == "PATCH") { 28 | return Method::Patch; 29 | } 30 | return std::nullopt; 31 | } 32 | 33 | std::string toString(Method method) 34 | { 35 | switch (method) { 36 | case Method::Get: 37 | return "GET"; 38 | case Method::Head: 39 | return "HEAD"; 40 | case Method::Post: 41 | return "POST"; 42 | case Method::Put: 43 | return "PUT"; 44 | case Method::Delete: 45 | return "DELETE"; 46 | case Method::Connect: 47 | return "CONNECT"; 48 | case Method::Options: 49 | return "OPTIONS"; 50 | case Method::Trace: 51 | return "TRACE"; 52 | case Method::Patch: 53 | return "PATCH"; 54 | default: 55 | return "invalid"; 56 | } 57 | } 58 | 59 | template 60 | HeaderMap::HeaderMap(std::vector> h) 61 | : headers_(std::move(h)) 62 | { 63 | } 64 | 65 | template 66 | bool HeaderMap::contains(std::string_view name) const 67 | { 68 | return find(name).has_value(); 69 | } 70 | 71 | template 72 | std::optional HeaderMap::get(std::string_view name) const 73 | { 74 | const auto idx = find(name); 75 | if (idx) { 76 | return headers_[*idx].second; 77 | } else { 78 | return std::nullopt; 79 | } 80 | } 81 | 82 | template 83 | std::vector HeaderMap::getAll(std::string_view name) const 84 | { 85 | std::vector values; 86 | for (const auto& [k, v] : headers_) { 87 | if (ciEqual(k, name)) { 88 | values.push_back(v); 89 | } 90 | } 91 | return values; 92 | } 93 | 94 | template 95 | void HeaderMap::add(std::string_view name, std::string_view value) 96 | { 97 | headers_.emplace_back(StringType(name), StringType(value)); 98 | } 99 | 100 | template 101 | size_t HeaderMap::set(std::string_view name, std::string_view value) 102 | { 103 | const auto removed = remove(name); 104 | add(name, value); 105 | return removed; 106 | } 107 | 108 | template 109 | size_t HeaderMap::remove(std::string_view name) 110 | { 111 | size_t removed = 0; 112 | for (auto it = headers_.begin(); it != headers_.end();) { 113 | if (ciEqual(it->first, name)) { 114 | it = headers_.erase(it); 115 | removed++; 116 | } else { 117 | ++it; 118 | } 119 | } 120 | return removed; 121 | } 122 | 123 | template 124 | std::optional HeaderMap::operator[](std::string_view name) const 125 | { 126 | return get(name); 127 | } 128 | 129 | template 130 | const std::vector>& HeaderMap::getEntries() const 131 | { 132 | return headers_; 133 | } 134 | 135 | template 136 | void HeaderMap::serialize(std::string& str) const 137 | { 138 | for (const auto& [name, value] : headers_) { 139 | str.append(name); 140 | str.append(": "); 141 | str.append(value); 142 | str.append("\r\n"); 143 | } 144 | } 145 | 146 | template 147 | bool HeaderMap::parse(std::string_view str) 148 | { 149 | size_t cursor = 0; 150 | while (cursor < str.size()) { 151 | const auto headerLineEnd = str.find("\r\n", cursor); 152 | const auto line = str.substr(cursor, 153 | headerLineEnd == std::string_view::npos ? headerLineEnd : headerLineEnd - cursor); 154 | auto colon = line.find(':'); 155 | if (colon == std::string_view::npos) { 156 | slog::debug("No colon in header line"); 157 | return false; 158 | } 159 | const auto name = line.substr(0, colon); 160 | const auto value = httpTrim(line.substr(colon + 1)); 161 | add(name, value); 162 | if (headerLineEnd == std::string_view::npos) { 163 | break; 164 | } 165 | cursor = headerLineEnd + 2; 166 | } 167 | return true; 168 | } 169 | 170 | template 171 | std::optional HeaderMap::find(std::string_view name) const 172 | { 173 | for (size_t i = 0; i < headers_.size(); ++i) { 174 | if (ciEqual(headers_[i].first, name)) { 175 | return i; 176 | } 177 | } 178 | return std::nullopt; 179 | } 180 | 181 | template class HeaderMap; 182 | template class HeaderMap; 183 | 184 | namespace { 185 | std::string removeDotSegments(std::string_view input) 186 | { 187 | // RFC3986, 5.2.4: Remove Dot Segments 188 | // This algorithm is a bit different, because of the following assert (ensured in Url::parse). 189 | // If we leave the trailing slashes in the input buffer, we know that after every step in the 190 | // loop below, inputLeft still starts with a slash. 191 | assert(!input.empty() && input[0] == '/'); 192 | std::string output; 193 | output.reserve(input.size()); 194 | while (!input.empty()) { 195 | assert(input[0] == '/'); 196 | 197 | if (input == "/") { 198 | output.push_back('/'); 199 | break; 200 | } else { 201 | // I think it's not very clear, why this works in all cases, but if I go through all 202 | // cases one by one instead, it's just a bunch of ifs with the same code in each branch. 203 | const auto segmentLength = input.find('/', 1); 204 | const auto segment = input.substr(0, segmentLength); 205 | 206 | if (segment == "/.") { 207 | // do nothing 208 | } else if (segment == "/..") { 209 | // Removing trailing segment (including slash) from output buffer 210 | const auto lastSlash = output.rfind('/'); 211 | if (lastSlash != std::string::npos) { 212 | output.resize(lastSlash); 213 | } else { 214 | // Considering that every segment starts with a slash, output must be empty 215 | assert(output.empty()); 216 | } 217 | } else { 218 | output.append(segment); 219 | } 220 | 221 | if (segmentLength == std::string_view::npos) { 222 | break; 223 | } else { 224 | input = input.substr(segmentLength); 225 | } 226 | } 227 | } 228 | if (output.empty()) { 229 | output.push_back('/'); 230 | } 231 | return output; 232 | } 233 | 234 | bool isAlphaNum(char ch) 235 | { 236 | return (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || (ch >= 'A' || ch <= 'Z'); 237 | } 238 | 239 | bool isSchemeChar(char ch) 240 | { 241 | return isAlphaNum(ch) || ch == '+' || ch == '.' || ch == '-'; 242 | } 243 | } 244 | 245 | std::optional Url::parse(std::string_view urlStr) 246 | { 247 | constexpr auto npos = std::string_view::npos; 248 | 249 | Url url; 250 | url.fullRaw = urlStr; 251 | urlStr = std::string_view(url.fullRaw); 252 | 253 | // There was a case for urlStr == "*" before, but it was referencing a section in an RFC does 254 | // not exist. I'll leave this comment in case a more knowledgable me in the future knows what 255 | // this was about. 256 | 257 | // I don't *actually* support CONNECT, so I will not parse authority URIs. 258 | 259 | // RFC1808, 2.4.1: The fragment is not technically part of the URL 260 | const auto fragmentStart = urlStr.find('#'); 261 | if (fragmentStart != npos) { 262 | url.fragment = urlStr.substr(fragmentStart + 1); 263 | urlStr = urlStr.substr(0, fragmentStart); 264 | } 265 | 266 | if (urlStr.empty()) { 267 | return std::nullopt; 268 | } 269 | 270 | // The other possible URLs are absoluteURI and abs_path and I have to parse absoluteURI: 271 | // "To allow for transition to absoluteURIs in all requests in future 272 | // versions of HTTP, all HTTP / 1.1 servers MUST accept the absoluteURI form in requests, even 273 | // though HTTP/1.1 clients will only generate them in requests to proxies." 274 | // I won't save any of the URI components that are part of absoluteURI (and not abs_path) 275 | // because I don't need them (even though I should). 276 | const auto colon = urlStr.find(':'); 277 | if (colon != npos) { 278 | // RFC1808, 2.4.2: If all characters up to this colon are valid characters for a scheme, 279 | // [0, colon) is a scheme. 280 | bool isScheme = true; 281 | for (size_t i = 0; i < colon; ++i) { 282 | if (!isSchemeChar(urlStr[i])) { 283 | isScheme = false; 284 | break; 285 | } 286 | } 287 | 288 | if (isScheme) { 289 | url.scheme = urlStr.substr(0, colon); 290 | // If we wanted to save the scheme 291 | urlStr = urlStr.substr(colon + 1); 292 | } 293 | } 294 | 295 | // RFC1808, 2.4.3 296 | if (urlStr.size() >= 2 && urlStr.substr(0, 2) == "//") { 297 | // I MUST (RFC2616, 5.2) with 400 if net_loc does not contain a valid host for this server, 298 | // but I don't want to add configuration for this, so I choose to be more "lenient" here and 299 | // ignore it completely. choose to be more "lenient" here and simply ignore it completely. 300 | const auto pathStart = urlStr.find("/", 2); 301 | if (pathStart == npos) { 302 | return std::nullopt; 303 | } 304 | url.netLoc = urlStr.substr(2, pathStart - 2); 305 | const auto at = url.netLoc.find('@'); 306 | const auto hostPortStart = at == npos ? 0 : at + 1; 307 | const auto hostPort = url.netLoc.substr(hostPortStart); 308 | const auto portDelim = hostPort.find(':'); 309 | url.host = hostPort.substr(0, portDelim); 310 | if (portDelim != npos) { 311 | const auto port = parseInt(hostPort.substr(portDelim + 1)); 312 | if (!port) { 313 | return std::nullopt; 314 | } 315 | url.port = *port; 316 | } 317 | urlStr = urlStr.substr(pathStart); 318 | } 319 | url.targetRaw = urlStr; 320 | 321 | // RFC1808, 2.4.4 322 | const auto queryStart = urlStr.find('?'); 323 | if (queryStart != npos) { 324 | url.query = urlStr.substr(queryStart + 1); 325 | urlStr = urlStr.substr(0, queryStart); 326 | } 327 | 328 | // RFC1808, 2.4.5 329 | const auto paramsStart = urlStr.find(';'); 330 | if (paramsStart != npos) { 331 | url.params = urlStr.substr(paramsStart + 1); 332 | urlStr = urlStr.substr(0, paramsStart); 333 | } 334 | 335 | // If the URI is absoluteURI, we jumped to the slash, otherwise it has to be 336 | // abs_path, which must start with a slash. (RFC1808, 2.2) 337 | if (urlStr.empty() || urlStr[0] != '/') { 338 | return std::nullopt; 339 | } 340 | url.path = removeDotSegments(urlStr); 341 | 342 | return url; 343 | } 344 | 345 | std::optional Request::parse(std::string_view requestStr) 346 | { 347 | // e.g.: GET /foobar/barbar HTTP/1.1\r\nHost: example.org\r\n\r\n 348 | Request req; 349 | 350 | const auto requestLineEnd = requestStr.find("\r\n"); 351 | if (requestLineEnd == std::string::npos) { 352 | slog::debug("No request line end"); 353 | return std::nullopt; 354 | } 355 | req.requestLine = requestStr.substr(0, requestLineEnd); 356 | 357 | const auto methodDelim = req.requestLine.find(' '); 358 | if (methodDelim == std::string::npos) { 359 | slog::debug("No method delimiter"); 360 | return std::nullopt; 361 | } 362 | const auto methodStr = req.requestLine.substr(0, methodDelim); 363 | // We'll allow OPTIONS in HTTP/1.0 too 364 | const auto method = parseMethod(methodStr); 365 | if (!method) { 366 | slog::debug("Invalid method"); 367 | return std::nullopt; 368 | } 369 | req.method = *method; 370 | 371 | // I could skip all whitespace here to be more robust, but RFC2616 5.1 only mentions 1 SP 372 | const auto urlStart = methodDelim + 1; 373 | if (urlStart >= req.requestLine.size()) { 374 | slog::debug("No URL"); 375 | return std::nullopt; 376 | } 377 | const auto urlLen = req.requestLine.substr(urlStart).find(' '); 378 | if (urlLen == std::string::npos) { 379 | slog::debug("No URL end"); 380 | return std::nullopt; 381 | } 382 | const auto url = Url::parse(req.requestLine.substr(urlStart, urlLen)); 383 | if (!url) { 384 | slog::debug("Invalid URL"); 385 | return std::nullopt; 386 | } 387 | req.url = url.value(); 388 | 389 | const auto versionStart = urlStart + urlLen + 1; 390 | if (versionStart > req.requestLine.size()) { 391 | slog::debug("No version start"); 392 | return std::nullopt; 393 | } 394 | req.version = req.requestLine.substr(versionStart); 395 | 396 | if (req.version.size() != 8 || req.version.substr(0, 7) != "HTTP/1." 397 | || (req.version[7] != '0' && req.version[7] != '1')) { 398 | slog::debug("Invalid version"); 399 | return std::nullopt; 400 | } 401 | 402 | const auto headersStart = requestLineEnd + 2; 403 | const auto headersEnd = requestStr.find("\r\n\r\n", headersStart); 404 | 405 | if (headersEnd == std::string_view::npos) { 406 | slog::debug("No headers end"); 407 | return std::nullopt; 408 | } 409 | 410 | // +2 to terminate the last header line 411 | if (!req.headers.parse(requestStr.substr(headersStart, headersEnd + 2 - headersStart))) { 412 | return std::nullopt; 413 | } 414 | 415 | req.body = requestStr.substr(headersEnd + 4); 416 | 417 | return req; 418 | } 419 | 420 | Response::Response() 421 | : status(StatusCode::Invalid) 422 | { 423 | } 424 | 425 | Response::Response(std::string body) 426 | : body(std::move(body)) 427 | { 428 | addServerHeader(); 429 | } 430 | 431 | Response::Response(std::string body, std::string_view contentType) 432 | : body(std::move(body)) 433 | { 434 | addServerHeader(); 435 | headers.add("Content-Type", contentType); 436 | } 437 | 438 | Response::Response(StatusCode status, std::string body) 439 | : status(status) 440 | , body(std::move(body)) 441 | { 442 | addServerHeader(); 443 | } 444 | 445 | Response::Response(StatusCode status) 446 | : status(status) 447 | { 448 | addServerHeader(); 449 | } 450 | 451 | Response::Response(StatusCode status, std::string body, std::string_view contentType) 452 | : status(status) 453 | , body(std::move(body)) 454 | { 455 | addServerHeader(); 456 | headers.add("Content-Type", contentType); 457 | } 458 | 459 | void Response::addServerHeader() 460 | { 461 | // I think it's useful to provide this header so clients can work around issues, 462 | // but I avoid the version, because this might expose too much information (like your server 463 | // being outdated or your patch cycle). E.g. if the server version bumps only on thursdays, that 464 | // could be valuable information. 465 | // If I add a reverse-proxy mode, I must not add this. 466 | headers.add("Server", "htcpp"); 467 | } 468 | 469 | std::string Response::string(std::string_view httpVersion) const 470 | { 471 | std::string s; 472 | s.reserve(512); 473 | auto size = 12 + 2; // status line 474 | const auto headerEntries = headers.getEntries(); 475 | for (const auto& [name, value] : headerEntries) { 476 | size += name.size() + value.size() + 4; 477 | } 478 | size += 2; 479 | size += body.size(); 480 | s.append(httpVersion); 481 | s.append(" "); 482 | s.append(std::to_string(static_cast(status))); 483 | // The reason phrase may be empty, but the separator space is not optional 484 | s.append(" \r\n"); 485 | headers.serialize(s); 486 | if (!headers.contains("Content-Length") && !body.empty()) { 487 | s.append("Content-Length: "); 488 | s.append(std::to_string(body.size())); 489 | s.append("\r\n"); 490 | } 491 | s.append("\r\n"); 492 | s.append(body); 493 | return s; 494 | } 495 | 496 | std::optional Response::parse(std::string_view responseStr) 497 | { 498 | if (responseStr.substr(0, 7) != "HTTP/1.") { 499 | slog::debug("Response doesn't start with HTTP"); 500 | return std::nullopt; 501 | } 502 | const auto statusLineEnd = responseStr.find("\r\n"); 503 | if (!statusLineEnd) { 504 | slog::debug("No status line end"); 505 | return std::nullopt; 506 | } 507 | const auto statusLine = responseStr.substr(0, statusLineEnd); 508 | 509 | const auto statusStart = statusLine.find(' ') + 1; 510 | const auto statusEnd = statusLine.find_first_of(" \r\n", statusStart); 511 | const auto statusCodeStr = statusLine.substr(statusStart, statusEnd - statusStart); 512 | const auto statusCode = parseInt(statusCodeStr); 513 | if (!statusCode) { 514 | slog::debug("Invalid status code: '", statusCodeStr, "'"); 515 | return std::nullopt; 516 | } 517 | 518 | const auto headersStart = statusLineEnd + 2; 519 | const auto headersEnd = responseStr.find("\r\n\r\n", headersStart); 520 | 521 | if (headersEnd == std::string_view::npos) { 522 | slog::debug("No headers end"); 523 | return std::nullopt; 524 | } 525 | 526 | Response resp; 527 | 528 | resp.status = static_cast(*statusCode); 529 | 530 | // +2 so the last header line is terminated as well (makes parsing the headers easier) 531 | if (!resp.headers.parse(responseStr.substr(headersStart, headersEnd + 2 - headersStart))) { 532 | return std::nullopt; 533 | } 534 | 535 | resp.body = responseStr.substr(headersEnd + 4); 536 | 537 | return resp; 538 | } 539 | -------------------------------------------------------------------------------- /src/http.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #include "string.hpp" 10 | 11 | enum class Method { 12 | Get, 13 | Head, 14 | Post, 15 | Put, 16 | Delete, 17 | Connect, 18 | Options, 19 | Trace, 20 | Patch, 21 | }; 22 | 23 | std::optional parseMethod(std::string_view method); 24 | std::string toString(Method method); 25 | 26 | enum class StatusCode : uint32_t { 27 | Invalid = 0, 28 | 29 | // 1xx = Informational Response 30 | Continue = 100, 31 | SwitchingProtocols = 101, 32 | Processing = 102, 33 | EarlyHints = 103, 34 | 35 | // 2xx = Success 36 | Ok = 200, 37 | Created = 201, 38 | Accepted = 202, 39 | NonAuthoritativeInformation = 203, 40 | NoContent = 204, 41 | ResetContent = 205, 42 | PartialContent = 206, 43 | MultiStatus = 207, 44 | AlreadyReported = 208, 45 | ImUsed = 209, 46 | 47 | // 3xx = Redirection 48 | MultipleChoices = 300, 49 | MovedPermanently = 301, 50 | Found = 302, 51 | SeeOther = 303, 52 | NotModified = 304, 53 | UseProxy = 305, 54 | SwitchProxy = 306, 55 | TemporaryRedirect = 307, 56 | PermanentRedirect = 308, 57 | 58 | // 4xx = Client Errors 59 | BadRequest = 400, 60 | Unauthorized = 401, 61 | PaymentRequired = 402, 62 | Forbidden = 403, 63 | NotFound = 404, 64 | MethodNotAllowed = 405, 65 | NotAcceptable = 406, 66 | ProxyAuthenticationRequired = 407, 67 | RequestTimeout = 408, 68 | Conflict = 409, 69 | Gone = 410, 70 | LengthRequired = 411, 71 | PreconditionFailed = 412, 72 | PayloadTooLarge = 413, 73 | UriTooLong = 414, 74 | UnsupportedMediaType = 415, 75 | RangeNotSatisfiable = 416, 76 | ExpectationFailed = 417, 77 | ImATeapot = 418, 78 | MisdirectedRequest = 421, 79 | UnprocessableEntity = 422, 80 | Locked = 423, 81 | FailedDependency = 424, 82 | TooEarly = 425, 83 | UpgradeRequired = 426, 84 | PreconditionRequired = 428, 85 | TooManyRequests = 429, 86 | RequestHeaderFieldsTooLarge = 431, 87 | UnavailableForLegalReasons = 451, 88 | 89 | // 5xx = Server Errors 90 | InternalServerError = 500, 91 | NotImplemented = 501, 92 | BadGateway = 502, 93 | ServiceUnavailable = 503, 94 | GatewayTimeout = 504, 95 | HttpVersionNotSupported = 505, 96 | VariantAlsoNegotiates = 506, 97 | InsufficientStorage = 507, 98 | LoopDetected = 508, 99 | NotExtended = 510, 100 | NetworkAuthenticationRequired = 511, 101 | }; 102 | 103 | template 104 | class HeaderMap { 105 | public: 106 | HeaderMap() = default; 107 | HeaderMap(std::vector> h); 108 | 109 | bool contains(std::string_view name) const; 110 | std::optional get(std::string_view name) const; 111 | std::vector getAll(std::string_view name) const; 112 | std::optional operator[](std::string_view name) const; // get 113 | const std::vector>& getEntries() const; 114 | 115 | void add(std::string_view name, std::string_view value); 116 | size_t set(std::string_view name, std::string_view value); 117 | size_t remove(std::string_view name); 118 | 119 | bool parse(std::string_view str); 120 | void serialize(std::string& str) const; 121 | 122 | private: 123 | std::optional find(std::string_view name) const; 124 | 125 | std::vector> headers_; 126 | }; 127 | 128 | extern template class HeaderMap; 129 | extern template class HeaderMap; 130 | 131 | struct Url { 132 | std::string fullRaw; 133 | // Most of these are view referencing 'fullRaw'. 134 | std::string_view scheme; 135 | std::string_view netLoc; 136 | std::string_view host; // this is a substring of netLoc 137 | uint16_t port = 0; 138 | // This is not a view because of the removal of dot segments. 139 | std::string_view targetRaw; 140 | std::string path; 141 | std::string_view params; 142 | std::string_view query; 143 | std::string_view fragment; // This is not technically considered part of the URL (RFC1808) 144 | 145 | static std::optional parse(std::string_view urlStr); 146 | }; 147 | 148 | struct Request { 149 | std::string_view requestLine; // for access log 150 | Method method; 151 | Url url; 152 | std::string_view version; 153 | HeaderMap headers; 154 | std::string_view body; 155 | 156 | std::unordered_map params; 157 | 158 | static std::optional parse(std::string_view requestStr); 159 | }; 160 | 161 | struct Response { 162 | StatusCode status = StatusCode::Ok; 163 | HeaderMap headers; 164 | std::string body = {}; 165 | 166 | Response(); 167 | 168 | Response(std::string body); 169 | 170 | Response(std::string body, std::string_view contentType); 171 | 172 | Response(StatusCode status); 173 | 174 | Response(StatusCode status, std::string body); 175 | 176 | Response(StatusCode status, std::string body, std::string_view contentType); 177 | 178 | void addServerHeader(); 179 | 180 | std::string string(std::string_view httpVersion = "HTTP/1.1") const; 181 | 182 | static std::optional parse(std::string_view responseStr); 183 | }; 184 | -------------------------------------------------------------------------------- /src/ioqueue.cpp: -------------------------------------------------------------------------------- 1 | #include "ioqueue.hpp" 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | #include "log.hpp" 9 | #include "metrics.hpp" 10 | #include "util.hpp" 11 | 12 | void IoQueue::setRelativeTimeout(Timespec* ts, uint64_t milliseconds) 13 | { 14 | ts->tv_sec = milliseconds / 1000; 15 | ts->tv_nsec = (milliseconds % 1000) * 1000 * 1000; 16 | } 17 | 18 | void IoQueue::setAbsoluteTimeout(Timespec* ts, uint64_t milliseconds) 19 | { 20 | ::timespec nowTs; 21 | ::clock_gettime(CLOCK_MONOTONIC, &nowTs); 22 | ts->tv_sec = nowTs.tv_sec + milliseconds / 1000; 23 | ts->tv_nsec = nowTs.tv_nsec + (milliseconds % 1000) * 1000 * 1000; 24 | ts->tv_sec += ts->tv_nsec / (1000 * 1000 * 1000); 25 | ts->tv_nsec = ts->tv_nsec % (1000 * 1000 * 1000); 26 | } 27 | 28 | IoQueue::IoQueue(size_t size, bool submissionQueuePolling) 29 | : completionHandlers_(size) 30 | { 31 | if (!ring_.init(size, submissionQueuePolling)) { 32 | slog::fatal("Could not create io_uring: ", errnoToString(errno)); 33 | std::exit(1); 34 | } 35 | if (!(ring_.getParams().features & IORING_FEAT_NODROP)) { 36 | slog::fatal("io_uring does not support NODROP"); 37 | std::exit(1); 38 | } 39 | if (!(ring_.getParams().features & IORING_FEAT_SUBMIT_STABLE)) { 40 | slog::fatal("io_uring does not support SUBMIT_STABLE"); 41 | std::exit(1); 42 | } 43 | } 44 | 45 | size_t IoQueue::getSize() const 46 | { 47 | return ring_.getNumSqeEntries(); 48 | } 49 | 50 | size_t IoQueue::getCapacity() const 51 | { 52 | return ring_.getSqeCapacity(); 53 | } 54 | 55 | bool IoQueue::accept(int fd, sockaddr_in* addr, socklen_t* addrlen, HandlerEcRes cb) 56 | { 57 | return addSqe( 58 | ring_.prepareAccept(fd, reinterpret_cast(addr), addrlen), std::move(cb)); 59 | } 60 | 61 | bool IoQueue::connect(int sockfd, const ::sockaddr* addr, socklen_t addrlen, HandlerEc cb) 62 | { 63 | return addSqe(ring_.prepareConnect(sockfd, addr, addrlen), std::move(cb)); 64 | } 65 | 66 | bool IoQueue::send(int sockfd, const void* buf, size_t len, HandlerEcRes cb) 67 | { 68 | return addSqe(ring_.prepareSend(sockfd, buf, len), std::move(cb)); 69 | } 70 | 71 | bool IoQueue::send(int sockfd, const void* buf, size_t len, IoQueue::Timespec* timeout, 72 | bool timeoutIsAbsolute, HandlerEcRes cb) 73 | { 74 | if (!timeout) { 75 | return send(sockfd, buf, len, std::move(cb)); 76 | } 77 | return addSqe(ring_.prepareSend(sockfd, buf, len), timeout, timeoutIsAbsolute, std::move(cb)); 78 | } 79 | 80 | bool IoQueue::recv(int sockfd, void* buf, size_t len, HandlerEcRes cb) 81 | { 82 | return addSqe(ring_.prepareRecv(sockfd, buf, len), std::move(cb)); 83 | } 84 | 85 | bool IoQueue::recv(int sockfd, void* buf, size_t len, IoQueue::Timespec* timeout, 86 | bool timeoutIsAbsolute, HandlerEcRes cb) 87 | { 88 | if (!timeout) { 89 | return recv(sockfd, buf, len, std::move(cb)); 90 | } 91 | return addSqe(ring_.prepareRecv(sockfd, buf, len), timeout, timeoutIsAbsolute, std::move(cb)); 92 | } 93 | 94 | bool IoQueue::read(int fd, void* buf, size_t count, HandlerEcRes cb) 95 | { 96 | return addSqe(ring_.prepareRead(fd, buf, count), std::move(cb)); 97 | } 98 | 99 | bool IoQueue::close(int fd, HandlerEc cb) 100 | { 101 | return addSqe(ring_.prepareClose(fd), std::move(cb)); 102 | } 103 | 104 | bool IoQueue::shutdown(int fd, int how, HandlerEc cb) 105 | { 106 | return addSqe(ring_.prepareShutdown(fd, how), std::move(cb)); 107 | } 108 | 109 | bool IoQueue::poll(int fd, short events, HandlerEcRes cb) 110 | { 111 | return addSqe(ring_.preparePollAdd(fd, events), std::move(cb)); 112 | } 113 | 114 | IoQueue::NotifyHandle::NotifyHandle(std::shared_ptr eventFd) 115 | : eventFd_(std::move(eventFd)) 116 | { 117 | } 118 | 119 | IoQueue::NotifyHandle::operator bool() const 120 | { 121 | return eventFd_ != nullptr; 122 | } 123 | 124 | void IoQueue::NotifyHandle::notify(uint64_t value) 125 | { 126 | assert(eventFd_); 127 | eventFd_->write(value); 128 | eventFd_.reset(); 129 | } 130 | 131 | IoQueue::NotifyHandle IoQueue::wait(Function cb) 132 | { 133 | auto eventFd = std::make_shared(*this); 134 | const auto res 135 | = eventFd->read([eventFd, cb = std::move(cb)](std::error_code ec, uint64_t value) { 136 | if (ec) { 137 | cb(ec, 0); 138 | } else { 139 | cb(std::error_code(), value); 140 | } 141 | }); 142 | if (res) { 143 | return NotifyHandle { std::move(eventFd) }; 144 | } else { 145 | return NotifyHandle { nullptr }; 146 | } 147 | } 148 | 149 | void IoQueue::run() 150 | { 151 | while (completionHandlers_.size() > 0) { 152 | const auto res = ring_.submitSqes(1); 153 | if (res < 0) { 154 | slog::error("Error submitting SQEs: ", errnoToString(errno)); 155 | continue; 156 | } 157 | const auto cqe = ring_.peekCqe(); 158 | if (!cqe) { 159 | continue; 160 | } 161 | 162 | if (cqe->user_data != Ignore) { 163 | assert(completionHandlers_.contains(cqe->user_data)); 164 | Metrics::get().ioQueueOpsQueued.labels().dec(); 165 | auto ch = std::move(completionHandlers_[cqe->user_data]); 166 | ch(cqe); 167 | completionHandlers_.remove(cqe->user_data); 168 | } 169 | ring_.advanceCq(); 170 | } 171 | } 172 | 173 | size_t IoQueue::addHandler(HandlerEc&& cb) 174 | { 175 | return completionHandlers_.emplace([cb = std::move(cb)](const io_uring_cqe* cqe) { 176 | if (cqe->res < 0) { 177 | cb(std::make_error_code(static_cast(-cqe->res))); 178 | } else { 179 | cb(std::error_code()); 180 | } 181 | }); 182 | } 183 | 184 | size_t IoQueue::addHandler(HandlerEcRes&& cb) 185 | { 186 | return completionHandlers_.emplace([cb = std::move(cb)](const io_uring_cqe* cqe) { 187 | if (cqe->res < 0) { 188 | cb(std::make_error_code(static_cast(-cqe->res)), -1); 189 | } else { 190 | cb(std::error_code(), cqe->res); 191 | } 192 | }); 193 | } 194 | 195 | template 196 | bool IoQueue::addSqe(io_uring_sqe* sqe, Callback cb) 197 | { 198 | if (!sqe) { 199 | slog::warning("io_uring full"); 200 | return false; 201 | } 202 | Metrics::get().ioQueueOpsQueued.labels().inc(); 203 | sqe->user_data = addHandler(std::move(cb)); 204 | return true; 205 | } 206 | 207 | template bool IoQueue::addSqe(io_uring_sqe* sqe, IoQueue::HandlerEc cb); 208 | template bool IoQueue::addSqe(io_uring_sqe* sqe, IoQueue::HandlerEcRes cb); 209 | 210 | template 211 | bool IoQueue::addSqe(io_uring_sqe* sqe, Timespec* timeout, bool timeoutIsAbsolute, Callback cb) 212 | { 213 | if (!sqe) { 214 | slog::warning("io_uring full"); 215 | return false; 216 | } 217 | sqe->user_data = addHandler(std::move(cb)); 218 | sqe->flags |= IOSQE_IO_LINK; 219 | // If the timeout does not fit into the SQ, that's fine. We don't want to undo the whole thing. 220 | // In the future the use of timeouts might be more critical and this should be reconsidered. 221 | auto timeoutSqe = ring_.prepareLinkTimeout(timeout, timeoutIsAbsolute ? IORING_TIMEOUT_ABS : 0); 222 | timeoutSqe->user_data = Ignore; 223 | return true; 224 | } 225 | 226 | template bool IoQueue::addSqe( 227 | io_uring_sqe* sqe, Timespec* timeout, bool timeoutIsAbsolute, IoQueue::HandlerEc cb); 228 | template bool IoQueue::addSqe( 229 | io_uring_sqe* sqe, Timespec* timeout, bool timeoutIsAbsolute, IoQueue::HandlerEcRes cb); 230 | -------------------------------------------------------------------------------- /src/ioqueue.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | 10 | #include "events.hpp" 11 | #include "function.hpp" 12 | #include "iouring.hpp" 13 | #include "log.hpp" 14 | #include "slotmap.hpp" 15 | 16 | class IoQueue { 17 | private: 18 | using CompletionHandler = Function; 19 | 20 | static constexpr auto Ignore = std::numeric_limits::max(); 21 | 22 | public: 23 | using HandlerEc = Function; 24 | using HandlerEcRes = Function; 25 | using Timespec = IoURing::Timespec; 26 | 27 | // These are both relative with respect to their arguments, but naming these is hard. 28 | static void setRelativeTimeout(Timespec* ts, uint64_t milliseconds); 29 | static void setAbsoluteTimeout(Timespec* ts, uint64_t milliseconds); 30 | 31 | IoQueue(size_t size = 1024, bool submissionQueuePolling = false); 32 | 33 | size_t getSize() const; 34 | 35 | size_t getCapacity() const; 36 | 37 | // TODO: Support cancellation by returning a RequestHandle wrapping an uint64_t containing the 38 | // SQE userData. Add an operator bool to replicate the old behaviour and add 39 | // cancel(RequestHandle), that generates an IORING_OP_ASYNC_CANCEL with the wrapped userData. 40 | 41 | // res argument is socket fd 42 | bool accept(int fd, sockaddr_in* addr, socklen_t* addrlen, HandlerEcRes cb); 43 | 44 | bool connect(int sockfd, const ::sockaddr* addr, socklen_t addrlen, HandlerEc cb); 45 | 46 | // res argument is sent bytes 47 | bool send(int sockfd, const void* buf, size_t len, HandlerEcRes cb); 48 | 49 | // timeout may be nullptr for convenience (which is equivalent to the function above) 50 | bool send(int sockfd, const void* buf, size_t len, Timespec* timeout, bool timeoutIsAbsolute, 51 | HandlerEcRes cb); 52 | 53 | // res argument is received bytes 54 | bool recv(int sockfd, void* buf, size_t len, HandlerEcRes cb); 55 | 56 | bool recv(int sockfd, void* buf, size_t len, Timespec* timeout, bool timeoutIsAbsolute, 57 | HandlerEcRes cb); 58 | 59 | bool read(int fd, void* buf, size_t count, HandlerEcRes cb); 60 | 61 | bool close(int fd, HandlerEc cb); 62 | 63 | bool shutdown(int fd, int how, HandlerEc cb); 64 | 65 | bool poll(int fd, short events, HandlerEcRes cb); 66 | 67 | class NotifyHandle { 68 | public: 69 | NotifyHandle(std::shared_ptr eventFd); 70 | 71 | // wait might fail, in which case this will return false 72 | explicit operator bool() const; 73 | 74 | // Note that all restrictions on EventFd::write apply here as well (writes synchronously, so 75 | // don't use from the main thread, but can be used from other threads). 76 | // Also this function must be called exactly once. If it is not called, the async read on 77 | // the eventfd will never terminate. If you call it more than once, there is no read queued 78 | // up, so this function will abort. 79 | void notify(uint64_t value = 1); 80 | 81 | private: 82 | // We need shared ownership, because wait will issue an async read, which needs to have 83 | // ownership of this event fd as well. 84 | std::shared_ptr eventFd_; 85 | }; 86 | 87 | // This will call a handler callback, when the NotifyHandle is notified. 88 | // The value passed to NotifyHandle::notify will be passed to the handler cb. 89 | NotifyHandle wait(Function cb); 90 | 91 | template 92 | bool async(Function func, Function cb) 93 | { 94 | // Only a minimal amount of hair has been ripped out of my skull because of this. 95 | std::promise prom; 96 | auto handle = wait( 97 | [fut = prom.get_future(), cb = std::move(cb)](std::error_code ec, uint64_t) mutable { 98 | if (ec) { 99 | cb(ec, Result()); 100 | } else { 101 | cb(std::error_code(), std::move(fut.get())); 102 | } 103 | }); 104 | if (!handle) { 105 | return false; 106 | } 107 | 108 | // Simply detaching a thread is really not very clean, but it's easy and enough for my 109 | // current use cases. 110 | std::thread t( 111 | [func = std::move(func), prom = std::move(prom), handle = std::move(handle)]() mutable { 112 | prom.set_value(func()); 113 | handle.notify(); 114 | }); 115 | t.detach(); 116 | return true; 117 | } 118 | 119 | void run(); 120 | 121 | private: 122 | size_t addHandler(HandlerEc&& cb); 123 | size_t addHandler(HandlerEcRes&& cb); 124 | 125 | template 126 | bool addSqe(io_uring_sqe* sqe, Callback cb); 127 | 128 | template 129 | bool addSqe(io_uring_sqe* sqe, Timespec* timeout, bool timeoutIsAbsolute, Callback cb); 130 | 131 | IoURing ring_; 132 | SlotMap completionHandlers_; 133 | }; 134 | -------------------------------------------------------------------------------- /src/libexample.cpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #include "filecache.hpp" 4 | #include "router.hpp" 5 | #include "tcp.hpp" 6 | 7 | #ifdef TLS_SUPPORT_ENABLED 8 | #include "ssl.hpp" 9 | #endif 10 | 11 | using namespace std::literals; 12 | 13 | static std::string getMimeType(std::string fileExt) 14 | { 15 | static std::unordered_map mimeTypes { 16 | { "jpg", "image/jpeg" }, 17 | { "html", "text/html" }, 18 | }; 19 | const auto it = mimeTypes.find(fileExt); 20 | if (it == mimeTypes.end()) { 21 | return "text/plain"; 22 | } 23 | return it->second; 24 | } 25 | 26 | int main() 27 | { 28 | slog::init(slog::Severity::Debug); 29 | 30 | IoQueue io; 31 | 32 | FileCache fileCache(io); 33 | 34 | Router router; 35 | 36 | router.route(Method::Get, "/", 37 | [](const Request&, const Router::RouteParams&) -> Response { return "Hello!"s; }); 38 | 39 | router.route(Method::Get, "/number/:num", 40 | [](const Request&, const Router::RouteParams& params) -> Response { 41 | return "Number: "s + std::string(params.at("num")); 42 | }); 43 | 44 | router.route("/headers", [](const Request& req, const Router::RouteParams&) -> Response { 45 | std::string s; 46 | s.reserve(1024); 47 | for (const auto& [name, value] : req.headers.getEntries()) { 48 | s.append("'" + std::string(name) + "' = '" + std::string(value) + "'\n"); 49 | } 50 | return s; 51 | }); 52 | 53 | router.route("/users/:uid", [](const Request&, const Router::RouteParams& params) -> Response { 54 | return "User #'" + std::string(params.at("uid")) + "'"; 55 | }); 56 | 57 | router.route( 58 | "/users/:uid/name", [](const Request&, const Router::RouteParams& params) -> Response { 59 | return "User name for #'" + std::string(params.at("uid")) + "'"; 60 | }); 61 | 62 | router.route("/users/:uid/friends/:fid", 63 | [](const Request&, const Router::RouteParams& params) -> Response { 64 | return "Friend #'" + std::string(params.at("fid")) + "' for user '" 65 | + std::string(params.at("uid")) + "'"; 66 | }); 67 | 68 | router.route("/users/:uid/files/:path*", 69 | [](const Request&, const Router::RouteParams& params) -> Response { 70 | return "File '" + std::string(params.at("path")) + "' for user '" 71 | + std::string(params.at("uid")) + "'"; 72 | }); 73 | 74 | router.route("/file/:path*", 75 | [&fileCache](const Request&, const Router::RouteParams& params) -> Response { 76 | const auto path = params.at("path"); 77 | const auto f = fileCache.get(std::string(path)); 78 | if (!f) { 79 | return Response(StatusCode::NotFound, "Not Found"); 80 | } 81 | const auto extDelim = path.find_last_of('.'); 82 | const auto ext = path.substr(std::min(extDelim + 1, path.size())); 83 | return Response(f->contents.value(), getMimeType(std::string(ext))); 84 | }); 85 | 86 | router.route("/metrics", 87 | [&io](const Request&, const Router::RouteParams&, std::shared_ptr responder) { 88 | io.async( 89 | []() { 90 | return Response( 91 | cpprom::Registry::getDefault().serialize(), "text/plain; version=0.0.4"); 92 | }, 93 | [responder = std::move(responder)]( 94 | std::error_code ec, Response&& response) mutable { 95 | assert(!ec); 96 | responder->respond(std::move(response)); 97 | }); 98 | }); 99 | 100 | Server server(io, TcpConnectionFactory {}, router); 101 | server.start(); 102 | io.run(); 103 | 104 | return 0; 105 | } 106 | -------------------------------------------------------------------------------- /src/log.cpp: -------------------------------------------------------------------------------- 1 | #include "log.hpp" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | 9 | #include "mpscqueue.hpp" 10 | 11 | namespace slog { 12 | namespace { 13 | MpscQueue& logQueue() 14 | { 15 | static MpscQueue queue; 16 | return queue; 17 | } 18 | 19 | std::atomic& logThreadRunning() 20 | { 21 | static std::atomic running; 22 | return running; 23 | } 24 | 25 | std::thread& logThread() 26 | { 27 | static std::thread t; 28 | return t; 29 | } 30 | 31 | void logThreadFunc() 32 | { 33 | auto& queue = logQueue(); 34 | auto& running = logThreadRunning(); 35 | while (true) { 36 | const auto line = queue.consume(); 37 | if (!line) { 38 | if (!running.load()) { 39 | return; 40 | } 41 | ::usleep(1); 42 | continue; 43 | } 44 | [[maybe_unused]] auto ignore = ::write(STDOUT_FILENO, line->data(), line->size()); 45 | } 46 | } 47 | 48 | void logAtExit() 49 | { 50 | logThreadRunning().store(false); 51 | logThread().join(); 52 | } 53 | } 54 | 55 | std::string_view toString(Severity severity) 56 | { 57 | static constexpr std::array strings { "DEBUG", "INFO", "WARNING", "ERROR", "FATAL" }; 58 | const auto idx = static_cast(severity); 59 | if (idx < 0 || static_cast(idx) >= strings.size()) { 60 | return "INVALID"; 61 | } 62 | return strings[idx]; 63 | } 64 | 65 | void setLogLevel(Severity severity) 66 | { 67 | detail::getCurrentLogLevel() = severity; 68 | } 69 | 70 | void init(Severity severity) 71 | { 72 | // Check that thread is default-constructed (not running yet) 73 | assert(logThread().get_id() == std::thread::id()); 74 | setLogLevel(severity); 75 | logThreadRunning().store(true); 76 | logThread() = std::thread { logThreadFunc }; 77 | // Maybe I also need to think of something for abnormal termination 78 | std::atexit(logAtExit); 79 | } 80 | 81 | namespace detail { 82 | StringStreamBuf::StringStreamBuf(size_t initialSize) 83 | : str_(initialSize, 0) 84 | { 85 | str_.resize(0); 86 | } 87 | 88 | std::streamsize StringStreamBuf::xsputn(const char* s, std::streamsize n) 89 | { 90 | str_.append(s, n); 91 | return n; 92 | } 93 | 94 | void StringStreamBuf::clear() 95 | { 96 | str_.clear(); 97 | } 98 | 99 | std::string& StringStreamBuf::string() 100 | { 101 | return str_; 102 | } 103 | 104 | Severity& getCurrentLogLevel() 105 | { 106 | static Severity severity = Severity::Info; 107 | return severity; 108 | } 109 | 110 | void replaceDateTime(char* buffer, size_t size, const char* format) 111 | { 112 | const auto t = std::time(nullptr); 113 | [[maybe_unused]] const auto n = std::strftime(buffer, size, format, std::localtime(&t)); 114 | assert(n > 0); 115 | } 116 | 117 | void log(std::string str) 118 | { 119 | logQueue().produce(std::move(str)); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/log.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | namespace slog { 6 | enum class Severity { Debug = 0, Info, Warning, Error, Fatal }; 7 | std::string_view toString(Severity severity); 8 | 9 | void init(Severity severity = Severity::Info); 10 | void setLogLevel(Severity severity); 11 | 12 | namespace detail { 13 | // We use a custom string buf, so we can preallocate and clear to reuse the same buffer 14 | class StringStreamBuf : public std::streambuf { 15 | public: 16 | StringStreamBuf(size_t initialSize); 17 | 18 | std::streamsize xsputn(const char* s, std::streamsize n) override; 19 | 20 | void clear(); 21 | std::string& string(); 22 | 23 | private: 24 | std::string str_; 25 | }; 26 | 27 | Severity& getCurrentLogLevel(); 28 | 29 | void replaceDateTime(char* buffer, size_t size, const char* format); 30 | 31 | void log(std::string str); 32 | } 33 | 34 | // This is thread-safe. Even though the whole program is single-threaded so far, 35 | // I don't want to have to worry about logging if I ever decide to make it multi-threaded. 36 | // EDIT: This decision turned out to be smart. 37 | template 38 | void log(Severity severity, Args&&... args) 39 | { 40 | using namespace detail; 41 | 42 | thread_local StringStreamBuf buf(1024); 43 | thread_local std::ostream os(&buf); 44 | buf.clear(); 45 | if (static_cast(severity) < static_cast(getCurrentLogLevel())) { 46 | return; 47 | } 48 | static constexpr std::string_view dtDummy = "YYYY-mm-dd HH:MM:SS"; 49 | (os << "[" << dtDummy << "] [" << toString(severity) << "] " << ... << args) << "\n"; 50 | replaceDateTime(buf.string().data() + 1, dtDummy.size() + 1, "%F %T"); 51 | // Restore the char that was overwritten with null by strftime (so silly) 52 | buf.string().data()[1 + dtDummy.size()] = ']'; 53 | log(buf.string()); 54 | } 55 | 56 | template 57 | void debug(Args&&... args) 58 | { 59 | log(Severity::Debug, std::forward(args)...); 60 | } 61 | 62 | template 63 | void info(Args&&... args) 64 | { 65 | log(Severity::Info, std::forward(args)...); 66 | } 67 | 68 | template 69 | void warning(Args&&... args) 70 | { 71 | log(Severity::Warning, std::forward(args)...); 72 | } 73 | 74 | template 75 | void error(Args&&... args) 76 | { 77 | log(Severity::Error, std::forward(args)...); 78 | } 79 | 80 | template 81 | void fatal(Args&&... args) 82 | { 83 | log(Severity::Fatal, std::forward(args)...); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/lrucache.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | template 7 | class LRUCache { 8 | public: 9 | using Entry = std::pair; 10 | 11 | LRUCache(size_t capacity) 12 | : capacity_(capacity) 13 | { 14 | listEntryMap_.reserve(capacity_); 15 | } 16 | 17 | bool inCache(const Key& key) { return listEntryMap_.count(key) > 0; } 18 | 19 | Value* get(const Key& key) 20 | { 21 | const auto it = listEntryMap_.find(key); 22 | if (it == listEntryMap_.end()) { 23 | return nullptr; 24 | } 25 | // Move to front of LRU 26 | lru_.splice(lru_.begin(), lru_, it->second); 27 | return &it->second->second; 28 | } 29 | 30 | // Returns true if element was inserted 31 | bool set(const Key& key, Value value) 32 | { 33 | auto it = listEntryMap_.find(key); 34 | if (it != listEntryMap_.end()) { 35 | it->second->second = std::move(value); 36 | lru_.splice(lru_.begin(), lru_, it->second); 37 | return false; 38 | } 39 | 40 | if (size() >= capacity()) { 41 | // Evict least recently used element 42 | listEntryMap_.erase(leastRecentlyUsed().first); 43 | lru_.pop_back(); 44 | } 45 | 46 | // Insert the new element at the front of the LRU list and add it to the cache. 47 | lru_.emplace_front(key, std::move(value)); 48 | listEntryMap_[key] = lru_.begin(); 49 | return true; 50 | } 51 | 52 | size_t size() const { return listEntryMap_.size(); } 53 | size_t capacity() const { return capacity_; } 54 | 55 | const Entry& lastRecentlyUsed() const { return lru_.front(); } 56 | const Entry& leastRecentlyUsed() const { return lru_.back(); } 57 | 58 | // Use these to iterate in last recently used order 59 | auto begin() { return lru_.begin(); } 60 | auto end() { return lru_.end(); } 61 | 62 | // Use these to iterate in least recently used order 63 | auto rbegin() { return lru_.rbegin(); } 64 | auto rend() { return lru_.rend(); } 65 | 66 | private: 67 | // TODO: Custom allocator for list so they are all contiguous in memory? 68 | std::list lru_; 69 | std::unordered_map::iterator> listEntryMap_; 70 | size_t capacity_; 71 | }; 72 | -------------------------------------------------------------------------------- /src/metrics.cpp: -------------------------------------------------------------------------------- 1 | #include "metrics.hpp" 2 | 3 | #include 4 | 5 | Metrics& Metrics::get() 6 | { 7 | static auto& reg 8 | = cpprom::Registry::getDefault().registerCollector(cpprom::makeProcessMetricsCollector()); 9 | static auto durationBuckets = cpprom::Histogram::defaultBuckets(); 10 | static auto sizeBuckets = cpprom::Histogram::exponentialBuckets(256.0, 4.0, 7); 11 | static Metrics metrics { 12 | reg.counter("htcpp_connections_accepted", {}, "Number of connections accepted"), 13 | reg.counter("htcpp_connections_dropped", {}, "Number of connections dropped"), 14 | reg.gauge("htcpp_connections_active", {}, "Number of active connections"), 15 | 16 | reg.counter( 17 | "htcpp_requests_total", { "method", "url", "status" }, "Number of received requests"), 18 | reg.histogram("htcpp_request_header_size_bytes", { "method", "url" }, sizeBuckets, 19 | "Request header size"), 20 | reg.histogram( 21 | "htcpp_request_body_size_bytes", { "method", "url" }, sizeBuckets, "Request body size"), 22 | reg.histogram("htcpp_request_duration_seconds", { "method", "url" }, durationBuckets, 23 | "Time from first recv until after last send"), 24 | 25 | reg.counter( 26 | "htcpp_responses_total", { "method", "url", "status" }, "Number of sent responses"), 27 | reg.histogram("htcpp_response_size_bytes", { "method", "url", "status" }, sizeBuckets, 28 | "Response size in bytes"), 29 | 30 | reg.counter("htcpp_accept_errors_total", { "errno" }, "Number of errors in accept"), 31 | reg.counter("htcpp_recv_errors_total", { "errno" }, "Number of errors in recv"), 32 | reg.counter("htcpp_send_errors_total", { "errno" }, "Number of errors in send"), 33 | reg.counter( 34 | "htcpp_request_errors_total", { "cause" }, "Number of errors while processing request"), 35 | 36 | reg.counter("htcpp_filecache_queries_total", { "path" }, 37 | "Number of queries towards the file cache"), 38 | reg.counter("htcpp_filecache_hit_total", { "path" }, 39 | "Number of queries towards the file cache that returned data immediately"), 40 | reg.counter("htcpp_filecache_failures_total", { "path" }, 41 | "Number of times the file cache could not load a file"), 42 | reg.histogram( 43 | "htcpp_file_read_duration", { "path" }, durationBuckets, "Time to read a file"), 44 | 45 | reg.gauge("htcpp_io_queued_total", { /*"op"*/ }, 46 | "Number of operations currently queued in the IO queue"), 47 | }; 48 | return metrics; 49 | } 50 | -------------------------------------------------------------------------------- /src/metrics.hpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | /* https://prometheus.io/docs/practices/instrumentation/ 4 | * Summary: 5 | * - Key metrics are performaned queries, errors, latency, number of req in progress 6 | * - Be consistent in whether you count queries when they start or when they end 7 | * - Every line of logging code should have a counter that is incremented 8 | * - Failures should be handled similarly to logging. Every time there is a failure, a counter 9 | * should be incremented. 10 | * - Threadpools: number of queued requests, the number of threads in use, the total number of 11 | * threads, the number of tasks processed, and how long they took. how long things were waiting in 12 | * the queue. 13 | * - Caches: total queries, hits, overall latency. query count, errors and latency of whatever 14 | * online-serving system the cache is in front of 15 | */ 16 | struct Metrics { 17 | cpprom::MetricFamily& connAccepted; 18 | cpprom::MetricFamily& connDropped; 19 | cpprom::MetricFamily& connActive; 20 | 21 | cpprom::MetricFamily& reqsTotal; 22 | cpprom::MetricFamily& reqHeaderSize; 23 | cpprom::MetricFamily& reqBodySize; 24 | cpprom::MetricFamily& reqDuration; 25 | 26 | cpprom::MetricFamily& respTotal; 27 | cpprom::MetricFamily& respSize; 28 | 29 | cpprom::MetricFamily& acceptErrors; 30 | cpprom::MetricFamily& recvErrors; 31 | cpprom::MetricFamily& sendErrors; 32 | cpprom::MetricFamily& reqErrors; 33 | 34 | cpprom::MetricFamily& fileCacheQueries; 35 | cpprom::MetricFamily& fileCacheHits; 36 | cpprom::MetricFamily& fileCacheFailures; 37 | cpprom::MetricFamily& fileReadDuration; 38 | 39 | cpprom::MetricFamily& ioQueueOpsQueued; 40 | // cpprom::MetricFamily& ioQueueOpDuration; 41 | 42 | static Metrics& get(); 43 | }; 44 | -------------------------------------------------------------------------------- /src/mpscqueue.hpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | 4 | // Vyukov MPSC (wait-free multiple producers, single consumer) queue 5 | // https://www.1024cores.net/home/lock-free-algorithms/queues/intrusive-mpsc-node-based-queue 6 | template 7 | class MpscQueue { 8 | public: 9 | MpscQueue() 10 | : stub_() 11 | , consumeEnd_(&stub_) 12 | , produceEnd_(&stub_) 13 | { 14 | } 15 | 16 | void produce(T&& value) 17 | { 18 | produce(new Node { std::move(value), nullptr }); 19 | } 20 | 21 | std::optional consume() 22 | { 23 | auto node = consumeEnd_.load(); 24 | auto next = node->next.load(); 25 | 26 | // If we are supposed to consume the stub, then the list is either empty (nullopt) 27 | // or this is the first time we consume, in which case we just move consumeEnd ahead. 28 | if (node == &stub_) { 29 | if (!next) { 30 | return std::nullopt; 31 | } 32 | consumeEnd_.store(next); 33 | node = next; 34 | next = node->next; 35 | } 36 | 37 | if (next) { 38 | consumeEnd_.store(next); 39 | return unpackNode(node); 40 | } 41 | 42 | // If we don't have a `next` element, `node` should be the last item in the list, 43 | // unless a new item was produced since we last loaded consumeEnd. 44 | // If there was, we need to try from the start (because there would be a `next`). 45 | // Instead of calling consume recursively (dangerous), we just bail and let the caller 46 | // retry. 47 | // I am fairly sure you could leave this check out completely and it would still work 48 | // correctly, but it would be less efficient. 49 | if (node != produceEnd_.load()) { 50 | return std::nullopt; 51 | } 52 | 53 | // Assuming the check above failed (and we got here), the state of the list should be: 54 | // stub -> node (consumeEnd, produceEnd) -> nullptr 55 | 56 | // Since we have no next item to make the new consumeEnd, we need to put stub_ into the 57 | // queue again. 58 | 59 | stub_.next.store(nullptr); 60 | produce(&stub_); 61 | 62 | // Now we have either attached stub to `node` or to another element other producer threads 63 | // might have added in the meantime. 64 | // In case we have finished attaching the other element to stub_, but the other producer 65 | // thread has not finished attaching `node` to the new element (i.e. it did not set 66 | // node->next yet), the below condition (`if (next)`) would be false. 67 | 68 | // Assuming one other producer thread the list would look like this (next != NULL): 69 | // node (consumeEnd) *(-> elem) -> stub (produceEnd) 70 | // or this (next is NULL): 71 | // node (consumeEnd) -X- elem -> stub (produceEnd) 72 | // The latter case is what Vyukov refers to saying that the consumer is blocking (see source 73 | // link). 74 | 75 | next = node->next.load(); 76 | if (next) { 77 | consumeEnd_.store(next); 78 | return unpackNode(node); 79 | } 80 | 81 | // If the other thread has not managed to attach the new element to `node` yet, we have no 82 | // other choice but to wait for it to finish, so we return nullopt. 83 | 84 | return std::nullopt; 85 | } 86 | 87 | private: 88 | struct Node { 89 | T value; 90 | // "next" in the order of consumption 91 | std::atomic next; 92 | }; 93 | 94 | static T unpackNode(Node* node) 95 | { 96 | auto value = std::move(node->value); 97 | delete node; 98 | return value; 99 | } 100 | 101 | void produce(Node* node) 102 | { 103 | auto prev = produceEnd_.exchange(node); 104 | prev->next.store(node); 105 | } 106 | 107 | // This is not an actual element of the queue, but simply a place to "park" consumeEnd, when 108 | // there is nothing to consume. 109 | // Sadly this makes default constructability for T a requirement. 110 | Node stub_; 111 | // Yes, screw "head" and "tail" and everyone doing whatever they please with those words. 112 | std::atomic consumeEnd_; 113 | std::atomic produceEnd_; 114 | }; 115 | -------------------------------------------------------------------------------- /src/pattern.cpp: -------------------------------------------------------------------------------- 1 | #include "pattern.hpp" 2 | 3 | #include 4 | 5 | #include "log.hpp" 6 | #include "string.hpp" 7 | 8 | std::optional Pattern::create(std::string_view str) 9 | { 10 | Pattern ret; 11 | ret.raw_ = std::string(str); 12 | 13 | size_t i = 0; 14 | while (i < str.size()) { 15 | if (str[i] == '*') { 16 | ret.parts_.push_back(Wildcard {}); 17 | i++; 18 | } else if (str[i] == '{') { 19 | // not +1, because str might be too small 20 | const auto end = str.find('}', i); 21 | if (end == std::string_view::npos) { 22 | return std::nullopt; 23 | } 24 | const auto args = str.substr(i + 1, end - i - 1); 25 | const auto parts = split(args, ','); 26 | ret.parts_.push_back(AnyOf { std::vector(parts.begin(), parts.end()) }); 27 | i = end + 1; 28 | } else { 29 | const auto end = str.find_first_of("{*"); 30 | ret.parts_.push_back(Literal { std::string(str.substr(i, end - i)) }); 31 | i = end; 32 | } 33 | } 34 | 35 | if (ret.isLiteral()) { 36 | ret.type_ = Type::Literal; 37 | } else if (ret.isWildcard()) { 38 | ret.type_ = Type::Wildcard; 39 | } else if (ret.isAnyOf()) { 40 | ret.type_ = Type::AnyOf; 41 | } else if (ret.isLiteralPrefix()) { 42 | ret.type_ = Type::LiteralPrefix; 43 | } else if (ret.isAnyOfPrefix()) { 44 | ret.type_ = Type::AnyOfPrefix; 45 | } else if (ret.isLiteralSuffix()) { 46 | ret.type_ = Type::LiteralSuffix; 47 | } else if (ret.isAnyOfSuffix()) { 48 | ret.type_ = Type::AnyOfSuffix; 49 | } else { 50 | ret.type_ = Type::Generic; 51 | } 52 | 53 | return ret; 54 | } 55 | 56 | bool Pattern::hasGroupReferences(std::string_view str) 57 | { 58 | return str.find('$') != std::string_view::npos; 59 | } 60 | 61 | std::string Pattern::replaceGroupReferences( 62 | std::string_view str, const std::vector& groups) 63 | { 64 | assert(groups.size() < 10); 65 | std::string ret; 66 | ret.reserve(256); 67 | size_t cursor = 0; 68 | while (cursor < str.size()) { 69 | const auto marker = str.find('$', cursor); 70 | if (marker == std::string_view::npos) { 71 | ret.append(str.substr(cursor)); 72 | break; 73 | } else { 74 | ret.append(str.substr(cursor, marker - cursor)); 75 | } 76 | 77 | if (marker + 1 < str.size() && str[marker + 1] != '0' && isDigit(str[marker + 1])) { 78 | assert(str[marker + 1] >= '1'); 79 | const auto idx = static_cast(str[marker + 1] - '1'); 80 | if (idx < groups.size()) { 81 | ret.append(groups[idx]); 82 | } 83 | cursor = marker + 2; 84 | } else { 85 | ret.push_back('$'); 86 | cursor = marker + 1; 87 | } 88 | } 89 | return ret; 90 | } 91 | 92 | Pattern::MatchResult Pattern::match(std::string_view str) const 93 | { 94 | switch (type_) { 95 | case Type::Literal: 96 | assert(isLiteral()); 97 | return MatchResult { std::get(parts_[0]).str == str }; 98 | case Type::AnyOf: { 99 | for (const auto& opt : std::get(parts_[0]).options) { 100 | if (str == opt) { 101 | return MatchResult { true }; 102 | } 103 | } 104 | return MatchResult { false }; 105 | } 106 | case Type::Wildcard: 107 | assert(isWildcard()); 108 | return MatchResult { true, { str } }; 109 | case Type::LiteralPrefix: { 110 | assert(isLiteralPrefix()); 111 | const auto& literal = std::get(parts_[0]); 112 | if (startsWith(str, literal.str)) { 113 | return MatchResult { true, { str.substr(literal.str.size()) } }; 114 | } 115 | return MatchResult { false }; 116 | } 117 | case Type::AnyOfPrefix: { 118 | assert(isAnyOfPrefix()); 119 | const auto& anyof = std::get(parts_[0]); 120 | for (const auto& opt : anyof.options) { 121 | if (startsWith(str, opt)) { 122 | return MatchResult { true, { str.substr(opt.size()) } }; 123 | } 124 | } 125 | return MatchResult { false }; 126 | } 127 | case Type::LiteralSuffix: { 128 | assert(isLiteralSuffix()); 129 | const auto& literal = std::get(parts_[1]); 130 | if (endsWith(str, literal.str)) { 131 | return MatchResult { true, { str.substr(0, str.size() - literal.str.size()) } }; 132 | } 133 | return MatchResult { false }; 134 | } 135 | case Type::AnyOfSuffix: { 136 | assert(isAnyOfSuffix()); 137 | for (const auto& opt : std::get(parts_[1]).options) { 138 | if (endsWith(str, opt)) { 139 | return MatchResult { true, { str.substr(0, str.size() - opt.size()) } }; 140 | } 141 | } 142 | return MatchResult { false }; 143 | } 144 | case Type::Generic: 145 | return genericMatch(str); 146 | default: 147 | assert(false && "Invalid Pattern Type"); 148 | break; 149 | } 150 | } 151 | 152 | size_t Pattern::numCaptureGroups() const 153 | { 154 | size_t n = 0; 155 | for (const auto& part : parts_) { 156 | if (std::holds_alternative(part)) { 157 | n++; 158 | } 159 | } 160 | return n; 161 | } 162 | 163 | bool Pattern::isValidReplacementString(std::string_view str) const 164 | { 165 | const auto numGroups = numCaptureGroups(); 166 | size_t cursor = 0; 167 | while (cursor < str.size()) { 168 | const auto marker = str.find('$', cursor); 169 | if (marker == std::string_view::npos) { 170 | break; 171 | } 172 | 173 | if (marker + 1 < str.size() && isDigit(str[marker + 1])) { 174 | if (str[marker + 1] == '0') { 175 | slog::info("'$0' is invalid. The first group is adressed by '$1'"); 176 | return false; 177 | } 178 | assert(str[marker + 1] >= '1'); 179 | const auto idx = static_cast(str[marker + 1] - '1'); 180 | if (idx >= numGroups) { 181 | slog::error("'", str.substr(marker, 2), "' is out of bounds. Pattern only has ", 182 | numGroups, " groups"); 183 | return false; 184 | } 185 | cursor = marker + 2; 186 | } else { 187 | cursor = marker + 1; 188 | } 189 | } 190 | return true; 191 | } 192 | 193 | const std::string& Pattern::raw() const 194 | { 195 | return raw_; 196 | } 197 | 198 | bool Pattern::isLiteral() const 199 | { 200 | return parts_.size() == 1 && std::holds_alternative(parts_[0]); 201 | } 202 | 203 | bool Pattern::isAnyOf() const 204 | { 205 | return parts_.size() == 1 && std::holds_alternative(parts_[0]); 206 | } 207 | 208 | bool Pattern::isWildcard() const 209 | { 210 | return parts_.size() == 1 && std::holds_alternative(parts_[0]); 211 | } 212 | 213 | bool Pattern::isLiteralPrefix() const 214 | { 215 | return parts_.size() == 2 && std::holds_alternative(parts_[0]) 216 | && std::holds_alternative(parts_[1]); 217 | } 218 | 219 | bool Pattern::isAnyOfPrefix() const 220 | { 221 | return parts_.size() == 2 && std::holds_alternative(parts_[0]) 222 | && std::holds_alternative(parts_[1]); 223 | } 224 | 225 | bool Pattern::isLiteralSuffix() const 226 | { 227 | return parts_.size() == 2 && std::holds_alternative(parts_[0]) 228 | && std::holds_alternative(parts_[1]); 229 | } 230 | 231 | bool Pattern::isAnyOfSuffix() const 232 | { 233 | return parts_.size() == 2 && std::holds_alternative(parts_[0]) 234 | && std::holds_alternative(parts_[1]); 235 | } 236 | 237 | Pattern::MatchResult Pattern::genericMatch(std::string_view /*str*/) const 238 | { 239 | assert(false && "Not Implemented"); 240 | return MatchResult { false }; 241 | } 242 | -------------------------------------------------------------------------------- /src/pattern.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | class Pattern { 10 | public: 11 | struct MatchResult { 12 | bool match; 13 | std::vector groups = {}; // just wildcards for now 14 | }; 15 | 16 | static std::optional create(std::string_view str); 17 | static bool hasGroupReferences(std::string_view str); 18 | static std::string replaceGroupReferences( 19 | std::string_view str, const std::vector& groups); 20 | 21 | MatchResult match(std::string_view str) const; 22 | 23 | size_t numCaptureGroups() const; 24 | 25 | bool isValidReplacementString(std::string_view str) const; 26 | 27 | const std::string& raw() const; 28 | 29 | bool isLiteral() const; 30 | bool isWildcard() const; 31 | 32 | private: 33 | enum class Type { 34 | Invalid = 0, 35 | Literal, 36 | AnyOf, 37 | Wildcard, 38 | LiteralPrefix, 39 | AnyOfPrefix, 40 | LiteralSuffix, 41 | AnyOfSuffix, 42 | Generic, 43 | }; 44 | 45 | struct Literal { 46 | std::string str; 47 | }; 48 | 49 | struct AnyOf { 50 | std::vector options; 51 | }; 52 | 53 | struct Wildcard { }; 54 | 55 | using Part = std::variant; 56 | 57 | Pattern() = default; 58 | 59 | bool isAnyOf() const; 60 | bool isLiteralPrefix() const; 61 | bool isAnyOfPrefix() const; 62 | bool isLiteralSuffix() const; 63 | bool isAnyOfSuffix() const; 64 | 65 | MatchResult genericMatch(std::string_view str) const; 66 | 67 | Type type_ = Type::Generic; 68 | std::string raw_; 69 | std::vector parts_; 70 | }; 71 | -------------------------------------------------------------------------------- /src/result.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | template 7 | struct ErrorWrapper { 8 | E value; 9 | }; 10 | 11 | template 12 | ErrorWrapper error(E&& e) 13 | { 14 | return ErrorWrapper { std::forward(e) }; 15 | } 16 | 17 | inline ErrorWrapper errnoError() 18 | { 19 | return error(std::make_error_code(static_cast(errno))); 20 | } 21 | 22 | template 23 | class Result { 24 | public: 25 | Result(const T& t) 26 | : value_(t) 27 | { 28 | } 29 | 30 | template 31 | Result(ErrorWrapper&& e) 32 | : value_(E { e.value }) 33 | { 34 | } 35 | 36 | bool hasValue() const { return value_.index() == 0; } 37 | explicit operator bool() const { return hasValue(); } 38 | 39 | const T& value() const { return std::get<0>(value_); } 40 | T& value() { return std::get<0>(value_); } 41 | 42 | const T& operator*() const { return value(); } 43 | T& operator*() { return value(); } 44 | 45 | const T* operator->() const { return &value(); } 46 | 47 | const E& error() const { return std::get<1>(value_); } 48 | 49 | private: 50 | std::variant value_; 51 | }; 52 | -------------------------------------------------------------------------------- /src/router.cpp: -------------------------------------------------------------------------------- 1 | #include "router.hpp" 2 | 3 | #include 4 | 5 | void Router::route(std::string_view pattern, 6 | std::function)> handler) 7 | { 8 | routes_.push_back(Route { Route::Pattern::parse(pattern), Method::Get, std::move(handler) }); 9 | } 10 | 11 | void Router::route( 12 | std::string_view pattern, std::function handler) 13 | { 14 | routes_.push_back(Route { 15 | Route::Pattern::parse(pattern), 16 | Method::Get, 17 | [handler = std::move(handler)](const Request& request, const RouteParams& params, 18 | std::unique_ptr responder) { responder->respond(handler(request, params)); }, 19 | }); 20 | } 21 | 22 | void Router::route(Method method, std::string_view pattern, 23 | std::function)> handler) 24 | { 25 | routes_.push_back(Route { Route::Pattern::parse(pattern), method, std::move(handler) }); 26 | } 27 | 28 | void Router::route(Method method, std::string_view pattern, 29 | std::function handler) 30 | { 31 | routes_.push_back(Route { 32 | Route::Pattern::parse(pattern), 33 | method, 34 | [handler = std::move(handler)](const Request& request, const RouteParams& params, 35 | std::unique_ptr responder) { responder->respond(handler(request, params)); }, 36 | }); 37 | } 38 | 39 | void Router::operator()(const Request& request, std::unique_ptr responder) const 40 | { 41 | for (const auto& route : routes_) { 42 | if (route.method != request.method) { 43 | continue; 44 | } 45 | const auto params = route.pattern.match(request.url.path); 46 | if (params) { 47 | route.handler(request, *params, std::move(responder)); 48 | return; 49 | } 50 | } 51 | // No matching route 52 | responder->respond(Response(StatusCode::NotFound, "Not Found")); 53 | } 54 | 55 | Router::Route::Pattern Router::Route::Pattern::parse(std::string_view str) 56 | { 57 | Pattern pattern { std::string(str), {} }; 58 | for (const auto& part : split(str, '/')) { 59 | if (!part.empty() && part[0] == ':') { 60 | if (part.back() == '*') { 61 | pattern.parts.push_back( 62 | Part { Part::Type::PlaceholderPath, part.substr(1, part.size() - 2) }); 63 | } else { 64 | pattern.parts.push_back(Part { Part::Type::Placeholder, part.substr(1) }); 65 | } 66 | } else { 67 | pattern.parts.push_back(Part { Part::Type::Literal, part }); 68 | } 69 | } 70 | return pattern; 71 | } 72 | 73 | std::optional Router::Route::Pattern::match(std::string_view urlPath) const 74 | { 75 | size_t cursor = 0; 76 | std::unordered_map params; 77 | for (size_t i = 0; i < parts.size(); ++i) { 78 | if (parts[i].type == Part::Type::Literal || parts[i].type == Part::Type::Placeholder) { 79 | const auto slash = std::min(urlPath.find('/', cursor), urlPath.size()); 80 | const auto urlPart = urlPath.substr(cursor, slash - cursor); 81 | if (parts[i].type == Part::Type::Literal) { 82 | if (parts[i].str != urlPart) { 83 | return std::nullopt; 84 | } 85 | } else { 86 | assert(parts[i].type == Part::Type::Placeholder); 87 | params[parts[i].str] = urlPart; 88 | } 89 | // We have reached the end of urlPath, but there are pattern parts left 90 | if (cursor >= urlPath.size() && i < parts.size() - 1) { 91 | return std::nullopt; 92 | } 93 | cursor = slash + 1; 94 | } else { 95 | assert(parts[i].type == Part::Type::PlaceholderPath); 96 | params[parts[i].str] = urlPath.substr(cursor); 97 | return params; 98 | } 99 | } 100 | // Not the whole urlPath has been consumed => no complete match 101 | if (cursor < urlPath.size()) { 102 | return std::nullopt; 103 | } 104 | return params; 105 | } 106 | -------------------------------------------------------------------------------- /src/router.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include "http.hpp" 6 | #include "server.hpp" 7 | 8 | class Router { 9 | public: 10 | using RouteParams = std::unordered_map; 11 | 12 | void route(std::string_view pattern, 13 | std::function)> 14 | handler); 15 | 16 | void route(std::string_view pattern, 17 | std::function handler); 18 | 19 | void route(Method method, std::string_view pattern, 20 | std::function)> 21 | handler); 22 | 23 | void route(Method method, std::string_view pattern, 24 | std::function handler); 25 | 26 | void operator()(const Request& request, std::unique_ptr) const; 27 | 28 | private: 29 | struct Route { 30 | struct Pattern { 31 | struct Part { 32 | enum class Type { 33 | Literal, 34 | Placeholder, 35 | PlaceholderPath, 36 | }; 37 | 38 | Type type; 39 | std::string_view str; 40 | }; 41 | 42 | std::string pattern; 43 | std::vector parts; 44 | 45 | static Pattern parse(std::string_view str); 46 | 47 | std::optional match(std::string_view urlPath) const; 48 | }; 49 | 50 | Pattern pattern; 51 | Method method; 52 | std::function)> handler; 53 | }; 54 | 55 | std::vector routes_; 56 | }; 57 | -------------------------------------------------------------------------------- /src/server.cpp: -------------------------------------------------------------------------------- 1 | #include "server.hpp" 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | Fd createTcpListenSocket(uint16_t listenPort, uint32_t listenAddr, int backlog) 13 | { 14 | Fd fd { ::socket(AF_INET, SOCK_STREAM, 0) }; 15 | if (fd == -1) 16 | return fd; 17 | 18 | sockaddr_in addr; 19 | ::memset(&addr, 0, sizeof(addr)); 20 | addr.sin_family = AF_INET; 21 | addr.sin_addr.s_addr = listenAddr; 22 | addr.sin_port = htons(listenPort); 23 | 24 | const int reuse = 1; 25 | if (::setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1) { 26 | slog::error("Could not set sockopt SO_REUSEADDR"); 27 | return Fd {}; 28 | } 29 | 30 | if (::bind(fd, reinterpret_cast(&addr), sizeof(addr)) == -1) { 31 | slog::error("Could not bind to port ", listenPort); 32 | return Fd {}; 33 | } 34 | 35 | if (::listen(fd, backlog) == -1) { 36 | slog::error("Could not listen on socket"); 37 | return Fd {}; 38 | } 39 | 40 | return fd; 41 | } 42 | -------------------------------------------------------------------------------- /src/slotmap.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include "vectormap.hpp" 6 | 7 | template 8 | class SlotMap { 9 | public: 10 | SlotMap() 11 | { 12 | } 13 | 14 | SlotMap(size_t size) 15 | : data_(size) 16 | { 17 | } 18 | 19 | size_t size() const 20 | { 21 | return data_.occupied(); 22 | } 23 | 24 | bool contains(size_t index) const 25 | { 26 | return data_.contains(index); 27 | } 28 | 29 | void resize(size_t size) 30 | { 31 | assert(size > data_.size()); 32 | nextIndex_ = data_.size(); 33 | data_.resize(size); 34 | } 35 | 36 | size_t insert(const T& v) 37 | { 38 | const auto idx = getNewIndex(); 39 | data_.insert(idx, v); 40 | return idx; 41 | } 42 | 43 | size_t insert(T&& v) 44 | { 45 | const auto idx = getNewIndex(); 46 | data_.insert(idx, std::move(v)); 47 | return idx; 48 | } 49 | 50 | template 51 | size_t emplace(Args&&... args) 52 | { 53 | const auto idx = getNewIndex(); 54 | data_.emplace(idx, std::forward(args)...); 55 | return idx; 56 | } 57 | 58 | void remove(size_t index) 59 | { 60 | assert(data_.contains(index)); 61 | data_.remove(index); 62 | freeList_.push(index); 63 | } 64 | 65 | T& operator[](size_t index) 66 | { 67 | assert(data_.contains(index)); 68 | return data_[index]; 69 | } 70 | 71 | const T& operator[](size_t index) const 72 | { 73 | assert(data_.contains(index)); 74 | return data_[index]; 75 | } 76 | 77 | private: 78 | size_t getNewIndex() 79 | { 80 | if (!freeList_.empty()) { 81 | const auto idx = freeList_.front(); 82 | freeList_.pop(); 83 | return idx; 84 | } 85 | return nextIndex_++; 86 | } 87 | 88 | VectorMap data_; 89 | size_t nextIndex_ = 0; 90 | std::queue freeList_; 91 | }; 92 | -------------------------------------------------------------------------------- /src/ssl.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | 8 | #include "filewatcher.hpp" 9 | #include "tcp.hpp" 10 | 11 | // Must be at least 1.1.1 12 | static_assert(OPENSSL_VERSION_NUMBER >= 10101000); 13 | 14 | // No init function necessary anymore 15 | // https://www.openssl.org/docs/man1.1.1/man3/OPENSSL_init_ssl.html 16 | // As of version 1.1.0 OpenSSL will automatically allocate all resources that it needs so no 17 | // explicit initialisation is required. Similarly it will also automatically deinitialise as 18 | // required. 19 | 20 | // If an error occured, call this or ERR_clear_error() to clear the error queue 21 | std::string getSslErrorString(); 22 | 23 | std::string sslErrorToString(int sslError); 24 | 25 | class SslContext { 26 | public: 27 | static std::unique_ptr createServer( 28 | const std::string& certChainPath, const std::string& keyPath); 29 | static std::unique_ptr createClient(); 30 | 31 | enum class Mode { Invalid = 0, Client, Server }; 32 | 33 | SslContext(Mode mode); 34 | ~SslContext(); 35 | SslContext(SslContext&) = delete; 36 | SslContext& operator=(SslContext&) = delete; 37 | SslContext(SslContext&&); 38 | SslContext& operator=(SslContext&&); 39 | 40 | // The cert chain file and key file must be PEM files without a password. 41 | // This is enough because I can test with it and it is also what certbot spits out. 42 | bool initServer(const std::string& certChainPath, const std::string& keyPath); 43 | bool initClient(); 44 | 45 | operator SSL_CTX*(); 46 | 47 | private: 48 | SSL_CTX* ctx_; 49 | }; 50 | 51 | class SslServerContextManager { 52 | public: 53 | SslServerContextManager(IoQueue& io, std::string certChainPath, std::string keyPath); 54 | 55 | std::shared_ptr getCurrentContext() const; 56 | 57 | private: 58 | void updateContext(); 59 | 60 | void fileWatcherCallback(std::error_code ec); 61 | 62 | std::string certChainPath_; 63 | std::string keyPath_; 64 | std::shared_ptr currentContext_; 65 | IoQueue& io_; 66 | FileWatcher fileWatcher_; 67 | }; 68 | 69 | class SslClientContextManager { 70 | public: 71 | SslClientContextManager(); 72 | 73 | std::shared_ptr getCurrentContext() const; 74 | 75 | private: 76 | std::shared_ptr currentContext_; 77 | }; 78 | 79 | struct OpenSslErrorCategory : public std::error_category { 80 | public: 81 | const char* name() const noexcept override; 82 | std::string message(int errorCode) const override; 83 | 84 | static std::error_code makeError(unsigned long err); 85 | }; 86 | 87 | OpenSslErrorCategory& getOpenSslErrorCategory(); 88 | 89 | enum class SslOperation { Invalid = 0, Read, Write, Shutdown }; 90 | std::string toString(SslOperation op); 91 | 92 | // This whole thing is *heavily* inspired by what Boost ASIO is doing 93 | class SslConnection : public TcpConnection { 94 | public: 95 | SslConnection(IoQueue& io, int fd, std::shared_ptr context); 96 | ~SslConnection(); 97 | 98 | // Not movable or copyable, because pointers to it are captured in lambdas 99 | SslConnection(SslConnection&&) = delete; 100 | SslConnection(const SslConnection&) = delete; 101 | SslConnection& operator=(const SslConnection&) = delete; 102 | SslConnection& operator=(SslConnection&&) = delete; 103 | 104 | // For hostname validation (only applicable to client connection) 105 | bool setHostname(const std::string& hostname); 106 | 107 | // If a handler of any of these three functions comes back with an error, 108 | // don't do any other IO on the socket and do not call shutdown (just close it). 109 | void recv(void* buffer, size_t len, IoQueue::HandlerEcRes handler); 110 | void recv(void* buffer, size_t len, IoQueue::Timespec* timeout, IoQueue::HandlerEcRes handler); 111 | void send(const void* buffer, size_t len, IoQueue::HandlerEcRes handler); 112 | void send( 113 | const void* buffer, size_t len, IoQueue::Timespec* timeout, IoQueue::HandlerEcRes handler); 114 | void shutdown(IoQueue::HandlerEc handler); 115 | 116 | private: 117 | struct SslOperationResult { 118 | int result; 119 | int error; 120 | }; 121 | 122 | // There is only one of these, but I think it's nicer to contain it 123 | struct SslOperationState { 124 | IoQueue::HandlerEcRes handler = nullptr; 125 | SslOperation currentOp = SslOperation::Invalid; 126 | void* buffer = nullptr; 127 | int length = 0; 128 | IoQueue::Timespec* timeout = nullptr; 129 | int lastResult = 0; 130 | int lastError = 0; 131 | }; 132 | 133 | static SslOperationResult performSslOperation( 134 | SslOperation op, SSL* ssl, void* buffer, int length); 135 | 136 | void sendFromBuffer(size_t offset, size_t size); 137 | 138 | void startSslOperation(SslOperation op, void* buffer, int length, IoQueue::Timespec* timeout, 139 | IoQueue::HandlerEcRes handler); 140 | void performSslOperation(); 141 | void processSslOperationResult(const SslOperationResult& result); 142 | void updateSslOperation(); 143 | void completeSslOperation(std::error_code ec, int result); 144 | 145 | SSL* ssl_; 146 | BIO* externalBio_ = nullptr; 147 | std::vector recvBuffer_; 148 | std::vector sendBuffer_; 149 | SslOperationState state_; 150 | }; 151 | 152 | template 153 | struct SslConnectionFactory { 154 | using Connection = SslConnection; 155 | 156 | std::unique_ptr contextManager; 157 | 158 | template 159 | SslConnectionFactory(Args&&... args) 160 | : contextManager(std::make_unique(std::forward(args)...)) 161 | { 162 | } 163 | 164 | std::unique_ptr create(IoQueue& io, int fd) 165 | { 166 | auto context = contextManager->getCurrentContext(); 167 | return context ? std::make_unique(io, fd, std::move(context)) : nullptr; 168 | } 169 | }; 170 | 171 | using SslClientConnectionFactory = SslConnectionFactory; 172 | using SslServerConnectionFactory = SslConnectionFactory; 173 | -------------------------------------------------------------------------------- /src/string.cpp: -------------------------------------------------------------------------------- 1 | #include "string.hpp" 2 | 3 | #include 4 | #include 5 | 6 | constexpr std::array getToLowerTable() 7 | { 8 | std::array table = {}; 9 | for (size_t i = 0; i < 256; ++i) { 10 | table[i] = static_cast(static_cast(i)); 11 | if (i >= 'A' && i <= 'Z') { 12 | table[i] -= 'A' - 'a'; 13 | } 14 | } 15 | return table; 16 | } 17 | 18 | // No fucking LOCALES, DUDE (FUCK THEEEEEM) 19 | char toLower(char c) 20 | { 21 | static auto table = getToLowerTable(); 22 | return table[static_cast(c)]; 23 | } 24 | 25 | bool ciEqual(std::string_view a, std::string_view b) 26 | { 27 | if (a.size() != b.size()) { 28 | return false; 29 | } 30 | for (size_t i = 0; i < a.size(); ++i) { 31 | if (toLower(a[i]) != toLower(b[i])) { 32 | return false; 33 | } 34 | } 35 | return true; 36 | } 37 | 38 | bool isHttpWhitespace(char c) 39 | { 40 | return c == ' ' || c == '\t'; 41 | } 42 | 43 | bool isDigit(char c) 44 | { 45 | return c >= '0' && c <= '9'; 46 | } 47 | 48 | std::vector split(std::string_view str, char delim) 49 | { 50 | std::vector parts; 51 | size_t i = 0; 52 | while (i < str.size()) { 53 | const auto delimPos = str.find(delim, i); 54 | if (delimPos == std::string_view::npos) { 55 | break; 56 | } 57 | parts.push_back(str.substr(i, delimPos - i)); 58 | i = delimPos + 1; 59 | } 60 | parts.push_back(str.substr(i)); 61 | return parts; 62 | } 63 | 64 | std::string_view httpTrim(std::string_view str) 65 | { 66 | if (str.empty()) { 67 | return str; 68 | } 69 | 70 | size_t start = 0; 71 | while (start < str.size() && isHttpWhitespace(str[start])) { 72 | start++; 73 | } 74 | if (start == str.size()) { 75 | return str.substr(start, 0); 76 | } 77 | assert(start < str.size()); 78 | 79 | auto end = str.size() - 1; 80 | while (end > start && isHttpWhitespace(str[end])) { 81 | end--; 82 | } 83 | 84 | return str.substr(start, end + 1 - start); 85 | } 86 | 87 | bool startsWith(std::string_view str, std::string_view start) 88 | { 89 | return str.substr(0, start.size()) == start; 90 | } 91 | 92 | bool endsWith(std::string_view str, std::string_view end) 93 | { 94 | return str.substr(str.size() - end.size()) == end; 95 | } 96 | 97 | std::string pathJoin(std::string_view a, std::string_view b) 98 | { 99 | assert(!a.empty()); 100 | std::string ret(a); 101 | if (a.back() != '/') { 102 | ret.push_back('/'); 103 | } 104 | ret.append(b); 105 | return ret; 106 | } 107 | 108 | std::string rjust(std::string_view str, size_t length, char ch) 109 | { 110 | if (str.size() >= length) { 111 | return std::string(str); 112 | } 113 | const auto n = length - str.size(); 114 | return std::string(n, ch) + std::string(str); 115 | } 116 | -------------------------------------------------------------------------------- /src/string.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | // NO. LOCALES. 10 | char toLower(char c); 11 | 12 | bool ciEqual(std::string_view a, std::string_view b); 13 | 14 | bool isHttpWhitespace(char c); 15 | bool isDigit(char c); 16 | 17 | std::vector split(std::string_view str, char delim); 18 | 19 | std::string_view httpTrim(std::string_view str); 20 | 21 | bool startsWith(std::string_view str, std::string_view start); 22 | bool endsWith(std::string_view str, std::string_view end); 23 | 24 | template 25 | std::optional parseInt(std::string_view str, int base = 10) 26 | { 27 | const auto first = str.data(); 28 | const auto last = first + str.size(); 29 | T value; 30 | const auto res = std::from_chars(first, last, value, base); 31 | if (res.ec == std::errc() && res.ptr == last) { 32 | return value; 33 | } else { 34 | return std::nullopt; 35 | } 36 | } 37 | 38 | std::string pathJoin(std::string_view a, std::string_view b); 39 | 40 | template 41 | std::string join(const Container& container, std::string_view delim = ", ") 42 | { 43 | std::string ret; 44 | bool first = true; 45 | for (const auto& elem : container) { 46 | if (!first) { 47 | ret.append(delim); 48 | } 49 | first = false; 50 | ret.append(elem); 51 | } 52 | return ret; 53 | } 54 | 55 | std::string rjust(std::string_view str, size_t length, char ch); 56 | -------------------------------------------------------------------------------- /src/tcp.cpp: -------------------------------------------------------------------------------- 1 | #include "tcp.hpp" 2 | 3 | TcpConnection::TcpConnection(IoQueue& io, int fd) 4 | : io_(io) 5 | , fd_(fd) 6 | { 7 | } 8 | 9 | void TcpConnection::recv(void* buffer, size_t len, IoQueue::HandlerEcRes handler) 10 | { 11 | io_.recv(fd_, buffer, len, std::move(handler)); 12 | } 13 | 14 | void TcpConnection::recv( 15 | void* buffer, size_t len, IoQueue::Timespec* timeout, IoQueue::HandlerEcRes handler) 16 | { 17 | io_.recv(fd_, buffer, len, timeout, true, std::move(handler)); 18 | } 19 | 20 | void TcpConnection::send(const void* buffer, size_t len, IoQueue::HandlerEcRes handler) 21 | { 22 | io_.send(fd_, buffer, len, std::move(handler)); 23 | } 24 | 25 | void TcpConnection::send( 26 | const void* buffer, size_t len, IoQueue::Timespec* timeout, IoQueue::HandlerEcRes handler) 27 | { 28 | io_.send(fd_, buffer, len, timeout, true, std::move(handler)); 29 | } 30 | 31 | void TcpConnection::shutdown(IoQueue::HandlerEc handler) 32 | { 33 | io_.shutdown(fd_, SHUT_RDWR, std::move(handler)); 34 | } 35 | 36 | void TcpConnection::close() 37 | { 38 | io_.close(fd_, [](std::error_code /*ec*/) {}); 39 | } 40 | -------------------------------------------------------------------------------- /src/tcp.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | #include 6 | 7 | class TcpConnection { 8 | public: 9 | TcpConnection(IoQueue& io, int fd); 10 | 11 | void recv(void* buffer, size_t len, IoQueue::HandlerEcRes handler); 12 | void recv(void* buffer, size_t len, IoQueue::Timespec* timeout, IoQueue::HandlerEcRes handler); 13 | void send(const void* buffer, size_t len, IoQueue::HandlerEcRes handler); 14 | void send( 15 | const void* buffer, size_t len, IoQueue::Timespec* timeout, IoQueue::HandlerEcRes handler); 16 | void shutdown(IoQueue::HandlerEc handler); 17 | void close(); 18 | 19 | protected: 20 | IoQueue& io_; 21 | int fd_; 22 | }; 23 | 24 | struct TcpConnectionFactory { 25 | using Connection = TcpConnection; 26 | 27 | std::unique_ptr create(IoQueue& io, int fd) 28 | { 29 | return std::make_unique(io, fd); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/time.cpp: -------------------------------------------------------------------------------- 1 | #include "time.hpp" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include "string.hpp" 8 | 9 | Duration Duration::normalized() const 10 | { 11 | auto totalSeconds = toSeconds(); 12 | const auto days = totalSeconds / 24 / 60 / 60; 13 | totalSeconds -= days * 24 * 60 * 60; 14 | const auto hours = totalSeconds / 60 / 60; 15 | totalSeconds -= hours * 60 * 60; 16 | const auto minutes = totalSeconds / 60; 17 | totalSeconds -= minutes * 60; 18 | const auto seconds = totalSeconds; 19 | return Duration { days, hours, minutes, seconds }; 20 | } 21 | 22 | std::optional Duration::parse(std::string_view str) 23 | { 24 | if (str.size() < 2) { 25 | return std::nullopt; 26 | } 27 | 28 | const auto num = str.substr(0, str.size() - 1); 29 | const auto numVal = parseInt(num); 30 | if (!numVal) { 31 | return std::nullopt; 32 | } 33 | 34 | switch (str.back()) { 35 | case 'd': 36 | return Duration::fromDays(*numVal); 37 | case 'h': 38 | return Duration::fromHours(*numVal); 39 | case 'm': 40 | return Duration::fromMinutes(*numVal); 41 | case 's': 42 | return Duration::fromSeconds(*numVal); 43 | default: 44 | return std::nullopt; 45 | } 46 | } 47 | 48 | Duration Duration::fromDays(uint32_t v) 49 | { 50 | return Duration { v, 0, 0, 0 }.normalized(); 51 | } 52 | 53 | Duration Duration::fromHours(uint32_t v) 54 | { 55 | return Duration { 0, v, 0, 0 }.normalized(); 56 | } 57 | 58 | Duration Duration::fromMinutes(uint32_t v) 59 | { 60 | return Duration { 0, 0, v, 0 }.normalized(); 61 | } 62 | 63 | Duration Duration::fromSeconds(uint32_t v) 64 | { 65 | return Duration { 0, 0, 0, v }.normalized(); 66 | } 67 | 68 | std::string toString(const Duration& d) 69 | { 70 | return std::to_string(d.days) + "d" + std::to_string(d.hours) + "h" + std::to_string(d.minutes) 71 | + "m" + std::to_string(d.seconds) + "s"; 72 | } 73 | 74 | bool operator<(const Duration& a, const Duration& b) 75 | { 76 | const auto na = a.normalized(); 77 | const auto nb = b.normalized(); 78 | // It's much less code and much simpler this way 79 | return std::make_tuple(na.days, na.hours, na.minutes, na.seconds) 80 | < std::make_tuple(nb.days, nb.hours, nb.minutes, nb.seconds); 81 | } 82 | 83 | std::optional TimePoint::parse(std::string_view str) 84 | { 85 | if (str.size() < 3) { 86 | return std::nullopt; 87 | } 88 | 89 | const auto parts = split(str, ':'); 90 | assert(parts.size() > 0); 91 | if (parts.size() < 2 || parts.size() > 3) { 92 | return std::nullopt; 93 | } 94 | 95 | std::vector nums; 96 | for (const auto part : parts) { 97 | const auto n = parseInt(part); 98 | if (!n) { 99 | return std::nullopt; 100 | } 101 | nums.push_back(*n); 102 | } 103 | 104 | assert(nums.size() == 2 || nums.size() == 3); 105 | if (nums[0] >= 24 || nums[1] >= 60) { 106 | return std::nullopt; 107 | } 108 | if (nums.size() == 2) { 109 | return TimePoint { nums[0], nums[1], 0 }; 110 | } else if (nums.size() == 3) { 111 | if (nums[2] >= 60) { 112 | return std::nullopt; 113 | } 114 | return TimePoint { nums[0], nums[1], nums[2] }; 115 | } else { 116 | return std::nullopt; 117 | } 118 | } 119 | 120 | Duration TimePoint::getDurationUntil(const TimePoint& other) const 121 | { 122 | assert(hours < 24 && minutes < 60 && seconds < 60); 123 | assert(other.hours < 24 && other.minutes < 60 && other.seconds < 60); 124 | 125 | const auto secs = Duration { 0, hours, minutes, seconds }.toSeconds(); 126 | const auto otherSecs = Duration { 0, other.hours, other.minutes, other.seconds }.toSeconds(); 127 | 128 | auto ds = static_cast(otherSecs) - static_cast(secs); 129 | 130 | if (ds < 0) { 131 | ds += 24 * 60 * 60; 132 | } 133 | 134 | return Duration::fromSeconds(ds); 135 | } 136 | 137 | std::string toString(const TimePoint& tp) 138 | { 139 | return rjust(std::to_string(tp.hours), 2, '0') + ":" + rjust(std::to_string(tp.minutes), 2, '0') 140 | + ":" + rjust(std::to_string(tp.seconds), 2, '0'); 141 | } 142 | -------------------------------------------------------------------------------- /src/time.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | struct Duration { 7 | uint32_t days = 0; 8 | uint32_t hours = 0; 9 | uint32_t minutes = 0; 10 | uint32_t seconds = 0; 11 | 12 | constexpr uint32_t toSeconds() const 13 | { 14 | return seconds + 60 * (minutes + 60 * (hours + 24 * days)); 15 | } 16 | 17 | constexpr uint32_t toMinutes() const { return toSeconds() / 60; } 18 | constexpr uint32_t toHours() const { return toMinutes() / 60; } 19 | constexpr uint32_t toDays() const { return toHours() / 24; } 20 | 21 | Duration normalized() const; 22 | 23 | static std::optional parse(std::string_view str); 24 | static Duration fromDays(uint32_t d); 25 | static Duration fromHours(uint32_t h); 26 | static Duration fromMinutes(uint32_t m); 27 | static Duration fromSeconds(uint32_t s); 28 | }; 29 | 30 | std::string toString(const Duration& d); 31 | 32 | bool operator<(const Duration& a, const Duration& b); 33 | 34 | struct TimePoint { 35 | uint32_t hours; 36 | uint32_t minutes; 37 | uint32_t seconds = 0; 38 | 39 | Duration getDurationUntil(const TimePoint& until) const; 40 | static std::optional parse(std::string_view str); 41 | }; 42 | 43 | std::string toString(const TimePoint& d); 44 | -------------------------------------------------------------------------------- /src/tokenbucket.cpp: -------------------------------------------------------------------------------- 1 | #include "tokenbucket.hpp" 2 | 3 | #include "util.hpp" 4 | 5 | TokenBucket::TokenBucket(double capacity, double fillRate) 6 | : capacity_(capacity) 7 | , fillRate_(fillRate) 8 | , level_(capacity) 9 | , lastUpdate_(nowMillis()) 10 | { 11 | } 12 | 13 | bool TokenBucket::pull(double tokens) 14 | { 15 | update(); 16 | if (level_ >= tokens) { 17 | level_ -= tokens; 18 | return true; 19 | } 20 | return false; 21 | } 22 | 23 | void TokenBucket::update() 24 | { 25 | const auto now = nowMillis(); 26 | // I use doubles so that you don't get rejected if the bucket is empty, the fill rate is 1 27 | // and you attempt to request the page every 500ms. 28 | // With integers, this would just update lastUpdate_ every time and increment level_ by nothing. 29 | level_ += (now - lastUpdate_) * fillRate_ / 1000.0; 30 | level_ = std::min(level_, capacity_); 31 | lastUpdate_ = now; 32 | } 33 | 34 | double TokenBucket::getLevel() const 35 | { 36 | return level_; 37 | } 38 | 39 | uint64_t TokenBucket::getLastUpdate() const 40 | { 41 | return lastUpdate_; 42 | } 43 | -------------------------------------------------------------------------------- /src/tokenbucket.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | 5 | class TokenBucket { 6 | public: 7 | TokenBucket(double capacity, double fillRate); 8 | 9 | bool pull(double tokens = 1.0); 10 | 11 | void update(); 12 | 13 | double getLevel() const; 14 | 15 | uint64_t getLastUpdate() const; 16 | 17 | private: 18 | double capacity_; 19 | double fillRate_; 20 | double level_; 21 | uint64_t lastUpdate_; 22 | }; 23 | -------------------------------------------------------------------------------- /src/util.cpp: -------------------------------------------------------------------------------- 1 | #include "util.hpp" 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | #include 8 | #include 9 | 10 | #include "log.hpp" 11 | #include "metrics.hpp" 12 | #include "string.hpp" 13 | 14 | std::optional IpPort::parse(std::string_view str) 15 | { 16 | auto ipStr = std::string_view(); 17 | auto portStr = std::string_view(); 18 | 19 | const auto colon = str.find(':'); 20 | if (colon == std::string::npos) { 21 | portStr = str; 22 | } else { 23 | ipStr = str.substr(0, colon); 24 | portStr = str.substr(colon + 1); 25 | } 26 | 27 | std::optional ip; 28 | if (!ipStr.empty()) { 29 | ip = parseIpAddress(std::string(ipStr)); 30 | if (!ip) { 31 | return std::nullopt; 32 | } 33 | } 34 | 35 | const auto port = parseInt(portStr); 36 | if (!port) { 37 | return std::nullopt; 38 | } 39 | 40 | return IpPort { ip, *port }; 41 | } 42 | 43 | std::string errnoToString(int err) 44 | { 45 | return std::make_error_code(static_cast(err)).message(); 46 | } 47 | 48 | std::optional parseIpAddress(const std::string& str) 49 | { 50 | ::in_addr addr; 51 | const auto res = ::inet_aton(str.c_str(), &addr); 52 | if (res == 0) { 53 | return std::nullopt; 54 | } 55 | return addr.s_addr; 56 | } 57 | 58 | std::optional readFile(const std::string& path) 59 | { 60 | const auto timeHandle = Metrics::get().fileReadDuration.labels(path).time(); 61 | auto f = std::unique_ptr( 62 | std::fopen(path.c_str(), "rb"), &std::fclose); 63 | if (!f) { 64 | slog::error("Could not open file: '", path, "'"); 65 | return std::nullopt; 66 | } 67 | 68 | const auto fd = ::fileno(f.get()); 69 | if (fd == -1) { 70 | slog::error("Could not retrieve file descriptor for file: '", path, "'"); 71 | return std::nullopt; 72 | } 73 | 74 | struct ::stat st; 75 | if (::fstat(fd, &st)) { 76 | slog::error("Could not stat file: '", path, "'"); 77 | return std::nullopt; 78 | } 79 | 80 | // fopen-ing a directory in read-only mode will actually not fail! 81 | // And it will return a size of 0x7fffffffffffffff, which is bad. 82 | if (!S_ISREG(st.st_mode)) { 83 | slog::error("'", path, "' is not a regular file"); 84 | return std::nullopt; 85 | } 86 | 87 | if (std::fseek(f.get(), 0, SEEK_END) != 0) { 88 | slog::error("Error seeking to end of file: '", path, "'"); 89 | return std::nullopt; 90 | } 91 | const auto size = std::ftell(f.get()); 92 | if (size < 0) { 93 | slog::error("Error getting size of file: '", path, "'"); 94 | return std::nullopt; 95 | } 96 | if (std::fseek(f.get(), 0, SEEK_SET) != 0) { 97 | slog::error("Error seeking to start of file: '", path, "'"); 98 | return std::nullopt; 99 | } 100 | std::string buf(size, '\0'); 101 | if (std::fread(buf.data(), 1, size, f.get()) != static_cast(size)) { 102 | slog::error("Error reading file: '", path, "'"); 103 | return std::nullopt; 104 | } 105 | return buf; 106 | } 107 | 108 | uint64_t nowMillis() 109 | { 110 | ::timespec ts; 111 | // Errors are ignored on purpose 112 | clock_gettime(CLOCK_MONOTONIC, &ts); 113 | return ts.tv_sec * 1000 + ts.tv_nsec / (1000 * 1000); 114 | } 115 | -------------------------------------------------------------------------------- /src/util.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | 6 | struct IpPort { 7 | std::optional ip; 8 | uint16_t port; 9 | 10 | static std::optional parse(std::string_view str); 11 | }; 12 | 13 | std::string errnoToString(int err); 14 | std::optional parseIpAddress(const std::string& str); 15 | std::optional readFile(const std::string& path); 16 | 17 | uint64_t nowMillis(); 18 | -------------------------------------------------------------------------------- /src/vectormap.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | 7 | template 8 | class VectorMap { 9 | private: 10 | using DataElem = std::aligned_storage_t; 11 | 12 | public: 13 | VectorMap() = default; 14 | VectorMap(size_t size) 15 | : data_(new DataElem[size]) 16 | , occupied_(size, bool_ { false }) 17 | { 18 | } 19 | 20 | ~VectorMap() 21 | { 22 | delete[] data_; 23 | } 24 | 25 | size_t size() const 26 | { 27 | return size_; 28 | } 29 | 30 | size_t occupied() const 31 | { 32 | return numOccupied_; 33 | } 34 | 35 | void resize(size_t size) 36 | { 37 | assert(size > size_); 38 | auto newData = new DataElem[size]; 39 | for (size_t i = 0; i < size_; ++i) { 40 | new (newData + i) T { std::move(*reinterpret_cast(data_ + i)) }; 41 | reinterpret_cast(data_ + i)->~T(); 42 | } 43 | delete[] data_; 44 | data_ = newData; 45 | size_ = size; 46 | occupied_.resize(size, bool_ { false }); 47 | } 48 | 49 | void insert(size_t index, const T& v) 50 | { 51 | emplace(index, v); 52 | } 53 | 54 | void insert(size_t index, T&& v) 55 | { 56 | emplace(index, std::move(v)); 57 | } 58 | 59 | template 60 | T& emplace(size_t index, Args&&... args) 61 | { 62 | assert(!contains(index)); 63 | if (index >= size_) { 64 | resize(std::max(std::max(size_ * 2, index + 1), 0ul)); 65 | } 66 | new (data_ + index) T { std::forward(args)... }; 67 | occupied_[index].value = true; 68 | numOccupied_++; 69 | return *reinterpret_cast(data_ + index); 70 | } 71 | 72 | bool contains(size_t index) const 73 | { 74 | return index < occupied_.size() && occupied_[index].value; 75 | } 76 | 77 | void remove(size_t index) 78 | { 79 | assert(contains(index)); 80 | reinterpret_cast(data_ + index)->~T(); 81 | occupied_[index].value = false; 82 | numOccupied_--; 83 | } 84 | 85 | T& operator[](size_t index) 86 | { 87 | assert(contains(index)); 88 | return *reinterpret_cast(data_ + index); 89 | } 90 | 91 | const T& operator[](size_t index) const 92 | { 93 | assert(contains(index)); 94 | return *reinterpret_cast(data_ + index); 95 | } 96 | 97 | private: 98 | struct bool_ { 99 | bool value; 100 | }; 101 | 102 | DataElem* data_ = nullptr; 103 | size_t size_ = 0; 104 | size_t numOccupied_ = 0; 105 | std::vector occupied_; 106 | }; 107 | -------------------------------------------------------------------------------- /subprojects/clipp.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | url = https://github.com/pfirsich/clipp 3 | revision = 1abc055afa9a9c77879092461f83e51176771fea 4 | -------------------------------------------------------------------------------- /subprojects/cpprom.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | url = https://github.com/pfirsich/cpprom 3 | revision = 24f8062e5f5a860a4288cbc14faaea7d1feb447c 4 | -------------------------------------------------------------------------------- /subprojects/joml-cpp.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | url = https://github.com/pfirsich/joml-cpp 3 | revision = edde76f69d4198ae8e96451b89f7eda805864dd3 4 | -------------------------------------------------------------------------------- /subprojects/liburingpp.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | url = https://github.com/pfirsich/liburingpp 3 | revision = 4b34f0e9c5bcf6debd3af7b3a75b25c8b8fde2d1 4 | -------------------------------------------------------------------------------- /subprojects/minijson.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | url = https://github.com/pfirsich/minijson 3 | revision = a9c00aa38181994e9e59cbec64921ea94e524764 4 | -------------------------------------------------------------------------------- /testfiles/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pfirsich/htcpp/38dc045390e7ee1a3ddd72eaaf65401095226335/testfiles/cat.jpg -------------------------------------------------------------------------------- /testfiles/lorem_ipsum.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Condimentum id venenatis a condimentum vitae. Vivamus at augue eget arcu dictum varius duis at consectetur. Aliquet nibh praesent tristique magna sit amet purus. Nulla porttitor massa id neque aliquam vestibulum morbi. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Viverra vitae congue eu consequat ac felis donec et. In fermentum posuere urna nec tincidunt praesent. In cursus turpis massa tincidunt dui ut. Porttitor eget dolor morbi non arcu risus quis varius quam. Enim blandit volutpat maecenas volutpat. Pretium quam vulputate dignissim suspendisse in est ante in nibh. 2 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Condimentum id venenatis a condimentum vitae. Vivamus at augue eget arcu dictum varius duis at consectetur. Aliquet nibh praesent tristique magna sit amet purus. Nulla porttitor massa id neque aliquam vestibulum morbi. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Viverra vitae congue eu consequat ac felis donec et. In fermentum posuere urna nec tincidunt praesent. In cursus turpis massa tincidunt dui ut. Porttitor eget dolor morbi non arcu risus quis varius quam. Enim blandit volutpat maecenas volutpat. Pretium quam vulputate dignissim suspendisse in est ante in nibh. 3 | -------------------------------------------------------------------------------- /testfiles/lorem_ipsum_large.txt: -------------------------------------------------------------------------------- 1 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Condimentum id venenatis a condimentum vitae. Vivamus at augue eget arcu dictum varius duis at consectetur. Aliquet nibh praesent tristique magna sit amet purus. Nulla porttitor massa id neque aliquam vestibulum morbi. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Viverra vitae congue eu consequat ac felis donec et. In fermentum posuere urna nec tincidunt praesent. In cursus turpis massa tincidunt dui ut. Porttitor eget dolor morbi non arcu risus quis varius quam. Enim blandit volutpat maecenas volutpat. Pretium quam vulputate dignissim suspendisse in est ante in nibh. 2 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Condimentum id venenatis a condimentum vitae. Vivamus at augue eget arcu dictum varius duis at consectetur. Aliquet nibh praesent tristique magna sit amet purus. Nulla porttitor massa id neque aliquam vestibulum morbi. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Viverra vitae congue eu consequat ac felis donec et. In fermentum posuere urna nec tincidunt praesent. In cursus turpis massa tincidunt dui ut. Porttitor eget dolor morbi non arcu risus quis varius quam. Enim blandit volutpat maecenas volutpat. Pretium quam vulputate dignissim suspendisse in est ante in nibh. 3 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Condimentum id venenatis a condimentum vitae. Vivamus at augue eget arcu dictum varius duis at consectetur. Aliquet nibh praesent tristique magna sit amet purus. Nulla porttitor massa id neque aliquam vestibulum morbi. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Viverra vitae congue eu consequat ac felis donec et. In fermentum posuere urna nec tincidunt praesent. In cursus turpis massa tincidunt dui ut. Porttitor eget dolor morbi non arcu risus quis varius quam. Enim blandit volutpat maecenas volutpat. Pretium quam vulputate dignissim suspendisse in est ante in nibh. 4 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Condimentum id venenatis a condimentum vitae. Vivamus at augue eget arcu dictum varius duis at consectetur. Aliquet nibh praesent tristique magna sit amet purus. Nulla porttitor massa id neque aliquam vestibulum morbi. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Viverra vitae congue eu consequat ac felis donec et. In fermentum posuere urna nec tincidunt praesent. In cursus turpis massa tincidunt dui ut. Porttitor eget dolor morbi non arcu risus quis varius quam. Enim blandit volutpat maecenas volutpat. Pretium quam vulputate dignissim suspendisse in est ante in nibh. 5 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Condimentum id venenatis a condimentum vitae. Vivamus at augue eget arcu dictum varius duis at consectetur. Aliquet nibh praesent tristique magna sit amet purus. Nulla porttitor massa id neque aliquam vestibulum morbi. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Viverra vitae congue eu consequat ac felis donec et. In fermentum posuere urna nec tincidunt praesent. In cursus turpis massa tincidunt dui ut. Porttitor eget dolor morbi non arcu risus quis varius quam. Enim blandit volutpat maecenas volutpat. Pretium quam vulputate dignissim suspendisse in est ante in nibh. 6 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Condimentum id venenatis a condimentum vitae. Vivamus at augue eget arcu dictum varius duis at consectetur. Aliquet nibh praesent tristique magna sit amet purus. Nulla porttitor massa id neque aliquam vestibulum morbi. Laoreet suspendisse interdum consectetur libero id faucibus nisl tincidunt eget. Viverra vitae congue eu consequat ac felis donec et. In fermentum posuere urna nec tincidunt praesent. In cursus turpis massa tincidunt dui ut. Porttitor eget dolor morbi non arcu risus quis varius quam. Enim blandit volutpat maecenas volutpat. Pretium quam vulputate dignissim suspendisse in est ante in nibh. 7 | -------------------------------------------------------------------------------- /tests/rate_limiting/config.joml: -------------------------------------------------------------------------------- 1 | services: { 2 | "127.0.0.1:6969": { 3 | access_log: false 4 | limit_requests_by_ip: { 5 | steady_rate: 1 6 | burst_size: 10 7 | max_num_entries: 4 8 | } 9 | hosts: { 10 | "*": { 11 | files: "testfiles/" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/rate_limiting/test_rate_limiting.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import subprocess 3 | import re 4 | import time 5 | 6 | 7 | # It's a giant pain to control the source IP with requests/urllib, so I just use curl 8 | def get(src_ip, url): 9 | output = subprocess.run( 10 | ["curl", "--interface", src_ip, "--include", url], capture_output=True 11 | ) 12 | status_line = output.stdout.decode("utf-8").split("\r\n", 1)[0] 13 | m = re.match(r"^HTTP/1\.1 (\d+) .*$", status_line) 14 | assert m 15 | return int(m.group(1)) 16 | 17 | 18 | def probe_bucket_level(src_ip, url): 19 | max_tries = 100 20 | for i in range(max_tries): 21 | if get(src_ip, url) == 429: 22 | return i 23 | return max_tries 24 | 25 | 26 | def main(): 27 | htcpp = subprocess.Popen(["build/htcpp", "tests/rate_limiting/config.joml"]) 28 | url = "http://localhost:6969/lorem_ipsum.txt" 29 | 30 | print("Testing burst sizes") 31 | # First request from this IP, bucket should be full (steady_rate) 32 | assert 10 <= probe_bucket_level("127.0.0.1", url) <= 12 33 | # Bucket should be empty now, so only 0 or 1 requests should get through 34 | assert probe_bucket_level("127.0.0.1", url) <= 1 35 | 36 | assert 10 <= probe_bucket_level("127.0.0.2", url) <= 12 37 | assert 10 <= probe_bucket_level("127.0.0.3", url) <= 12 38 | assert 10 <= probe_bucket_level("127.0.0.4", url) <= 12 39 | assert 10 <= probe_bucket_level("127.0.0.5", url) <= 12 40 | 41 | print("Test evictions") 42 | # 127.0.0.5 should have evicted 127.0.0.1 from the cache and reset rate limiting 43 | assert 10 <= probe_bucket_level("127.0.0.1", url) <= 12 44 | 45 | print("Test steady rate") 46 | # Check that steady rate works 47 | start = time.time() 48 | num_success = 0 49 | while time.time() < start + 6.0: 50 | if get("127.0.0.1", url) == 200: 51 | num_success += 1 52 | assert 5 <= num_success <= 6 53 | 54 | print("Stress test evictions") 55 | # Assert nothing here, just evict a bunch and make sure it doesn't crash or something 56 | for i in range(255): 57 | get(f"127.0.0.{i}", url) 58 | 59 | print("Killing htcpp") 60 | htcpp.kill() 61 | htcpp.wait() 62 | 63 | 64 | if __name__ == "__main__": 65 | main() 66 | -------------------------------------------------------------------------------- /unittests/main.cpp: -------------------------------------------------------------------------------- 1 | #define TEST_DEFINE_MAIN 2 | #include "test.hpp" 3 | -------------------------------------------------------------------------------- /unittests/test.hpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | 6 | static constexpr const char* ESC_RESET = "\x1b[0m"; 7 | static constexpr const char* ESC_BOLD = "\x1b[1m"; 8 | constexpr const char* ESC_GREEN = "\x1b[32m"; 9 | constexpr const char* ESC_RED = "\x1b[31m"; 10 | 11 | namespace detail { 12 | struct TestCase { 13 | struct Context { 14 | bool testPassed = true; 15 | 16 | void check(bool cond, std::string condStr) 17 | { 18 | if (!cond) { 19 | if (testPassed) { 20 | std::cerr << ESC_RED << "FAIL" << ESC_RESET << "\n"; 21 | } 22 | testPassed = false; 23 | std::cerr << "'" << condStr << "' failed.\n"; 24 | } 25 | } 26 | }; 27 | 28 | std::string name; 29 | std::string file; 30 | int line; 31 | std::function func; 32 | 33 | TestCase(std::string name, std::string file, int line, std::function func) 34 | : name(std::move(name)) 35 | , file(std::move(file)) 36 | , line(line) 37 | , func(std::move(func)) 38 | { 39 | registry.push_back(this); 40 | } 41 | 42 | static std::vector registry; 43 | }; 44 | } 45 | 46 | #define TEST_CAT(s1, s2) s1##s2 47 | // This extra layer of indirection (via INNER) is needed so __COUNTER__ gets expanded properly. 48 | // I don't even care why that is necessary, but it hurt to figure out. 49 | #define TEST_FUNCNAME_INNER(counter) TEST_CAT(TEST_FUNCNAME_PREFIX_, counter) 50 | #define TEST_FUNCNAME(counter) TEST_FUNCNAME_INNER(counter) 51 | #define TEST_TC_NAME(func_name) func_name##_tc 52 | 53 | #define TEST_CREATE_TEST_CASE(func_name, tc_name) \ 54 | static void func_name(detail::TestCase::Context&); \ 55 | static const detail::TestCase TEST_TC_NAME(func_name)(tc_name, __FILE__, __LINE__, func_name); \ 56 | static void func_name([[maybe_unused]] detail::TestCase::Context& testContext) 57 | 58 | #define TEST_CASE(tc_name) TEST_CREATE_TEST_CASE(TEST_FUNCNAME(__COUNTER__), tc_name) 59 | 60 | #define TEST_CHECK(cond) testContext.check(cond, #cond); 61 | #define TEST_REQUIRE(cond) \ 62 | testContext.check(cond, #cond); \ 63 | return; 64 | 65 | #ifdef TEST_DEFINE_MAIN 66 | std::vector detail::TestCase::registry; 67 | 68 | int main() 69 | { 70 | for (size_t i = 0; i < detail::TestCase::registry.size(); ++i) { 71 | const auto tc = detail::TestCase::registry[i]; 72 | detail::TestCase::Context ctx; 73 | std::cerr << ESC_BOLD << "[" << i + 1 << "/" << detail::TestCase::registry.size() << "] " 74 | << tc->name << ":" << ESC_RESET << " "; 75 | tc->func(ctx); 76 | if (ctx.testPassed) { 77 | std::cerr << ESC_GREEN << "PASS" << ESC_RESET << "\n"; 78 | } 79 | } 80 | } 81 | #endif 82 | -------------------------------------------------------------------------------- /unittests/time.cpp: -------------------------------------------------------------------------------- 1 | #include "test.hpp" 2 | 3 | #include "time.hpp" 4 | 5 | TEST_CASE("TimePoint::parse") 6 | { 7 | TEST_CHECK(!!TimePoint::parse("23:45")); 8 | TEST_CHECK(!!TimePoint::parse("23:45:12")); 9 | } 10 | 11 | TEST_CASE("TimePoint::parse fails") 12 | { 13 | TEST_CHECK(!TimePoint::parse("01")); 14 | TEST_CHECK(!TimePoint::parse("010203")); 15 | TEST_CHECK(!TimePoint::parse("010203")); 16 | TEST_CHECK(!TimePoint::parse("01:02:a")); 17 | TEST_CHECK(!TimePoint::parse("24:05:06")); 18 | TEST_CHECK(!TimePoint::parse("23:60:06")); 19 | TEST_CHECK(!TimePoint::parse("23:03:60")); 20 | } 21 | 22 | TEST_CASE("TimePoint::getDurationUntil") 23 | { 24 | TEST_CHECK(toString(TimePoint { 12, 4, 3 }.getDurationUntil({ 12, 5, 8 })) == "0d0h1m5s"); 25 | TEST_CHECK(toString(TimePoint { 23, 59, 59 }.getDurationUntil({ 0, 0, 0 })) == "0d0h0m1s"); 26 | } 27 | --------------------------------------------------------------------------------