diff --git a/.github/workflows/cmake-multi-platform.yml b/.github/workflows/cmake-multi-platform.yml index 0439ff0..2788750 100644 --- a/.github/workflows/cmake-multi-platform.yml +++ b/.github/workflows/cmake-multi-platform.yml @@ -72,11 +72,71 @@ jobs: - name: Create config_test.json working-directory: build run: | - echo '{"app_name": "brazierApp"}' > config_test.json + cat > config_test.json <<'EOF' + { + "app_name": "brazierApp", + "https_server": { + "host": "0.0.0.0", + "port": 8443, + "tls": { + "cert_file": "brazier/app/certs/server.crt", + "key_file": "brazier/app/certs/server.key", + "ca_file": "", + "require_client_cert": false, + "verify_client_cert": false, + "handshake_timeout": 3, + "conf": [] + } + }, + "http": { + "max_connections_testing": 20, + "max_body_size_testing": 1048576, + "max_header_size_testing": 8192 + } + } + EOF mkdir -p ${{ matrix.build_type }} cp config_test.json ${{ matrix.build_type }}/ shell: bash + - name: Generate self-signed certificate (main server) + run: | + mkdir -p build/brazier/app/certs + openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \ + -keyout build/brazier/app/certs/server.key \ + -out build/brazier/app/certs/server.crt \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + shell: bash + + - name: Generate mTLS certificates (CA + client) + run: | + set -e + mkdir -p build/certs + cd build/certs + + openssl genrsa -out ca.key 4096 + openssl req -x509 -new -nodes -key ca.key -sha256 -days 1825 \ + -out ca.crt -subj "/CN=Test CA" + + openssl genrsa -out server.key 2048 + openssl req -new -key server.key -out server.csr -subj "/CN=localhost" + echo "subjectAltName=DNS:localhost,IP:127.0.0.1" > server_ext.cnf + openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \ + -CAcreateserial -out server.crt -days 365 -sha256 \ + -extfile server_ext.cnf + + openssl genrsa -out client.key 2048 + openssl req -new -key client.key -out client.csr -subj "/CN=brazier-client" + echo "extendedKeyUsage=clientAuth" > client_ext.cnf + openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \ + -CAserial ca.srl -out client.crt -days 365 -sha256 \ + -extfile client_ext.cnf + + openssl verify -CAfile ca.crt server.crt + openssl verify -CAfile ca.crt client.crt + shell: bash + - name: Run tests working-directory: build run: ./brazier_tests @@ -136,11 +196,67 @@ jobs: - name: Create config_test.json working-directory: build run: | - echo '{"app_name": "brazierApp"}' > config_test.json + cat > config_test.json <<'EOF' + { + "app_name": "brazierApp", + "https_server": { + "host": "0.0.0.0", + "port": 8443, + "tls": { + "cert_file": "brazier/app/certs/server.crt", + "key_file": "brazier/app/certs/server.key", + "ca_file": "", + "require_client_cert": false, + "verify_client_cert": false, + "handshake_timeout": 3, + "conf": [] + } + }, + "http": { + "max_connections_testing": 20, + "max_body_size_testing": 1048576, + "max_header_size_testing": 8192 + } + } + EOF mkdir -p ${{ matrix.build_type }} cp config_test.json ${{ matrix.build_type }}/ shell: bash + - name: Generate self-signed certificate (main server) + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "build/brazier/app/certs" | Out-Null + openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes ` + -keyout "build/brazier/app/certs/server.key" ` + -out "build/brazier/app/certs/server.crt" ` + -subj "/CN=localhost" ` + -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" + + - name: Generate mTLS certificates (CA + client) + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path "build/certs" | Out-Null + Push-Location build/certs + + openssl genrsa -out ca.key 4096 + openssl req -x509 -new -nodes -key ca.key -sha256 -days 1825 -out ca.crt -subj "/CN=Test CA" + + openssl genrsa -out server.key 2048 + openssl req -new -key server.key -out server.csr -subj "/CN=localhost" + Set-Content -Path server_ext.cnf -Value "subjectAltName=DNS:localhost,IP:127.0.0.1" + openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out server.crt -days 365 -sha256 -extfile server_ext.cnf + + openssl genrsa -out client.key 2048 + openssl req -new -key client.key -out client.csr -subj "/CN=brazier-client" + Set-Content -Path client_ext.cnf -Value "extendedKeyUsage=clientAuth" + openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key -CAserial ca.srl -out client.crt -days 365 -sha256 -extfile client_ext.cnf + + openssl verify -CAfile ca.crt server.crt + openssl verify -CAfile ca.crt client.crt + + Pop-Location + - name: Run tests working-directory: build run: ./${{ matrix.build_type }}/brazier_tests.exe @@ -192,11 +308,73 @@ jobs: - name: Create config_test.json working-directory: build run: | - echo '{"app_name": "brazierApp"}' > config_test.json + cat > config_test.json <<'EOF' + { + "app_name": "brazierApp", + "https_server": { + "host": "0.0.0.0", + "port": 8443, + "tls": { + "cert_file": "brazier/app/certs/server.crt", + "key_file": "brazier/app/certs/server.key", + "ca_file": "", + "require_client_cert": false, + "verify_client_cert": false, + "handshake_timeout": 3, + "conf": [] + } + }, + "http": { + "max_connections_testing": 20, + "max_body_size_testing": 1048576, + "max_header_size_testing": 8192 + } + } + EOF mkdir -p ${{ matrix.build_type }} cp config_test.json ${{ matrix.build_type }}/ shell: bash + - name: Generate self-signed certificate (main server) + run: | + mkdir -p build/brazier/app/certs + cd build/brazier/app/certs + echo "subjectAltName=DNS:localhost,IP:127.0.0.1" > server_ext.cnf + openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \ + -keyout server.key -out server.crt \ + -subj "/CN=localhost" \ + -extensions v3_req \ + -config <(cat /etc/ssl/openssl.cnf; echo "[v3_req]"; echo "subjectAltName=DNS:localhost,IP:127.0.0.1") + shell: bash + + - name: Generate mTLS certificates (CA + client) + run: | + set -e + mkdir -p build/certs + cd build/certs + + openssl genrsa -out ca.key 4096 + openssl req -x509 -new -nodes -key ca.key -sha256 -days 1825 \ + -out ca.crt -subj "/CN=Test CA" + + openssl genrsa -out server.key 2048 + openssl req -new -key server.key -out server.csr -subj "/CN=localhost" + echo "subjectAltName=DNS:localhost,IP:127.0.0.1" > server_ext.cnf + openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \ + -CAcreateserial -out server.crt -days 365 -sha256 \ + -extfile server_ext.cnf + + openssl genrsa -out client.key 2048 + openssl req -new -key client.key -out client.csr -subj "/CN=brazier-client" + echo "extendedKeyUsage=clientAuth" > client_ext.cnf + openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \ + -CAserial ca.srl -out client.crt -days 365 -sha256 \ + -extfile client_ext.cnf + + openssl verify -CAfile ca.crt server.crt + openssl verify -CAfile ca.crt client.crt + shell: bash + - name: Run tests working-directory: build run: ./brazier_tests --gtest_filter=-RoutingTest.* diff --git a/README.md b/README.md index 6526cf6..8bd561b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ The framework combines the power of Boost.Asio with modern C++20/23 features. - **JWT authentication** support - **Redis caching** - **WebSockets** technology support +- **HTTPS / TLS** support with SNI, ALPN-ready, mTLS - **High performance** with minimal overhead ## Requirements @@ -222,6 +223,19 @@ sh build.sh "port": 3501, "keep-alive-timeout": 60 }, + "https_server": { + "host": "0.0.0.0", + "port": 8443, + "tls": { + "cert_file": "app/certs/server.crt", + "key_file": "app/certs/server.key", + "ca_file": "", + "require_client_cert": false, + "verify_client_cert": false, + "handshake_timeout": 15, + "conf": [] + } + }, "database": { "host": "127.0.0.1", "port": 5432, @@ -472,6 +486,468 @@ boost::asio::awaitable createUser(const Request& req, Response& res, const } ``` +## Brazier HTTPS Server + +Brazier ships with a first-class HTTPS server built on **Boost.Beast + Boost.Asio + OpenSSL**. +It is API-compatible with the plain HTTP `Server` class — switching between them, or running +both side by side, is a couple of lines in your `main`. + +### Features + +- **TLS 1.2 / 1.3** by default, with per-server override via `conf` +- **Session resumption** via RFC 5077 tickets with automatic key rotation +- **Hot-reload** of certificates without restarting the server +- **Certificate from file OR from memory (PEM string)** +- **HSTS** header sent automatically on every response +- **Keep-alive** over TLS with configurable idle timeout +- **Graceful shutdown** with `close_notify` and a bounded drain timeout +- **Handshake timeout** to protect against Slowloris-style attacks +- **mTLS** (mutual TLS / client certificates) with custom CA +- **Multi-io_context** — N worker threads, one per CPU core +- **`SO_REUSEPORT`** on Linux/macOS, round-robin dispatch on Windows +- **`TCP_DEFER_ACCEPT`** on Linux — less wake-ups, more throughput +- **Dynamic limits** — body size, header size, and connection count + auto-tuned to the host hardware at startup +- **Raw OpenSSL tuning** through `SSL_CONF_cmd` — no code changes for + cipher / protocol tweaks +- **Same Router / Engine / Middleware** as the HTTP server — routing is transport-agnostic + +### Requirements + +- **OpenSSL** 3.x (installed via vcpkg — the `openssl` package) +- **Boost** 1.82+ (`boost::asio::cancel_after` is used internally) +- A **PEM-encoded** certificate chain and matching private key + +### Configuration + +Add an `https_server` section to your `config.json` alongside the existing `server`: + +```json +{ + "server": { + "host": "0.0.0.0", + "port": 3501, + "keep-alive-timeout": 60 + }, + "https_server": { + "host": "0.0.0.0", + "port": 8443, + "tls": { + "cert_file": "app/certs/server.crt", + "key_file": "app/certs/server.key", + "ca_file": "", + "require_client_cert": false, + "verify_client_cert": false, + "handshake_timeout": 15, + "conf": [] + } + }, + "http": { + "keep_alive_timeout": 60, + "max_connections": 50000, + "max_body_size": 1048576, + "max_header_size": 8192 + } +} +``` + +#### TLS section reference + +| Key | Type | Default | Description | +|------------------------|-----------|---------------|-------------| +| `cert_file` | `string` | `server.crt` | Path to PEM certificate chain (leaf + intermediates) | +| `key_file` | `string` | `server.key` | Path to PEM private key | +| `cert_pem` | `string` | `""` | Certificate as an in-memory PEM string (overrides `cert_file`) | +| `key_pem` | `string` | `""` | Private key as an in-memory PEM string (overrides `key_file`) | +| `ca_file` | `string` | `""` | Path to a CA bundle — required for mTLS | +| `require_client_cert` | `bool` | `false` | Reject connections without a client certificate | +| `verify_client_cert` | `bool` | `false` | Verify client certificate if presented (but don't require) | +| `handshake_timeout` | `int` | `15` | Seconds to wait for TLS handshake before closing | +| `conf` | `array` | `[]` | Raw `SSL_CONF_cmd` commands — see below | + +> **Paths are resolved relative to the process's current working +> directory**, not to the `config.json` file. Either run the binary from +> the project root, or use absolute paths. On Windows, always use forward +> slashes (`"C:/certs/server.crt"`) — backslashes are escape characters in JSON. + +#### `conf` array — raw OpenSSL commands + +The `conf` array passes commands directly to `SSL_CONF_cmd`. Each entry is +a two-element array `["command", "value"]`, or a one-element array for +commands without arguments. + +```json +"conf": [ + ["CipherString", "ECDHE+AESGCM:ECDHE+CHACHA20"], + ["Options", "-SessionTicket"], + ["MinProtocol", "TLSv1.3"] +] +``` + +These are passed verbatim to OpenSSL. See the OpenSSL documentation for +`SSL_CONF_cmd` for the full list. Brazier does not validate them — if +OpenSSL rejects a command, `initialize()` fails with a clear error. + +#### HTTP section reference + +Global HTTP limits shared by both `Server` and `HttpsServer`. + +| Key | Type | Default | Description | +|---------------------------|-------|---------------|-------------| +| `keep_alive_timeout` | `int` | `60` | Seconds to keep an idle connection open (legacy `keep-alive-timeout` at top level is also accepted) | +| `max_connections` | `int` | `ulimit × 0.8` | Max simultaneous connections; if unset, derived from `RLIMIT_NOFILE` | +| `max_body_size` | `int` | auto | Max request body in bytes; auto-derived from RAM and `max_connections` | +| `max_header_size` | `int` | auto | Max total request header size; auto-derived from RAM and `max_connections` | +| `max_connections_testing` | `int` | `0` | Test-only override for `max_connections` | +| `max_body_size_testing` | `int` | `0` | Test-only override for `max_body_size` | +| `max_header_size_testing` | `int` | `0` | Test-only override for `max_header_size` | + +**Auto-derivation rules:** + +- `max_connections` = `RLIMIT_NOFILE × 0.8` (POSIX) or `16384 × 0.8` (Windows) +- `max_body_size` = `(RAM × 25%) / max_connections`, clamped to `[64 KB, 16 MB]` +- `max_header_size` = `(RAM × 1%) / max_connections`, clamped to `[4 KB, 32 KB]` + +Any explicit value in the config wins over auto-derivation. The `_testing` +keys take priority over everything else and are intended for the test suite. + +### Using the HTTPS server + +```cpp +#include "../include/brazier/Core" +#include "../include/brazier/DB" +#include "../include/brazier/Http" +#include "../include/brazier/Engine.hpp" + +int main() { + try { + brazier::ConfigManager::initGlobal("config.json"); + brazier::global_config->setAutoSave(false); + + std::string host = brazier::global_config->get( + "https_server.host", "0.0.0.0"); + int port = brazier::global_config->get( + "https_server.port", 8443); + + brazier::HttpsServer server(host, port); + + if (!server.initialize()) return 1; + server.run(); // blocks until stop() + + return 0; + } + catch (const std::exception& e) { + std::cerr << "Fatal error: " << e.what() << std::endl; + return 1; + } +} +``` + +`HttpsServer` reads everything it needs from `https_server.*` in +`global_config`: host, port, TLS settings, the `conf` array, handshake +timeout, and mTLS flags. You don't pass them explicitly — just make sure +`ConfigManager::initGlobal()` runs first. + +### Overriding TLS programmatically + +When the certificate path is only known at runtime, or you're running +multiple `HttpsServer` instances with different certificates, pass a +`TlsConfig` directly: + +```cpp +brazier::HttpsServer::TlsConfig tls; +tls.cert_file = "/var/lib/myapp/api.crt"; +tls.key_file = "/var/lib/myapp/api.key"; +tls.conf = { + { "min_protocol", "TLSv1.3" } +}; +tls.handshake_timeout = std::chrono::seconds(10); + +brazier::HttpsServer api("0.0.0.0", 9443, tls); +api.initialize(); +api.run(); +``` + +When `TlsConfig` is passed explicitly, `https_server.tls.*` from the JSON +is ignored entirely — the code-level config wins. + +### Loading certificates from memory (PEM strings) + +If the certificate comes from a secret manager, a mounted Kubernetes secret, +or any source other than a plain file path, put the PEM content directly +into `config.json` using `cert_pem` / `key_pem` instead of `cert_file` / +`key_file`: + +```json +"https_server": { + "tls": { + "cert_pem": "-----BEGIN CERTIFICATE-----\nMIIF...\n-----END CERTIFICATE-----\n", + "key_pem": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n" + } +} +``` + +Note the `\n` escapes — PEM is multi-line, and JSON strings must escape +newlines. The `nlohmann::json` parser converts them to real newlines before +OpenSSL sees them. + +`cert_pem` may contain the full chain (leaf + intermediates) — Brazier parses +and registers each certificate automatically. `key_pem` must be the matching +private key; the pair is validated with `SSL_CTX_check_private_key` at load +time. If the key does not match the certificate, `initialize()` fails with +`"Certificate/private key mismatch"`. + +`cert_pem` takes priority over `cert_file`. If both are set, the in-memory +copy is used. This lets you supply a file path for hot-reload and a cached +PEM for the initial load — but note that `reloadTls()` (no args) needs +`cert_file` / `key_file` to know where to re-read from. + +For programmatic sources (Vault API, custom secret fetch, database), skip +the config entirely and pass `TlsConfig` directly: + +```cpp +brazier::HttpsServer::TlsConfig tls; +tls.cert_pem = fetchPemFromVault("tls/server.crt"); +tls.key_pem = fetchPemFromVault("tls/server.key"); + +brazier::HttpsServer server("0.0.0.0", 8443, tls); +server.initialize(); +``` + +When `TlsConfig` is passed explicitly, `https_server.tls.*` from `config.json` +is ignored entirely — the code-level config wins. + +### Hot-reload of TLS certificates + +Reload certificates without restarting the server or dropping existing +connections: + +```cpp +// Re-read from files (cert_file / key_file must be set) +server.reloadTls(); + +// Or supply a new config explicitly — useful for Vault / K8s / DB sources +brazier::HttpsServer::TlsConfig new_tls = server.getTlsConfig(); +new_tls.cert_pem = fetchPemFromVault("tls/server.crt"); +new_tls.key_pem = fetchPemFromVault("tls/server.key"); +server.reloadTls(new_tls); +``` + +**Guarantees:** + +- Existing connections continue on the old `SSL_CTX` and finish normally. +- New handshakes use the new `SSL_CTX`. +- If the new config is invalid, the operation fails and the old `SSL_CTX` + stays active — the server is never left in a broken state. +- Session ticket keys are shared across contexts, so resumption keeps + working across reloads. + +The reload is implemented with `std::shared_ptr` plus a +`std::shared_mutex`. The cost per connection is one atomic refcount and one +shared-lock acquire — invoked once per connection, not per request. + +**Common trigger patterns:** + +```cpp +// 1. Console command +std::thread([&server] { + std::string line; + while (std::getline(std::cin, line)) { + if (line == "reload") server.reloadTls(); + if (line == "quit") server.stop(); + } +}).detach(); + +// 2. SIGHUP (Linux / macOS) +std::signal(SIGHUP, [](int) { + g_reload_requested.store(true, std::memory_order_release); +}); +// ... in a background thread ... +if (g_reload_requested.exchange(false)) server.reloadTls(); + +// 3. File watcher +std::thread([&server] { + fs::file_time_type last{}; + while (server.running()) { + auto now = fs::last_write_time("app/certs/server.crt"); + if (last != fs::file_time_type{} && now != last) { + server.reloadTls(); + } + last = now; + std::this_thread::sleep_for(std::chrono::seconds(5)); + } +}).detach(); +``` + +### Session resumption + +Brazier uses **stateless session tickets** (RFC 5077) — no per-session state +is kept on the server. This is the only resumption mechanism in TLS 1.3, +and it is the recommended mechanism for TLS 1.2 as well. + +Tickets are encrypted with rotating keys: + +- **Rotation interval** — a new key every 12 hours. +- **Key lifetime** — old keys are kept for 48 hours, so clients with valid + tickets can still resume. +- **Per-server isolation** — each `HttpsServer` has its own key store. + Multiple servers in the same process do not share tickets. + +Resumption typically costs **0.3–0.8 ms** versus **2–3 ms** for a full +handshake — roughly a 10× CPU reduction on resumed sessions. This matters +most for connection-churn workloads. + +### Threading model + +On startup, `HttpsServer` creates **N io_context instances, one per CPU core**: + +| Platform | io_contexts | Acceptors | Dispatch | +|---------------|------------------------|--------------------|----------| +| Linux / macOS | `hardware_concurrency()` | same as io_contexts | `SO_REUSEPORT` — kernel hashes 4-tuples | +| Windows | `hardware_concurrency()` | 1 | round-robin `co_spawn` from a single acceptor | + +Each io_context owns its own thread, and each thread owns its own IOCP +(Windows) or epoll instance (Linux). There is no cross-thread signalling on +the hot path — completions for a connection always run on the thread that +owns its io_context. + +The startup log tells you exactly what happened: + +``` +[SUCCESS] HTTPS server initialized on 0.0.0.0:8443 [TLS, io_contexts=12, acceptors=1, dispatch=round-robin, SO_REUSEPORT=no, TCP_DEFER_ACCEPT=no] +[INFO] Starting 12 io_context(s) over 12 thread(s), dispatch=round-robin +``` + +### Mutual TLS (mTLS) + +mTLS requires each client to present a certificate signed by a CA you trust. +This is common for service-to-service communication, IoT devices, and +internal APIs. + +Enable it in the config: + +```json +"tls": { + "cert_file": "app/certs/server.crt", + "key_file": "app/certs/server.key", + "ca_file": "app/certs/ca.crt", + "require_client_cert": true +} +``` + +With `require_client_cert: true`, the server sends a `CertificateRequest` +during the handshake. Clients that cannot present a valid certificate are +rejected **before** any HTTP request is processed — the TLS handshake +simply fails. + +Set `verify_client_cert: true` (without `require_client_cert`) to ask for a +client certificate but accept clients that don't present one. Useful when +client certs are optional. + +> **`ca_file` is required when `require_client_cert: true`.** Without a CA +> bundle the server has no way to validate client certificates. + +### Response headers set automatically + +For every response, `HttpsServer` sets: + +| Header | Value | Why | +|----------------------------|--------------------------------|-----| +| `Server` | `brazier` (configurable) | Identifies the framework | +| `Strict-Transport-Security`| `max-age=31536000` (configurable) | HSTS — tells browsers to use HTTPS for a year | +| `Connection` | `keep-alive` or `close` | Matches the request | + +Configure via `http.server_name`, `http.hsts_enabled`, and `http.hsts_header`. +You don't need to set these in your controllers. + +### Lifecycle and timeouts + +| Phase | Timeout | Config key | +|--------------------------|---------|------------| +| TLS handshake | 15 s | `https_server.tls.handshake_timeout` | +| Idle keep-alive | 60 s | `http.keep_alive_timeout` (or legacy `keep-alive-timeout`) | +| Graceful TLS shutdown | 5 s | — | +| Graceful server shutdown | 10 s | — | + +If any of these fire, the connection is closed cleanly — you'll see a +corresponding `[DEBUG] HTTPS client disconnected` line in the log, not an +error. + +### Shutting down + +```cpp +server.stop(); // returns after all in-flight connections complete (up to 10 s) +``` + +`stop()` performs a graceful shutdown: + +1. Acceptors are closed — no new connections. +2. The work guard is released — `io_context::run()` exits when idle. +3. Waits up to **10 seconds** for the active connection count to reach zero. +4. `io_context::stop()` — force-cancels anything still running. +5. Worker threads are joined. + +If `run()` is executing on a different thread, join that thread **after** +`stop()` returns. Do **not** call `stop()` from inside a request handler — +it will deadlock waiting for the calling connection to close. + +### Verifying a running server + +```bash +// Basic request (accepts self-signed certificates) +curl -vk https://localhost:8443/ + +// Strict verification against your own CA +curl -v --cacert app/certs/server.crt https://localhost:8443/ + +// Inspect the handshake +openssl s_client -connect localhost:8443 -servername localhost + +// Verify that TLS 1.1 is rejected +openssl s_client -connect localhost:8443 -tls1_1 +``` + +> **Windows `curl.exe` uses Schannel, which does not support ECDSA server +> certificates.** If your cert is ECDSA P-256, use curl from `Git for Windows` +> (which links against OpenSSL), or stick with `openssl s_client`. `ab` +> (ApacheBench) uses its own OpenSSL and works with both cert types. + +### Common pitfalls + +- **`use_certificate_chain_file: cannot find the file`** — the path in + `cert_file` is relative to the process's *current working directory*, not + the config file. Run from the project root or use absolute paths. + +- **`Certificate/private key mismatch`** — the cert and key in `cert_file` / + `key_file` don't belong to the same pair. Brazier rejects this at startup + with a clear error instead of failing later at handshake time. + +- **`SSL_CONF_cmd failed for 'min_protocol=...'`** — some OpenSSL builds + (notably via vcpkg) don't register `min_protocol` in `SSL_CONF_cmd`. + Use TLS options in code instead, or check the OpenSSL documentation for + the correct command name in your build. + +- **`no shared cipher` / `alert 40` on handshake** — the client and server + could not agree on a cipher suite. The usual cause is a mismatch between + the certificate key type and the client's cipher list: an ECDSA certificate + cannot satisfy an RSA-only client, and vice versa. + +- **Browser says "certificate not trusted"** — expected for self-signed + certificates. Either click through the warning, add the cert to the user + root store, or use a certificate from a public CA. + +- **`WSAECONNRESET` / `WSAECONNABORTED` in logs** — these are normal client + disconnect events, not errors. Brazier logs them at `DEBUG` level. + +- **`TLS shutdown error` on every connection** — this is + `APPLICATION_DATA_AFTER_CLOSE_NOTIFY`, a benign artefact of clients that + send data after the shutdown alert. Logged at `DEBUG` and safe to ignore. + +- **Hot-reload says `reloadTls: source is PEM, nothing to reload`** — you + supplied the certificate only as `cert_pem` / `key_pem`, with no + `cert_file` / `key_file`. Set the file paths as well, or call + `reloadTls(new_tls)` with fresh PEM strings from your secret source. + ## Brazier WebSocket Routing System Brazier provides a complete WebSocket routing system with support for parameterized paths, multiple message types, and global handlers. The system integrates seamlessly with the existing HTTP router. @@ -1816,4 +2292,4 @@ private: )"; } }; -``` +``` \ No newline at end of file diff --git a/brazier/.gitignore b/brazier/.gitignore index bbcef60..12e6d29 100644 --- a/brazier/.gitignore +++ b/brazier/.gitignore @@ -6,4 +6,7 @@ /.vscode /cmake-build-debug .clangd -config.json \ No newline at end of file +config.json +certs/ +stress_logs/ +*.pem \ No newline at end of file diff --git a/brazier/CMakeLists.txt b/brazier/CMakeLists.txt index e2126e3..f2f293d 100644 --- a/brazier/CMakeLists.txt +++ b/brazier/CMakeLists.txt @@ -21,6 +21,9 @@ endif() if(MSVC) add_compile_options(/utf-8 /bigobj) + + set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} /Zi") + set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF") endif() find_package(Boost CONFIG REQUIRED COMPONENTS beast asio system thread filesystem) @@ -46,16 +49,47 @@ set(LIBRARIES OpenSSL::Crypto ) -file(GLOB_RECURSE PROJECT_SOURCES - "src/*.cpp" - "src/Database/*.cpp" - "src/Database/Migrations/*.cpp" - "src/Router/*.cpp" - "src/Filesystem/*.cpp" - "src/Cryptography/*.cpp" -) +if(WIN32) + set(BRAZIER_PLATFORM_DIR "Windows") +elseif(APPLE) + set(BRAZIER_PLATFORM_DIR "MacOS") +else() + set(BRAZIER_PLATFORM_DIR "Linux") +endif() + +message(STATUS "brazier: platform = ${BRAZIER_PLATFORM_DIR}") + +file(GLOB_RECURSE PROJECT_SOURCES CONFIGURE_DEPENDS "src/*.cpp") + +list(FILTER PROJECT_SOURCES EXCLUDE REGEX "^src/Platform/") + +file(GLOB_RECURSE BRAZIER_PLATFORM_SOURCES CONFIGURE_DEPENDS + "src/Platform/${BRAZIER_PLATFORM_DIR}/*.cpp") + +if(NOT BRAZIER_PLATFORM_SOURCES) + message(FATAL_ERROR + "No platform sources found in src/Platform/${BRAZIER_PLATFORM_DIR}/") +endif() + +list(APPEND PROJECT_SOURCES ${BRAZIER_PLATFORM_SOURCES}) + list(FILTER PROJECT_SOURCES EXCLUDE REGEX "app/Main\\.cpp$") +function(brazier_hide_platform platform) + if(platform STREQUAL BRAZIER_PLATFORM_DIR) + return() + endif() + file(GLOB_RECURSE OTHER "src/Platform/${platform}/*.cpp") + if(OTHER) + set_source_files_properties(${OTHER} + PROPERTIES HEADER_FILE_ONLY TRUE) + endif() +endfunction() + +brazier_hide_platform(Linux) +brazier_hide_platform(MacOS) +brazier_hide_platform(Windows) + add_library(brazier_objects OBJECT ${PROJECT_SOURCES}) target_link_libraries(brazier_objects PUBLIC Boost::boost) @@ -134,13 +168,13 @@ target_compile_definitions(brazier_app PRIVATE BOOST_ASIO_HAS_STD_COROUTINE ) -target_link_libraries(brazier_app PRIVATE +target_link_libraries(brazier_app PRIVATE brazier_static ${LIBRARIES} ) add_library(brazier INTERFACE) -target_link_libraries(brazier INTERFACE +target_link_libraries(brazier INTERFACE brazier_static brazier_shared ) @@ -166,7 +200,7 @@ install(DIRECTORY include/ DESTINATION include) install(EXPORT brazierTargets NAMESPACE brazier:: DESTINATION share/brazier - FILE brazierTargets.cmake + FILE brazierTargets.cmake ) install( @@ -178,12 +212,14 @@ option(BUILD_TESTS "Build tests" ON) if(BUILD_TESTS) message(STATUS "Building tests") - + find_package(GTest CONFIG REQUIRED) get_target_property(GTEST_INCLUDE_DIRS GTest::gtest INTERFACE_INCLUDE_DIRECTORIES) - file(GLOB_RECURSE TEST_SOURCES "tests/*.cpp") + file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS + "tests/*.cpp" + ) add_executable(brazier_tests ${TEST_SOURCES}) @@ -199,12 +235,12 @@ if(BUILD_TESTS) ${OPENSSL_INCLUDE_DIR} ${GTEST_INCLUDE_DIRS} ) - + target_compile_definitions(brazier_tests PRIVATE BOOST_ASIO_HAS_CO_AWAIT BOOST_ASIO_HAS_STD_COROUTINE ) - + target_compile_features(brazier_tests PRIVATE cxx_std_20) target_link_libraries(brazier_tests PRIVATE @@ -216,16 +252,11 @@ if(BUILD_TESTS) ${LIBRARIES} ) - # include(GoogleTest) - # gtest_discover_tests(brazier_tests) - - # add_test(NAME brazier_tests COMMAND brazier_tests) - add_custom_target(check COMMAND $ DEPENDS brazier_tests COMMENT "Running tests..." ) - + message(STATUS "Tests configured. Run 'cmake --build . --target check' to run tests") endif() \ No newline at end of file diff --git a/brazier/app/BenchmarkController.hpp b/brazier/app/BenchmarkController.hpp new file mode 100644 index 0000000..724b5b1 --- /dev/null +++ b/brazier/app/BenchmarkController.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include "../include/brazier/Core" + +class BenchmarkController : public brazier::Controller { +public: + using Request = boost::beast::http::request; + using Response = boost::beast::http::response; + + boost::asio::awaitable test(const Request& req, + Response& res, + const brazier::Params& params) { + res.result(boost::beast::http::status::ok); + res.set(boost::beast::http::field::content_type, "text/plain"); + res.body() = ""; + co_return; + } +}; \ No newline at end of file diff --git a/brazier/app/Main.cpp b/brazier/app/Main.cpp index 169ec35..89da2c1 100644 --- a/brazier/app/Main.cpp +++ b/brazier/app/Main.cpp @@ -22,21 +22,65 @@ #include "../include/brazier/DB" #include "../include/brazier/Http" #include "../include/brazier/Engine.hpp" +#include "BenchmarkController.hpp" + +using namespace brazier; int main() { try { + auto benchmark_controller = std::make_shared(); + + R(GET, "/test", benchmark_controller, test); + brazier::ConfigManager::initGlobal("config_test.json"); - brazier::global_config->setAutoSave(false); + brazier::global_config->setAutoSave(false); + std::string https_server_host = + brazier::global_config->get("https_server.host", "0.0.0.0"); + int https_server_port = + brazier::global_config->get("https_server.port", 8443); + brazier::Logger::log("HTTPS server: " + https_server_host + ":" + + std::to_string(https_server_port), "INFO"); + + brazier::HttpsServer::TlsConfig tls; + tls.cert_file = "app/certs/server.crt"; + tls.key_file = "app/certs/server.key"; + brazier::HttpsServer https_server(https_server_host, https_server_port, tls); + + if (!https_server.initialize()) return 1; + std::thread([&https_server] { + std::string line; + while (std::getline(std::cin, line)) { + if (line == "reload") { + https_server.reloadTls(); + } + else if (line == "quit") { + https_server.stop(); + break; + } + } + }).detach(); + https_server.run(); + + return 0; + //brazier::ConfigManager::initGlobal("config_test.json"); + //brazier::global_config->setAutoSave(false); + + //std::string server_host = + // brazier::global_config->get("server.host", "0.0.0.0"); + //int server_port = + // brazier::global_config->get("server.port", 3502); + + //brazier::Logger::log("HTTP server: " + server_host + ":" + + // std::to_string(server_port), "INFO"); + + //brazier::Server server(server_host, + // static_cast(server_port)); - std::string server_host = brazier::global_config->get("server.host", "0.0.0.0"); - int server_port = brazier::global_config->get("server.port", 3502); + //if (!server.initialize()) return 1; - brazier::Logger::log("server updated host: " + server_host, "INFO"); - brazier::Logger::log("server new host: " + std::to_string(server_port), "INFO"); + //server.run(); - brazier::Server server(server_host, server_port); - server.run(); - return 0; + //return 0; } catch (const std::exception& e) { std::cerr << "Fatal error: " << e.what() << std::endl; diff --git a/brazier/config_test.json b/brazier/config_test.json index 0554a2c..9acf9a6 100644 --- a/brazier/config_test.json +++ b/brazier/config_test.json @@ -1,17 +1,37 @@ { + "app_name": "brazierApp", "server": { "host": "0.0.0.0", "port": 3502 }, - "app_name": "brazierApp", + "https_server": { + "host": "0.0.0.0", + "port": 8443, + "tls": { + "cert_file": "app/certs/server.crt", + "key_file": "app/certs/server.key", + "ca_file": "", + "require_client_cert": false, + "verify_client_cert": false, + "handshake_timeout": 3, + "conf": [] + }, + "hsts": { + "enabled": true, + "header": "max-age=31536000" + } + }, + "protocol": { + "keep_alive_timeout": 60, + + "max_connections_testing": 100, + "max_body_size_testing": 1048576, + "max_header_size_testing": 8192 + }, "filesystem": { "drivers": { - "local": { - "root": "./storage" - }, - "root": { - "root": "./" - }, + "local": { "root": "./storage" }, + "root": { "root": "./" }, "default": "local" } } diff --git a/brazier/include/brazier/App/Http/Helpers/HttpClient.hpp b/brazier/include/brazier/App/Http/Helpers/HttpClient.hpp index 5b0b9f1..1a609e6 100644 --- a/brazier/include/brazier/App/Http/Helpers/HttpClient.hpp +++ b/brazier/include/brazier/App/Http/Helpers/HttpClient.hpp @@ -54,8 +54,14 @@ namespace brazier { using json = nlohmann::json; HttpClient(); + HttpClient(const HttpClient&) = delete; + HttpClient& operator=(const HttpClient&) = delete; + HttpClient(HttpClient&&) = default; + HttpClient& operator=(HttpClient&&) = default; + ~HttpClient() = default; + net::awaitable get(const std::string& url, const json& body = json{}); net::awaitable post(const std::string& url, const json& body); net::awaitable put(const std::string& url, const json& body); diff --git a/brazier/include/brazier/Core b/brazier/include/brazier/Core index dca66ff..060e226 100644 --- a/brazier/include/brazier/Core +++ b/brazier/include/brazier/Core @@ -7,6 +7,7 @@ #include "Router/Router.hpp" #include "Router/RouterRegisterer.hpp" #include "server.hpp" +#include "HttpsServer.hpp" #include "Engine.hpp" #include "WebSocketServer.hpp" #include "vendor/ConfigManager.hpp" \ No newline at end of file diff --git a/brazier/include/brazier/Engine.hpp b/brazier/include/brazier/Engine.hpp index f126a2d..73969fe 100644 --- a/brazier/include/brazier/Engine.hpp +++ b/brazier/include/brazier/Engine.hpp @@ -17,7 +17,6 @@ * You should have received a copy of the GNU Lesser General Public License * along with brazier; if not, see . */ - #pragma once #include @@ -28,12 +27,10 @@ namespace brazier { class Engine { public: static boost::asio::io_context& get_io_context(); - static inline void init(boost::asio::io_context& io_ctx) { - Engine::io_ctx_ptr_ = &io_ctx; - } + static void init(boost::asio::io_context& io_ctx); private: - static inline boost::asio::io_context* io_ctx_ptr_ = nullptr; + static boost::asio::io_context*& io_ctx_ptr(); }; inline std::shared_ptr global_config = nullptr; diff --git a/brazier/include/brazier/HttpsServer.hpp b/brazier/include/brazier/HttpsServer.hpp new file mode 100644 index 0000000..32f7912 --- /dev/null +++ b/brazier/include/brazier/HttpsServer.hpp @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2026 Kirill Sergeev, Nikolay Sugonyako, Andrey Agarkov, Gleb Safyannikov + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * This file is part of brazier. + * + * brazier is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * brazier is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with brazier; if not, see . + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Platform/SocketOptions.hpp" +#include "Platform/SystemInfo.hpp" + +#include "TLS/TicketKeyStore.hpp" +#include "Database/Queue.hpp" +#include "Database/Cache.hpp" +#include "Database/Migrations/MigrationManager.hpp" +#include "Router/RouterRegisterer.hpp" +#include "Router/Router.hpp" +#include "Engine.hpp" +#include "Filesystem/Filesystem.hpp" +#include "vendor/ConfigManager.hpp" + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +namespace ssl = boost::asio::ssl; +using tcp = net::ip::tcp; +using namespace std::chrono_literals; + +namespace brazier { + + class HttpsServer { + public: + struct TlsConfig { + std::string cert_file; + std::string key_file; + std::string ca_file; + std::string cert_pem; + std::string key_pem; + + std::vector> conf; + + bool require_client_cert = false; + bool verify_client_cert = false; + + std::chrono::seconds handshake_timeout{ 15 }; + }; + + private: + std::chrono::seconds keep_alive_timeout_{ 60 }; + + int max_body_size_ = 1024 * 1024; + int max_header_size_ = 8 * 1024; + int max_connections_ = 10000; + + std::vector> io_contexts_; + std::vector> acceptors_; + std::vector>> work_guards_; + + std::shared_ptr ssl_ctx_; + std::shared_mutex ssl_ctx_mutex_; + + std::thread stats_thread_; + std::atomic shutdown_flag_{ false }; + + std::mutex stats_mutex_; + std::condition_variable stats_cv_; + + std::atomic shutting_down_{ false }; + std::mutex shutdown_mutex_; + std::condition_variable shutdown_cv_; + + unsigned short port_; + std::string host_; + + std::vector threads_; + + std::atomic connection_count_{ 0 }; + std::atomic total_requests_{ 0 }; + + TicketKeyStore ticket_store_; + TlsConfig tls_; + bool tls_config_from_user_ = false; + + struct ConnectionGuard { + HttpsServer& srv; + explicit ConnectionGuard(HttpsServer& s) noexcept : srv(s) {} + ~ConnectionGuard() { srv.release_connection(); } + + ConnectionGuard(const ConnectionGuard&) = delete; + ConnectionGuard& operator=(const ConnectionGuard&) = delete; + }; + + std::string server_name_ = "brazier"; + std::string hsts_header_ = "max-age=31536000"; + bool hsts_enabled_ = true; + + std::string static_headers_; + + public: + HttpsServer(const std::string& host, unsigned short port); + HttpsServer(const std::string& host, unsigned short port, + const TlsConfig& tls); + + void setTlsConfig(const TlsConfig& tls); + + bool reloadTls(); + bool reloadTls(const TlsConfig& new_tls); + + bool initialize(); + + void run(); + void stop(); + + unsigned short getPort() const; + const std::string& getHost() const; + + int getMaxConnections() const { return max_connections_; } + int getMaxBodySize() const { return max_body_size_; } + int getMaxHeaderSize() const { return max_header_size_; } + + private: + void initializeConnections(); + + void load_common_config_from_global(); + void load_tls_config_from_global(); + void load_limits_from_config(); + + void configure_tls(); + void configure_ssl_ctx(ssl::context& ctx); + void apply_ssl_conf(ssl::context& ctx); + void load_cert_from_memory(ssl::context& ctx, + const std::string& cert_pem, + const std::string& key_pem); + + std::shared_ptr get_ssl_ctx(); + + void release_connection(); + + static int compute_max_body_size(int ram_mb, int max_conn); + static int compute_max_header_size(int ram_mb, int max_conn); + + net::awaitable handle_connection(tcp::socket socket); + net::awaitable accept_loop(tcp::acceptor& acceptor); + net::awaitable accept_and_dispatch(tcp::acceptor& acceptor, + int worker_begin); + }; + +} \ No newline at end of file diff --git a/brazier/include/brazier/Platform/NativeSocket.hpp b/brazier/include/brazier/Platform/NativeSocket.hpp new file mode 100644 index 0000000..33bb51d --- /dev/null +++ b/brazier/include/brazier/Platform/NativeSocket.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace brazier::platform { + + using NativeSocket = std::intptr_t; + +} \ No newline at end of file diff --git a/brazier/include/brazier/Platform/SocketOptions.hpp b/brazier/include/brazier/Platform/SocketOptions.hpp new file mode 100644 index 0000000..b2efe14 --- /dev/null +++ b/brazier/include/brazier/Platform/SocketOptions.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "NativeSocket.hpp" + +namespace brazier::platform { + + bool set_reuse_port(NativeSocket fd) noexcept; + bool set_defer_accept(NativeSocket fd, int seconds) noexcept; + + bool has_reuse_port() noexcept; + bool has_defer_accept() noexcept; + +} \ No newline at end of file diff --git a/brazier/include/brazier/Platform/SystemInfo.hpp b/brazier/include/brazier/Platform/SystemInfo.hpp new file mode 100644 index 0000000..2dc0db7 --- /dev/null +++ b/brazier/include/brazier/Platform/SystemInfo.hpp @@ -0,0 +1,17 @@ +#pragma once + +namespace brazier::platform { + + int get_fd_limit() noexcept; + + int get_system_memory_mb() noexcept; + + int get_worker_count() noexcept; + + int get_thread_count() noexcept; + + bool has_reuse_port() noexcept; + + int get_io_context_count() noexcept; + +} \ No newline at end of file diff --git a/brazier/include/brazier/TLS/TicketKeyStore.hpp b/brazier/include/brazier/TLS/TicketKeyStore.hpp new file mode 100644 index 0000000..7c655f7 --- /dev/null +++ b/brazier/include/brazier/TLS/TicketKeyStore.hpp @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +#if OPENSSL_VERSION_NUMBER < 0x30000000L +# include +#endif + +#include +#include +#include +#include +#include +#include + +namespace brazier { + + class TicketKeyStore { + public: + using Clock = std::chrono::steady_clock; + using TimePoint = Clock::time_point; + + static constexpr auto kRotationInterval = std::chrono::hours(12); + static constexpr auto kKeyLifetime = std::chrono::hours(48); + + static constexpr std::size_t kNameSize = 16; + static constexpr std::size_t kAesKeySize = 32; + static constexpr std::size_t kHmacKeySize = 32; + + struct Key { + unsigned char name[kNameSize]; + unsigned char aes_key[kAesKeySize]; + unsigned char hmac_key[kHmacKeySize]; + TimePoint created_at; + }; + + TicketKeyStore(); + explicit TicketKeyStore(std::function now_provider); + + TicketKeyStore(const TicketKeyStore&) = delete; + TicketKeyStore& operator=(const TicketKeyStore&) = delete; + + int handle(unsigned char key_name[kNameSize], + unsigned char iv[EVP_MAX_IV_LENGTH], + EVP_CIPHER_CTX* cipher_ctx, + void* mac_ctx, + int enc); + + void ensure_initialized(); + void rotate_now(); + + void attach_to(SSL_CTX* ctx); + + std::size_t key_count() const; + std::vector snapshot() const; + + private: + static Key generate_key(TimePoint now); + void maybe_rotate_locked(TimePoint now); + + mutable std::mutex mtx_; + std::deque keys_; + std::function now_provider_; + }; + + int ticket_store_ex_index(); + +} \ No newline at end of file diff --git a/brazier/src/Engine.cpp b/brazier/src/Engine.cpp index 4f72e23..6df223f 100644 --- a/brazier/src/Engine.cpp +++ b/brazier/src/Engine.cpp @@ -20,9 +20,19 @@ #include "../include/brazier/Engine.hpp" +boost::asio::io_context*& brazier::Engine::io_ctx_ptr() { + static thread_local boost::asio::io_context* ptr = nullptr; + return ptr; +} + +void brazier::Engine::init(boost::asio::io_context& io_ctx) { + io_ctx_ptr() = &io_ctx; +} + boost::asio::io_context& brazier::Engine::get_io_context() { - if (!brazier::Engine::io_ctx_ptr_) { - throw std::runtime_error("IO context not initialized"); + auto* ptr = io_ctx_ptr(); + if (!ptr) { + throw std::runtime_error("IO context not initialized for this thread"); } - return *brazier::Engine::io_ctx_ptr_; + return *ptr; } \ No newline at end of file diff --git a/brazier/src/HttpClient.cpp b/brazier/src/HttpClient.cpp index 15beb9d..7749842 100644 --- a/brazier/src/HttpClient.cpp +++ b/brazier/src/HttpClient.cpp @@ -24,6 +24,7 @@ namespace brazier { HttpClient::HttpClient() : ctx_(ssl::context::tlsv12_client) { + SSL_CTX_set_options(ctx_.native_handle(), SSL_OP_IGNORE_UNEXPECTED_EOF); ctx_.set_default_verify_paths(); ctx_.set_verify_mode(ssl::verify_peer); } @@ -198,13 +199,18 @@ namespace brazier { co_return res; } - net::awaitable HttpClient::send_https_request(const UrlParts& url_parts, http::verb method, const json& body) { + net::awaitable HttpClient::send_https_request(const UrlParts& url_parts, + http::verb method, + const json& body) { auto executor = co_await net::this_coro::executor; beast::ssl_stream stream(executor, ctx_); if (!SSL_set_tlsext_host_name(stream.native_handle(), url_parts.host.c_str())) { - boost::system::error_code ec{ static_cast(::ERR_get_error()), net::error::get_ssl_category() }; + boost::system::error_code ec{ + static_cast(::ERR_get_error()), + net::error::get_ssl_category() + }; throw boost::system::system_error(ec); } @@ -212,58 +218,50 @@ namespace brazier { auto const results = co_await resolver.async_resolve( url_parts.host, url_parts.port, - net::use_awaitable - ); + net::use_awaitable); if (results.empty()) { throw std::runtime_error("No DNS records found for " + url_parts.host); } - bool timed_out = false; - net::steady_timer timer(executor, timeout_); + { + boost::system::error_code connect_ec; + co_await beast::get_lowest_layer(stream).async_connect( + results, + net::cancel_after( + timeout_, + net::redirect_error(net::use_awaitable, connect_ec))); - timer.async_wait([&](boost::system::error_code ec) { - if (!ec) { - timed_out = true; - boost::system::error_code ignore; - beast::get_lowest_layer(stream).socket().close(ignore); - Logger::log("HttpClient: HTTPS connection timeout", "WARNING"); + if (connect_ec == net::error::timed_out) { + throw std::runtime_error("HTTPS connection timeout"); + } + if (connect_ec) { + throw std::runtime_error("Connection failed: " + connect_ec.message()); } - }); - - boost::system::error_code ec; - beast::get_lowest_layer(stream).connect(results, ec); - if (ec) { - throw std::runtime_error("Connection failed: " + ec.message()); } - timer.cancel(); - if (timed_out) { - throw std::runtime_error("HTTPS connection timeout"); - } + { + boost::system::error_code hs_ec; + co_await stream.async_handshake( + ssl::stream_base::client, + net::cancel_after( + timeout_, + net::redirect_error(net::use_awaitable, hs_ec))); - timed_out = false; - timer.expires_after(timeout_); - timer.async_wait([&](boost::system::error_code ec) { - if (!ec) { - timed_out = true; - boost::system::error_code ignore; - beast::get_lowest_layer(stream).socket().close(ignore); - Logger::log("HttpClient: SSL handshake timeout", "WARNING"); + if (hs_ec == net::error::timed_out) { + throw std::runtime_error("SSL handshake timeout"); + } + if (hs_ec) { + throw std::runtime_error("SSL handshake failed: " + hs_ec.message()); } - }); - - co_await stream.async_handshake(ssl::stream_base::client, net::use_awaitable); - timer.cancel(); - - if (timed_out) { - throw std::runtime_error("SSL handshake timeout"); } Request req{ method, url_parts.path, 11 }; setup_common_headers(req, url_parts.host); - if (method == http::verb::post || method == http::verb::put || method == http::verb::delete_) { + if (method == http::verb::post || + method == http::verb::put || + method == http::verb::delete_) { req.set(http::field::content_type, "application/json"); if (!body.empty()) { req.body() = body.dump(); @@ -278,46 +276,68 @@ namespace brazier { req.prepare_payload(); - timed_out = false; - timer.expires_after(timeout_); - timer.async_wait([&](boost::system::error_code ec) { - if (!ec) { - timed_out = true; - boost::system::error_code ignore; - beast::get_lowest_layer(stream).socket().close(ignore); - Logger::log("HttpClient: HTTPS write timeout", "WARNING"); + { + + { + boost::system::error_code write_ec; + co_await http::async_write( + stream, req, + net::cancel_after( + timeout_, + net::redirect_error(net::use_awaitable, write_ec))); + + if (write_ec == net::error::timed_out) { + throw std::runtime_error("HTTPS write timeout"); + } + if (write_ec) { + Logger::log("Client: write failed: " + write_ec.message(), "ERROR"); + throw boost::system::system_error(write_ec); + } } - }); - - co_await http::async_write(stream, req, net::use_awaitable); - timer.cancel(); - - if (timed_out) { - throw std::runtime_error("HTTPS write timeout"); } - timed_out = false; - timer.expires_after(timeout_); - timer.async_wait([&](boost::system::error_code ec) { - if (!ec) { - timed_out = true; - boost::system::error_code ignore; - beast::get_lowest_layer(stream).socket().close(ignore); - Logger::log("HttpClient: HTTPS read timeout", "WARNING"); - } - }); - beast::flat_buffer buffer; Response res; - co_await http::async_read(stream, buffer, res, net::use_awaitable); - timer.cancel(); - - if (timed_out) { - throw std::runtime_error("HTTPS read timeout"); + { + boost::system::error_code read_ec; + co_await http::async_read( + stream, buffer, res, + net::cancel_after( + timeout_, + net::redirect_error(net::use_awaitable, read_ec))); + + if (read_ec == net::error::timed_out) { + throw std::runtime_error("HTTPS read timeout"); + } + if (read_ec) { + const bool is_teardown_error = + read_ec == net::error::connection_aborted || + read_ec == net::error::connection_reset || + read_ec == ssl::error::stream_truncated || + read_ec == net::error::eof || + read_ec == net::error::broken_pipe; + const bool has_length_marker = + res.count(http::field::content_length) > 0 || + res.count(http::field::transfer_encoding) > 0; + + const auto payload = res.payload_size(); + const bool response_valid = + res.result_int() >= 100 && res.result_int() < 600 && + has_length_marker && + payload.has_value() && + res.body().size() == static_cast(*payload); + + if (!is_teardown_error || !response_valid) { + Logger::log("HTTPSC: read failed: " + read_ec.message() + + ", status=" + std::to_string(res.result_int()) + + ", body=" + std::to_string(res.body().size()) + + ", content_length=[" + std::string(res[http::field::content_length]) + "]", + "ERROR"); + throw boost::system::system_error(read_ec); + } + } } - stream.shutdown(ec); - co_return res; } @@ -325,7 +345,6 @@ namespace brazier { req.set(http::field::host, host); req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING); req.set(http::field::accept, "*/*"); - req.set(http::field::connection, "close"); } std::string HttpClient::json_to_query_string(const json& j) { diff --git a/brazier/src/HttpsServer.cpp b/brazier/src/HttpsServer.cpp new file mode 100644 index 0000000..da7ce55 --- /dev/null +++ b/brazier/src/HttpsServer.cpp @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2026 Kirill Sergeev, Nikolay Sugonyako, Andrey Agarkov, Gleb Safyannikov + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * This file is part of brazier. + * + * brazier is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * brazier is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with brazier; if not, see . + */ + +#include "../include/brazier/HttpsServer.hpp" + +brazier::HttpsServer::HttpsServer(const std::string& host, unsigned short port) + : port_(port), host_(host) {} + +brazier::HttpsServer::HttpsServer(const std::string& host, unsigned short port, + const TlsConfig& tls) + : port_(port), host_(host), tls_(tls), tls_config_from_user_(true) {} + +void brazier::HttpsServer::setTlsConfig(const TlsConfig& tls) { + tls_ = tls; + tls_config_from_user_ = true; +} + +bool brazier::HttpsServer::initialize() { + try { + Logger::init("debug.log"); + Logger::registerSignalHandlers(); + + json drivers = global_config->getJson("filesystem.drivers"); + + for (auto& [name, cfg] : drivers.items()) { + if (name == "default") continue; + + auto driver = std::make_shared(); + driver->setRootPath(cfg.value("root", "./")); + driver->initAsync(); + + StorageManager::getInstance().registerDriver(name, driver); + } + + std::string def = global_config->getNested( + "filesystem.default", "local"); + if (StorageManager::getInstance().hasDriver(def)) { + StorageManager::getInstance().setDefaultDriver(def); + } + + load_common_config_from_global(); + load_tls_config_from_global(); + load_limits_from_config(); + + ssl_ctx_ = std::make_shared(ssl::context::tls_server); + configure_tls(); + + const tcp::endpoint endpoint(net::ip::make_address(host_), port_); + + const int n = platform::get_thread_count(); + const bool split_accept = !platform::has_reuse_port(); + const int io_count = n; + + io_contexts_.reserve(io_count); + work_guards_.reserve(io_count); + acceptors_.reserve(split_accept ? 1 : io_count); + + for (int i = 0; i < io_count; ++i) { + auto io = std::make_unique(); + + const bool needs_acceptor = !split_accept || (i == 0); + + if (needs_acceptor) { + auto acc = std::make_unique(*io); + acc->open(endpoint.protocol()); + acc->set_option(tcp::acceptor::reuse_address(true)); + + const auto native = static_cast( + acc->native_handle()); + + if (!platform::set_reuse_port(native) + && platform::has_reuse_port()) { + Logger::log("SO_REUSEPORT setsockopt failed on worker " + + std::to_string(i), "WARNING"); + } + + if (!platform::set_defer_accept(native, 1) + && platform::has_defer_accept()) { + Logger::log("TCP_DEFER_ACCEPT setsockopt failed on worker " + + std::to_string(i), "WARNING"); + } + + acc->bind(endpoint); + acc->listen(boost::asio::socket_base::max_listen_connections); + acceptors_.push_back(std::move(acc)); + } + + work_guards_.push_back(std::make_unique< + net::executor_work_guard>( + io->get_executor())); + + io_contexts_.push_back(std::move(io)); + } + + initializeConnections(); + RouterRegisterer::init(*io_contexts_[0]); + + Logger::log("HTTPS server initialized on " + host_ + ":" + + std::to_string(port_) + " [TLS, io_contexts=" + + std::to_string(io_count) + ", acceptors=" + + std::to_string(acceptors_.size()) + ", dispatch=" + + (split_accept ? "round-robin" : "SO_REUSEPORT") + + ", SO_REUSEPORT=" + + (platform::has_reuse_port() ? "yes" : "no") + + ", TCP_DEFER_ACCEPT=" + + (platform::has_defer_accept() ? "yes" : "no") + + "]", "SUCCESS"); + return true; + } + catch (const std::exception& e) { + Logger::log("HTTPS initialization failed: " + std::string(e.what()), + "ERROR"); + return false; + } +} + +void brazier::HttpsServer::run() { + try { + const int io_count = static_cast(io_contexts_.size()); + const bool split_accept = !platform::has_reuse_port(); + + if (split_accept) { + net::co_spawn(*io_contexts_[0], + accept_and_dispatch(*acceptors_[0], 0), + net::detached); + } + else { + for (int i = 0; i < io_count; ++i) { + net::co_spawn(*io_contexts_[i], + accept_loop(*acceptors_[i]), + net::detached); + } + } + + Logger::log("Starting " + std::to_string(io_count) + + " io_context(s) over " + std::to_string(io_count) + + " thread(s), dispatch=" + + (split_accept ? "round-robin" : "SO_REUSEPORT"), + "INFO"); + + for (auto& io : io_contexts_) { + auto* io_ptr = io.get(); + threads_.emplace_back([io_ptr] { + brazier::Engine::init(*io_ptr); + io_ptr->run(); + }); + } + + shutdown_flag_.store(false, std::memory_order_release); + + stats_thread_ = std::thread([this] { + std::unique_lock lock(stats_mutex_); + while (!shutdown_flag_.load(std::memory_order_acquire)) { + const bool woke = stats_cv_.wait_for( + lock, + std::chrono::seconds(10), + [this] { + return shutdown_flag_.load(std::memory_order_acquire); + }); + + if (woke) break; + + lock.unlock(); + Logger::log("HTTPS STATS - Active connections: " + + std::to_string(connection_count_.load()) + + ", Total requests: " + + std::to_string(total_requests_.load()), + "INFO"); + lock.lock(); + } + }); + + for (auto& t : threads_) { + if (t.joinable()) t.join(); + } + + if (stats_thread_.joinable()) stats_thread_.join(); + + Logger::log("HTTPS server stopped", "INFO"); + } + catch (const std::exception& e) { + Logger::log("HTTPS server run failed: " + std::string(e.what()), "ERROR"); + throw; + } +} + +void brazier::HttpsServer::stop() { + Logger::log("HTTPS server stopping (graceful)...", "INFO"); + + shutdown_flag_.store(true, std::memory_order_release); + shutting_down_.store(true, std::memory_order_release); + + { + std::lock_guard lock(stats_mutex_); + stats_cv_.notify_all(); + } + + for (auto& acc : acceptors_) { + boost::system::error_code ignore; + acc->close(ignore); + } + + for (auto& wg : work_guards_) wg->reset(); + + { + std::unique_lock lock(shutdown_mutex_); + const bool drained = shutdown_cv_.wait_for( + lock, + std::chrono::seconds(10), + [this] { + return connection_count_.load(std::memory_order_acquire) == 0; + }); + + if (!drained) { + Logger::log("Graceful shutdown timeout: " + + std::to_string(connection_count_.load()) + + " connections still active, forcing stop", "WARNING"); + } + } + + for (auto& io : io_contexts_) io->stop(); + + Logger::log("HTTPS server stop() signaled", "INFO"); +} + +unsigned short brazier::HttpsServer::getPort() const { return port_; } +const std::string& brazier::HttpsServer::getHost() const { return host_; } + +void brazier::HttpsServer::release_connection() { + if (connection_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { + std::lock_guard lock(shutdown_mutex_); + shutdown_cv_.notify_all(); + } +} \ No newline at end of file diff --git a/brazier/src/HttpsServerConfig.cpp b/brazier/src/HttpsServerConfig.cpp new file mode 100644 index 0000000..b2d7bff --- /dev/null +++ b/brazier/src/HttpsServerConfig.cpp @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2026 Kirill Sergeev, Nikolay Sugonyako, Andrey Agarkov, Gleb Safyannikov + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * This file is part of brazier. + * + * brazier is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * brazier is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with brazier; if not, see . + */ + +#include "../include/brazier/HttpsServer.hpp" + +int brazier::HttpsServer::compute_max_body_size(int ram_mb, int max_conn) { + constexpr int kBodyRamBudgetPct = 25; + constexpr int kMinBody = 64 * 1024; + constexpr int kMaxBodyCap = 16 * 1024 * 1024; + + if (max_conn <= 0) max_conn = 1; + + const std::int64_t ram_bytes = (std::int64_t)(ram_mb) * 1024 * 1024; + const std::int64_t budget = ram_bytes * kBodyRamBudgetPct / 100; + std::int64_t per_conn = budget / (std::int64_t)(max_conn); + + if (per_conn < kMinBody) per_conn = kMinBody; + if (per_conn > kMaxBodyCap) per_conn = kMaxBodyCap; + return static_cast(per_conn); +} + +int brazier::HttpsServer::compute_max_header_size(int ram_mb, int max_conn) { + constexpr int kHeaderRamBudgetPct = 1; + constexpr int kMinHeader = 4 * 1024; + constexpr int kMaxHeaderCap = 32 * 1024; + + if (max_conn <= 0) max_conn = 1; + + const std::int64_t ram_bytes = (std::int64_t)(ram_mb) * 1024 * 1024; + const std::int64_t budget = ram_bytes * kHeaderRamBudgetPct / 100; + std::int64_t per_conn = budget / (std::int64_t)(max_conn); + + if (per_conn < kMinHeader) per_conn = kMinHeader; + if (per_conn > kMaxHeaderCap) per_conn = kMaxHeaderCap; + return static_cast(per_conn); +} + +void brazier::HttpsServer::load_common_config_from_global() { + keep_alive_timeout_ = std::chrono::seconds( + global_config->get("protocol.keep_alive_timeout", 60)); + + server_name_ = global_config->get("protocol.server_name", + std::string("brazier")); + + hsts_enabled_ = global_config->get("https_server.hsts.enabled", true); + hsts_header_ = global_config->get("https_server.hsts.header", + std::string("max-age=31536000")); + + static_headers_.clear(); + static_headers_ += "Server: " + server_name_ + "\r\n"; + if (hsts_enabled_) { + static_headers_ += "Strict-Transport-Security: " + + hsts_header_ + "\r\n"; + } +} + +void brazier::HttpsServer::load_tls_config_from_global() { + if (tls_config_from_user_) return; + + tls_.cert_pem = global_config->get("https_server.tls.cert_pem", + std::string("")); + tls_.key_pem = global_config->get("https_server.tls.key_pem", + std::string("")); + + tls_.cert_file = global_config->get("https_server.tls.cert_file", + std::string("server.crt")); + tls_.key_file = global_config->get("https_server.tls.key_file", + std::string("server.key")); + tls_.ca_file = global_config->get("https_server.tls.ca_file", + std::string("")); + + tls_.require_client_cert = + global_config->get("https_server.tls.require_client_cert", false); + tls_.verify_client_cert = + global_config->get("https_server.tls.verify_client_cert", false); + + tls_.handshake_timeout = std::chrono::seconds( + global_config->get("https_server.tls.handshake_timeout", 15)); + + try { + json conf = global_config->getJson("https_server.tls.conf"); + if (conf.is_array()) { + for (const auto& item : conf) { + if (!item.is_array() || item.empty() || item.size() > 2) { + throw std::runtime_error( + "https_server.tls.conf: each entry must be [command] " + "or [command, value]"); + } + std::string cmd = item[0].get(); + std::string val = item.size() > 1 + ? item[1].get() : ""; + tls_.conf.emplace_back(std::move(cmd), std::move(val)); + } + } + } + catch (const std::exception& e) { + Logger::log("https_server.tls.conf not loaded: " + + std::string(e.what()), "WARNING"); + } +} + +void brazier::HttpsServer::load_limits_from_config() { + const int ram_mb = platform::get_system_memory_mb(); + + const int testing_conn = + global_config->get("protocol.max_connections_testing", 0); + const int testing_body = + global_config->get("protocol.max_body_size_testing", 0); + const int testing_hdr = + global_config->get("protocol.max_header_size_testing", 0); + + const bool testing_mode = + testing_conn > 0 || testing_body > 0 || testing_hdr > 0; + + if (testing_conn > 0) { + max_connections_ = testing_conn; + } + else if (int v = global_config->get("protocol.max_connections", 0); v > 0) { + max_connections_ = v; + } + else { + const int fd_limit = platform::get_fd_limit(); + max_connections_ = fd_limit * 8 / 10; + } + + if (testing_body > 0) { + max_body_size_ = testing_body; + } + else if (int v = global_config->get("protocol.max_body_size", 0); v > 0) { + max_body_size_ = v; + } + else { + max_body_size_ = compute_max_body_size(ram_mb, max_connections_); + } + + if (testing_hdr > 0) { + max_header_size_ = testing_hdr; + } + else if (int v = global_config->get("protocol.max_header_size", 0); v > 0) { + max_header_size_ = v; + } + else { + max_header_size_ = compute_max_header_size(ram_mb, max_connections_); + } + + Logger::log( + std::string(testing_mode ? "[TESTING] " : "") + + "Final HTTP limits: max_connections=" + + std::to_string(max_connections_) + + ", max_body=" + std::to_string(max_body_size_ / 1024) + "KB" + + ", max_header=" + std::to_string(max_header_size_ / 1024) + "KB" + + " (RAM=" + std::to_string(ram_mb) + "MB)", + testing_mode ? "WARNING" : "INFO"); +} + +void brazier::HttpsServer::initializeConnections() { + try { + Queue::connect(global_config->get("nosql.host", "127.0.0.1"), + global_config->get("nosql.port", 6379)); + } + catch (const std::exception& e) { + Logger::log("Connection to queue failed: " + std::string(e.what()), + "ERROR"); + } + + try { + Cache::connect(global_config->get("redis.host", "127.0.0.1"), + global_config->get("redis.port", 6379)); + } + catch (const std::exception& e) { + Logger::log("Connection to NOSQL database failed: " + + std::string(e.what()), "ERROR"); + } + + try { + Database db; + auto migrator = std::make_unique(db); + migrator->Initialize(); + } + catch (const std::exception& e) { + Logger::log("Database migration failed: " + std::string(e.what()), + "ERROR"); + } +} \ No newline at end of file diff --git a/brazier/src/HttpsServerConnection.cpp b/brazier/src/HttpsServerConnection.cpp new file mode 100644 index 0000000..8c68a00 --- /dev/null +++ b/brazier/src/HttpsServerConnection.cpp @@ -0,0 +1,319 @@ +/* + * Copyright (c) 2026 Kirill Sergeev, Nikolay Sugonyako, Andrey Agarkov, Gleb Safyannikov + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * This file is part of brazier. + * + * brazier is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * brazier is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with brazier; if not, see . + */ + +#include "../include/brazier/HttpsServer.hpp" + +net::awaitable brazier::HttpsServer::accept_and_dispatch( + tcp::acceptor& acceptor, int worker_begin) +{ + const int worker_count = + static_cast(io_contexts_.size()) - worker_begin; + int next = 0; + + for (;;) { + if (shutting_down_.load(std::memory_order_acquire)) { + co_return; + } + + beast::error_code ec; + tcp::socket socket = co_await acceptor.async_accept( + net::redirect_error(net::use_awaitable, ec)); + + if (ec) { + if (ec == net::error::operation_aborted || + shutting_down_.load(std::memory_order_acquire)) { + co_return; + } + Logger::log("Accept error: " + ec.message(), "ERROR"); + continue; + } + + const int prev = connection_count_.fetch_add(1, + std::memory_order_acq_rel); + if (prev >= max_connections_) { + connection_count_.fetch_sub(1, std::memory_order_acq_rel); + Logger::log("Connection limit reached (" + + std::to_string(prev) + "/" + + std::to_string(max_connections_) + "), rejecting", + "WARNING"); + boost::system::error_code ignore; + socket.close(ignore); + continue; + } + + const int idx = worker_begin + (next++ % worker_count); + + net::co_spawn(*io_contexts_[idx], + handle_connection(std::move(socket)), + net::detached); + } +} + +net::awaitable brazier::HttpsServer::accept_loop( + tcp::acceptor& acceptor) +{ + for (;;) { + if (shutting_down_.load(std::memory_order_acquire)) { + co_return; + } + + beast::error_code ec; + tcp::socket socket = co_await acceptor.async_accept( + net::redirect_error(net::use_awaitable, ec)); + + if (ec) { + if (ec == net::error::operation_aborted || + shutting_down_.load(std::memory_order_acquire)) { + co_return; + } + Logger::log("Accept error: " + ec.message(), "ERROR"); + continue; + } + + const int prev = connection_count_.fetch_add(1, + std::memory_order_acq_rel); + if (prev >= max_connections_) { + connection_count_.fetch_sub(1, std::memory_order_acq_rel); + Logger::log("Connection limit reached (" + + std::to_string(prev) + "/" + + std::to_string(max_connections_) + "), rejecting", + "WARNING"); + boost::system::error_code ignore; + socket.close(ignore); + continue; + } + + net::co_spawn(acceptor.get_executor(), + handle_connection(std::move(socket)), + net::detached); + } +} + +net::awaitable brazier::HttpsServer::handle_connection( + tcp::socket socket) +{ + ConnectionGuard guard(*this); + + try { + socket.set_option(tcp::no_delay(true)); + socket.set_option(boost::asio::socket_base::keep_alive(true)); + + auto ctx = get_ssl_ctx(); + ssl::stream stream(std::move(socket), *ctx); + + { + net::steady_timer hs_timer(co_await net::this_coro::executor); + hs_timer.expires_after(tls_.handshake_timeout); + hs_timer.async_wait([&](beast::error_code ec) { + if (!ec) { + beast::error_code ignore; + stream.next_layer().close(ignore); + } + }); + + beast::error_code hs_ec; + co_await stream.async_handshake( + ssl::stream_base::server, + net::redirect_error(net::use_awaitable, hs_ec)); + + hs_timer.cancel(); + + if (hs_ec) { + Logger::log("TLS handshake failed: " + hs_ec.message(), + "WARNING"); + co_return; + } + } + + std::optional> parser; + + http::response res; + beast::flat_buffer buffer; + bool keep_alive = true; + + net::steady_timer idle_timer(co_await net::this_coro::executor); + const auto IDLE_TIMEOUT = keep_alive_timeout_; + bool timed_out = false; + + auto reset_timer = [&]() { + idle_timer.expires_after(IDLE_TIMEOUT); + idle_timer.async_wait([&](beast::error_code ec) { + if (!ec) { + timed_out = true; + beast::error_code ignore; + stream.next_layer().close(ignore); + } + }); + }; + + reset_timer(); + + while (keep_alive && !timed_out) { + parser.emplace(); + parser->body_limit( + static_cast(max_body_size_)); + parser->header_limit( + static_cast(max_header_size_)); + + beast::error_code ec; + + co_await http::async_read( + stream, buffer, *parser, + net::redirect_error(net::use_awaitable, ec)); + + if (ec == http::error::body_limit) { + http::response err{ + http::status::payload_too_large, 11 }; + err.set(http::field::content_type, "text/plain"); + err.set(http::field::connection, "close"); + err.body() = "Payload too large"; + err.prepare_payload(); + + beast::error_code write_ec; + co_await http::async_write( + stream, err, + net::redirect_error(net::use_awaitable, write_ec)); + break; + } + + if (ec == http::error::header_limit) { + http::response err{ + http::status::request_header_fields_too_large, 11 }; + err.set(http::field::content_type, "text/plain"); + err.set(http::field::connection, "close"); + err.body() = "Header too large"; + err.prepare_payload(); + + beast::error_code write_ec; + co_await http::async_write( + stream, err, + net::redirect_error(net::use_awaitable, write_ec)); + break; + } + + if (ec == http::error::end_of_stream) break; + if (ec) break; + + reset_timer(); + + http::request req = parser->release(); + total_requests_.fetch_add(1, std::memory_order_relaxed); + keep_alive = req.keep_alive(); + + res.clear(); + res.version(req.version()); + res.keep_alive(keep_alive); + + try { + co_await Router::handle_request(req, res); + } + catch (const std::exception& e) { + Logger::log("Router error: " + std::string(e.what()), + "ERROR"); + res.result(http::status::internal_server_error); + res.set(http::field::content_type, "application/json"); + res.body() = R"({"error":"internal server error"})"; + keep_alive = false; + } + + res.prepare_payload(); + + std::string flat; + flat.reserve(256 + static_headers_.size() + + res.body().size()); + + flat += "HTTP/1.1 "; + flat += std::to_string(res.result_int()); + flat += ' '; + flat += res.reason(); + flat += "\r\n"; + + flat += static_headers_; + + flat += "Connection: "; + flat += keep_alive ? "keep-alive\r\n" : "close\r\n"; + + for (const auto& field : res.base()) { + flat += field.name_string(); + flat += ": "; + flat += field.value(); + flat += "\r\n"; + } + flat += "\r\n"; + flat += res.body(); + + ec.clear(); + co_await net::async_write( + stream, net::buffer(flat), + net::redirect_error(net::use_awaitable, ec)); + + if (ec) break; + + buffer.consume(buffer.size()); + if (!keep_alive) break; + } + + idle_timer.cancel(); + + { + net::steady_timer sd_timer(co_await net::this_coro::executor); + sd_timer.expires_after(std::chrono::seconds(2)); + sd_timer.async_wait([&](beast::error_code ec) { + if (!ec) { + beast::error_code ignore; + stream.next_layer().close(ignore); + } + }); + + beast::error_code sd_ec; + co_await stream.async_shutdown( + net::redirect_error(net::use_awaitable, sd_ec)); + + sd_timer.cancel(); + } + + { + beast::error_code ec; + stream.next_layer().shutdown(tcp::socket::shutdown_both, ec); + stream.next_layer().close(ec); + } + } + catch (const boost::system::system_error& e) { + auto code = e.code(); + if (code != net::error::connection_reset && + code != net::error::connection_aborted && + code != net::error::eof && + code != net::error::operation_aborted && + code != net::error::broken_pipe && + code != ssl::error::stream_truncated) { + Logger::log("HTTPS connection error: " + + std::string(e.what()), "ERROR"); + } + } + catch (const std::exception& e) { + Logger::log("HTTPS connection error: " + std::string(e.what()), + "ERROR"); + } + catch (...) { + Logger::log("Unknown HTTPS connection error", "ERROR"); + } + + co_return; +} \ No newline at end of file diff --git a/brazier/src/HttpsServerTLS.cpp b/brazier/src/HttpsServerTLS.cpp new file mode 100644 index 0000000..a05bbba --- /dev/null +++ b/brazier/src/HttpsServerTLS.cpp @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026 Kirill Sergeev, Nikolay Sugonyako, Andrey Agarkov, Gleb Safyannikov + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * This file is part of brazier. + * + * brazier is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * brazier is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with brazier; if not, see . + */ + +#include "../include/brazier/HttpsServer.hpp" + +#include + +void brazier::HttpsServer::apply_ssl_conf(ssl::context& ctx) { + if (tls_.conf.empty()) return; + + SSL_CONF_CTX* cctx = SSL_CONF_CTX_new(); + if (!cctx) { + throw std::runtime_error("SSL_CONF_CTX_new failed"); + } + + SSL_CONF_CTX_set_flags(cctx, + SSL_CONF_FLAG_SERVER | SSL_CONF_FLAG_CERTIFICATE); + SSL_CONF_CTX_set_ssl_ctx(cctx, ctx.native_handle()); + + for (const auto& [cmd, val] : tls_.conf) { + int rv = val.empty() + ? SSL_CONF_cmd(cctx, cmd.c_str(), nullptr) + : SSL_CONF_cmd(cctx, cmd.c_str(), val.c_str()); + + if (rv <= 0) { + SSL_CONF_CTX_free(cctx); + throw std::runtime_error( + "SSL_CONF_cmd failed for '" + cmd + + (val.empty() ? "'" : "=" + val + "'")); + } + } + + if (SSL_CONF_CTX_finish(cctx) != 1) { + SSL_CONF_CTX_free(cctx); + throw std::runtime_error("SSL_CONF_CTX_finish failed"); + } + + SSL_CONF_CTX_free(cctx); +} + +void brazier::HttpsServer::configure_ssl_ctx(ssl::context& ctx) { + ctx.set_options( + ssl::context::default_workarounds + | ssl::context::no_sslv2 + | ssl::context::no_sslv3 + | ssl::context::no_tlsv1 + | ssl::context::no_tlsv1_1 + | ssl::context::single_dh_use); + + SSL_CTX_set_options(ctx.native_handle(), + SSL_OP_IGNORE_UNEXPECTED_EOF); + + SSL_CTX_set_session_cache_mode(ctx.native_handle(), SSL_SESS_CACHE_OFF); + + ticket_store_.ensure_initialized(); + ticket_store_.attach_to(ctx.native_handle()); + + apply_ssl_conf(ctx); + + if (!tls_.cert_pem.empty() && !tls_.key_pem.empty()) { + load_cert_from_memory(ctx, tls_.cert_pem, tls_.key_pem); + } + else { + ctx.use_certificate_chain_file(tls_.cert_file); + ctx.use_private_key_file(tls_.key_file, ssl::context::pem); + + if (SSL_CTX_check_private_key(ctx.native_handle()) != 1) { + throw std::runtime_error( + "Certificate/private key mismatch: " + + tls_.cert_file + " / " + tls_.key_file); + } + } + + if (tls_.require_client_cert || tls_.verify_client_cert) { + if (!tls_.ca_file.empty()) { + ctx.load_verify_file(tls_.ca_file); + } + auto mode = ssl::verify_peer; + if (tls_.require_client_cert) { + mode |= ssl::verify_fail_if_no_peer_cert; + } + ctx.set_verify_mode(mode); + } + else { + ctx.set_verify_mode(ssl::verify_none); + } +} + +void brazier::HttpsServer::configure_tls() { + if (!ssl_ctx_) { + throw std::runtime_error("configure_tls: ssl_ctx_ not initialized"); + } + configure_ssl_ctx(*ssl_ctx_); +} + +void brazier::HttpsServer::load_cert_from_memory( + ssl::context& ctx, + const std::string& cert_pem, const std::string& key_pem) +{ + if (cert_pem.empty() || key_pem.empty()) { + throw std::runtime_error("load_cert_from_memory: empty PEM"); + } + + BIO* cert_bio = BIO_new_mem_buf(cert_pem.data(), + static_cast(cert_pem.size())); + if (!cert_bio) { + throw std::runtime_error("BIO_new_mem_buf (cert) failed"); + } + + X509* leaf = PEM_read_bio_X509(cert_bio, nullptr, nullptr, nullptr); + if (!leaf) { + BIO_free(cert_bio); + throw std::runtime_error("PEM_read_bio_X509 failed: " + + std::string(ERR_error_string(ERR_get_error(), nullptr))); + } + + if (SSL_CTX_use_certificate(ctx.native_handle(), leaf) != 1) { + X509_free(leaf); + BIO_free(cert_bio); + throw std::runtime_error("SSL_CTX_use_certificate failed"); + } + X509_free(leaf); + + X509* chain_cert = nullptr; + while ((chain_cert = PEM_read_bio_X509(cert_bio, nullptr, + nullptr, nullptr)) != nullptr) { + if (SSL_CTX_add_extra_chain_cert(ctx.native_handle(), + chain_cert) != 1) { + X509_free(chain_cert); + BIO_free(cert_bio); + throw std::runtime_error( + "SSL_CTX_add_extra_chain_cert failed"); + } + } + BIO_free(cert_bio); + + BIO* key_bio = BIO_new_mem_buf(key_pem.data(), + static_cast(key_pem.size())); + if (!key_bio) { + throw std::runtime_error("BIO_new_mem_buf (key) failed"); + } + + EVP_PKEY* pkey = PEM_read_bio_PrivateKey(key_bio, nullptr, + nullptr, nullptr); + BIO_free(key_bio); + + if (!pkey) { + throw std::runtime_error("PEM_read_bio_PrivateKey failed: " + + std::string(ERR_error_string(ERR_get_error(), nullptr))); + } + + if (SSL_CTX_use_PrivateKey(ctx.native_handle(), pkey) != 1) { + EVP_PKEY_free(pkey); + throw std::runtime_error("SSL_CTX_use_PrivateKey failed"); + } + EVP_PKEY_free(pkey); + + if (SSL_CTX_check_private_key(ctx.native_handle()) != 1) { + throw std::runtime_error( + "Certificate and private key do not match"); + } + + Logger::log("TLS certificate loaded from PEM (in-memory, with chain)", + "INFO"); +} + +std::shared_ptr brazier::HttpsServer::get_ssl_ctx() { + std::shared_lock lock(ssl_ctx_mutex_); + return ssl_ctx_; +} + +bool brazier::HttpsServer::reloadTls() { + if (!tls_.cert_file.empty() && !tls_.key_file.empty()) { + std::ifstream cf(tls_.cert_file, std::ios::binary); + std::ifstream kf(tls_.key_file, std::ios::binary); + + if (!cf || !kf) { + Logger::log("reloadTls: cannot open files: " + + tls_.cert_file + " / " + tls_.key_file, "ERROR"); + return false; + } + + TlsConfig new_tls = tls_; + new_tls.cert_pem = std::string( + std::istreambuf_iterator(cf), {}); + new_tls.key_pem = std::string( + std::istreambuf_iterator(kf), {}); + + return reloadTls(new_tls); + } + + Logger::log("reloadTls: no file paths configured, " + "use reloadTls(new_tls) with fresh PEM", "WARNING"); + return false; +} + +bool brazier::HttpsServer::reloadTls(const TlsConfig& new_tls) { + try { + auto new_ctx = std::make_shared( + ssl::context::tls_server); + + TlsConfig saved = tls_; + tls_ = new_tls; + + try { + configure_ssl_ctx(*new_ctx); + } + catch (...) { + tls_ = saved; + throw; + } + + { + std::unique_lock lock(ssl_ctx_mutex_); + ssl_ctx_ = new_ctx; + } + + Logger::log("TLS reloaded successfully (new SSL_CTX active)", + "SUCCESS"); + return true; + } + catch (const std::exception& e) { + Logger::log("TLS reload failed: " + std::string(e.what()) + + " (keeping old SSL_CTX)", "ERROR"); + return false; + } +} \ No newline at end of file diff --git a/brazier/src/Platform/Linux/SocketOptions.cpp b/brazier/src/Platform/Linux/SocketOptions.cpp new file mode 100644 index 0000000..0a5db19 --- /dev/null +++ b/brazier/src/Platform/Linux/SocketOptions.cpp @@ -0,0 +1,23 @@ +#include "brazier/Platform/SocketOptions.hpp" + +#include +#include +#include + +namespace brazier::platform { + + bool set_reuse_port(NativeSocket fd) noexcept { + int one = 1; + return ::setsockopt(static_cast(fd), SOL_SOCKET, SO_REUSEPORT, + &one, sizeof(one)) == 0; + } + + bool set_defer_accept(NativeSocket fd, int seconds) noexcept { + return ::setsockopt(static_cast(fd), IPPROTO_TCP, TCP_DEFER_ACCEPT, + &seconds, sizeof(seconds)) == 0; + } + + bool has_reuse_port() noexcept { return true; } + bool has_defer_accept() noexcept { return true; } + +} \ No newline at end of file diff --git a/brazier/src/Platform/Linux/SystemInfo.cpp b/brazier/src/Platform/Linux/SystemInfo.cpp new file mode 100644 index 0000000..2cfce0c --- /dev/null +++ b/brazier/src/Platform/Linux/SystemInfo.cpp @@ -0,0 +1,42 @@ +#include "brazier/Platform/SystemInfo.hpp" + +#include +#include +#include + +namespace brazier::platform { + + int get_fd_limit() noexcept { + struct rlimit rl; + if (::getrlimit(RLIMIT_NOFILE, &rl) != 0) { + return 1024; + } + if (rl.rlim_cur == RLIM_INFINITY) { + return (rl.rlim_max == RLIM_INFINITY) + ? 65536 + : static_cast(rl.rlim_max); + } + return static_cast(rl.rlim_cur); + } + + int get_system_memory_mb() noexcept { + struct sysinfo si; + if (::sysinfo(&si) == 0) { + return static_cast( + (static_cast(si.totalram) * si.mem_unit) / + (1024 * 1024)); + } + return 1024; + } + + int get_worker_count() noexcept { + const int n = static_cast(std::thread::hardware_concurrency()); + return (n > 0) ? n : 1; + } + + int get_thread_count() noexcept { + const int n = static_cast(std::thread::hardware_concurrency()); + return (n > 0) ? n : 1; + } + +} \ No newline at end of file diff --git a/brazier/src/Platform/MacOS/SocketOptions.cpp b/brazier/src/Platform/MacOS/SocketOptions.cpp new file mode 100644 index 0000000..094987e --- /dev/null +++ b/brazier/src/Platform/MacOS/SocketOptions.cpp @@ -0,0 +1,20 @@ +#include "brazier/Platform/SocketOptions.hpp" + +#include + +namespace brazier::platform { + + bool set_reuse_port(NativeSocket fd) noexcept { + int one = 1; + return ::setsockopt(static_cast(fd), SOL_SOCKET, SO_REUSEPORT, + &one, sizeof(one)) == 0; + } + + bool set_defer_accept(NativeSocket /*fd*/, int /*seconds*/) noexcept { + return false; + } + + bool has_reuse_port() noexcept { return true; } + bool has_defer_accept() noexcept { return false; } + +} \ No newline at end of file diff --git a/brazier/src/Platform/MacOS/SystemInfo.cpp b/brazier/src/Platform/MacOS/SystemInfo.cpp new file mode 100644 index 0000000..f7eee90 --- /dev/null +++ b/brazier/src/Platform/MacOS/SystemInfo.cpp @@ -0,0 +1,42 @@ +#include "brazier/Platform/SystemInfo.hpp" + +#include +#include +#include +#include + +namespace brazier::platform { + + int get_fd_limit() noexcept { + struct rlimit rl; + if (::getrlimit(RLIMIT_NOFILE, &rl) != 0) { + return 1024; + } + if (rl.rlim_cur == RLIM_INFINITY) { + return (rl.rlim_max == RLIM_INFINITY) + ? 65536 + : static_cast(rl.rlim_max); + } + return static_cast(rl.rlim_cur); + } + + int get_system_memory_mb() noexcept { + int mib[2] = { CTL_HW, HW_MEMSIZE }; + uint64_t memsize = 0; + size_t len = sizeof(memsize); + if (::sysctl(mib, 2, &memsize, &len, nullptr, 0) == 0) { + return static_cast(memsize / (1024 * 1024)); + } + return 1024; + } + + int get_worker_count() noexcept { + const int n = static_cast(std::thread::hardware_concurrency()); + return (n > 0) ? n : 1; + } + + int get_thread_count() noexcept { + const int n = static_cast(std::thread::hardware_concurrency()); + return (n > 0) ? n : 1; + } +} \ No newline at end of file diff --git a/brazier/src/Platform/Windows/SocketOptions.cpp b/brazier/src/Platform/Windows/SocketOptions.cpp new file mode 100644 index 0000000..b1cf792 --- /dev/null +++ b/brazier/src/Platform/Windows/SocketOptions.cpp @@ -0,0 +1,13 @@ +#include "brazier/Platform/SocketOptions.hpp" + +#include + +namespace brazier::platform { + + bool set_reuse_port(NativeSocket /*fd*/) noexcept { return false; } + bool set_defer_accept(NativeSocket /*fd*/, int /*seconds*/) noexcept { return false; } + + bool has_reuse_port() noexcept { return false; } + bool has_defer_accept() noexcept { return false; } + +} \ No newline at end of file diff --git a/brazier/src/Platform/Windows/SystemInfo.cpp b/brazier/src/Platform/Windows/SystemInfo.cpp new file mode 100644 index 0000000..6d31972 --- /dev/null +++ b/brazier/src/Platform/Windows/SystemInfo.cpp @@ -0,0 +1,29 @@ +#include "brazier/Platform/SystemInfo.hpp" + +#include + +namespace brazier::platform { + + int get_fd_limit() noexcept { + return 16384; + } + + int get_system_memory_mb() noexcept { + MEMORYSTATUSEX ms{}; + ms.dwLength = sizeof(ms); + if (::GlobalMemoryStatusEx(&ms)) { + return static_cast(ms.ullTotalPhys / (1024 * 1024)); + } + return 1024; + } + + int get_worker_count() noexcept { + return 1; + } + + int get_thread_count() noexcept { + const int n = static_cast(std::thread::hardware_concurrency()); + return (n > 0) ? n : 1; + } + +} \ No newline at end of file diff --git a/brazier/src/Server.cpp b/brazier/src/Server.cpp index 1f0ff5f..f710ae3 100644 --- a/brazier/src/Server.cpp +++ b/brazier/src/Server.cpp @@ -51,7 +51,6 @@ bool brazier::Server::initialize() { initializeConnections(); RouterRegisterer::init(io_); - Engine::init(io_); Logger::log("Server initialized on " + host_ + ":" + std::to_string(port_), "SUCCESS"); return true; @@ -73,8 +72,9 @@ void brazier::Server::run() { for (int i = 0; i < threads_count; ++i) { threads_.emplace_back([this] { + brazier::Engine::init(io_); io_.run(); - }); + }); } shutdown_flag_.store(false, std::memory_order_release); diff --git a/brazier/src/TLS/TicketKeyStore.cpp b/brazier/src/TLS/TicketKeyStore.cpp new file mode 100644 index 0000000..832aaf6 --- /dev/null +++ b/brazier/src/TLS/TicketKeyStore.cpp @@ -0,0 +1,223 @@ +#include "../../include/brazier/TLS/TicketKeyStore.hpp" + +#include + +#if OPENSSL_VERSION_NUMBER >= 0x30000000L +# include +# include +#endif + +#include +#include +#include + +namespace { + + std::once_flag g_ex_index_flag; + int g_ex_index = -1; + +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + + bool init_hmac(EVP_MAC_CTX* hctx, const unsigned char* key, std::size_t keylen) { + char digest_name[] = "SHA256"; + OSSL_PARAM params[] = { + OSSL_PARAM_construct_utf8_string(OSSL_MAC_PARAM_DIGEST, + digest_name, 0), + OSSL_PARAM_construct_end() + }; + return EVP_MAC_init(hctx, key, keylen, params) == 1; + } + + int ticket_key_cb(SSL* ssl, + unsigned char key_name[16], + unsigned char iv[EVP_MAX_IV_LENGTH], + EVP_CIPHER_CTX* cipher_ctx, + EVP_MAC_CTX* mac_ctx, + int enc) { + SSL_CTX* ssl_ctx = SSL_get_SSL_CTX(ssl); + if (!ssl_ctx) return 0; + + auto* store = static_cast( + SSL_CTX_get_ex_data(ssl_ctx, brazier::ticket_store_ex_index())); + if (!store) return 0; + + return store->handle(key_name, iv, cipher_ctx, mac_ctx, enc); + } + +#else + + bool init_hmac(HMAC_CTX* hctx, const unsigned char* key, int keylen) { + return HMAC_Init_ex(hctx, key, keylen, EVP_sha256(), nullptr) == 1; + } + + int ticket_key_cb(SSL* ssl, + unsigned char key_name[16], + unsigned char iv[EVP_MAX_IV_LENGTH], + EVP_CIPHER_CTX* cipher_ctx, + HMAC_CTX* mac_ctx, + int enc) { + SSL_CTX* ssl_ctx = SSL_get_SSL_CTX(ssl); + if (!ssl_ctx) return 0; + + auto* store = static_cast( + SSL_CTX_get_ex_data(ssl_ctx, brazier::ticket_store_ex_index())); + if (!store) return 0; + + return store->handle(key_name, iv, cipher_ctx, mac_ctx, enc); + } + +#endif + +} + +namespace brazier { + + TicketKeyStore::TicketKeyStore() + : now_provider_([] { return Clock::now(); }) {} + + TicketKeyStore::TicketKeyStore(std::function now_provider) + : now_provider_(std::move(now_provider)) { + if (!now_provider_) { + throw std::invalid_argument("TicketKeyStore: now_provider is null"); + } + } + + TicketKeyStore::Key TicketKeyStore::generate_key(TimePoint now) { + Key k{}; + if (RAND_bytes(k.name, kNameSize) != 1 || + RAND_bytes(k.aes_key, kAesKeySize) != 1 || + RAND_bytes(k.hmac_key, kHmacKeySize) != 1) { + throw std::runtime_error("TicketKeyStore: RAND_bytes failed"); + } + k.created_at = now; + return k; + } + + void TicketKeyStore::ensure_initialized() { + std::lock_guard lk(mtx_); + if (keys_.empty()) { + keys_.push_back(generate_key(now_provider_())); + } + } + + void TicketKeyStore::rotate_now() { + std::lock_guard lk(mtx_); + const auto now = now_provider_(); + keys_.push_front(generate_key(now)); + while (!keys_.empty() && + now - keys_.back().created_at > kKeyLifetime) { + keys_.pop_back(); + } + } + + void TicketKeyStore::maybe_rotate_locked(TimePoint now) { + if (keys_.empty()) { + keys_.push_back(generate_key(now)); + return; + } + if (now - keys_.front().created_at < kRotationInterval) { + return; + } + keys_.push_front(generate_key(now)); + while (!keys_.empty() && + now - keys_.back().created_at > kKeyLifetime) { + keys_.pop_back(); + } + } + + int TicketKeyStore::handle(unsigned char key_name[kNameSize], + unsigned char iv[EVP_MAX_IV_LENGTH], + EVP_CIPHER_CTX* cipher_ctx, + void* mac_ctx, + int enc) { +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + auto* hctx = static_cast(mac_ctx); +#else + auto* hctx = static_cast(mac_ctx); +#endif + + std::lock_guard lk(mtx_); + + if (keys_.empty()) { + keys_.push_back(generate_key(now_provider_())); + } + + if (enc == 1) { + maybe_rotate_locked(now_provider_()); + + const auto& cur = keys_.front(); + std::memcpy(key_name, cur.name, kNameSize); + RAND_bytes(iv, EVP_MAX_IV_LENGTH); + + if (EVP_EncryptInit_ex(cipher_ctx, EVP_aes_256_cbc(), nullptr, + cur.aes_key, iv) != 1) { + return -1; + } + if (!init_hmac(hctx, cur.hmac_key, kHmacKeySize)) { + return -1; + } + return 1; + } + + for (std::size_t i = 0; i < keys_.size(); ++i) { + if (std::memcmp(key_name, keys_[i].name, kNameSize) != 0) { + continue; + } + const auto& k = keys_[i]; + if (EVP_DecryptInit_ex(cipher_ctx, EVP_aes_256_cbc(), nullptr, + k.aes_key, iv) != 1) { + return -1; + } + if (!init_hmac(hctx, k.hmac_key, kHmacKeySize)) { + return -1; + } + return (i == 0) ? 1 : 2; + } + return 0; + } + + void TicketKeyStore::attach_to(SSL_CTX* ctx) { + if (!ctx) { + throw std::invalid_argument("TicketKeyStore::attach_to: ctx is null"); + } + + const int idx = ticket_store_ex_index(); + if (idx < 0) { + throw std::runtime_error( + "TicketKeyStore::attach_to: ex_data index not available"); + } + + void* existing = SSL_CTX_get_ex_data(ctx, idx); + if (existing != nullptr && existing != this) { + throw std::runtime_error( + "TicketKeyStore::attach_to: SSL_CTX already has a different store"); + } + + SSL_CTX_set_ex_data(ctx, idx, this); + +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + SSL_CTX_set_tlsext_ticket_key_evp_cb(ctx, ticket_key_cb); +#else + SSL_CTX_set_tlsext_ticket_key_cb(ctx, ticket_key_cb); +#endif + } + + std::size_t TicketKeyStore::key_count() const { + std::lock_guard lk(mtx_); + return keys_.size(); + } + + std::vector TicketKeyStore::snapshot() const { + std::lock_guard lk(mtx_); + return { keys_.begin(), keys_.end() }; + } + + int ticket_store_ex_index() { + std::call_once(g_ex_index_flag, [] { + g_ex_index = SSL_CTX_get_ex_new_index( + 0, nullptr, nullptr, nullptr, nullptr); + }); + return g_ex_index; + } + +} \ No newline at end of file diff --git a/brazier/tests/main.cpp b/brazier/tests/main.cpp index e615024..1695839 100644 --- a/brazier/tests/main.cpp +++ b/brazier/tests/main.cpp @@ -20,31 +20,46 @@ #include "main.h" -std::shared_ptr g_test_server; +constexpr bool kStartHttpServer = false; +constexpr bool kStartHttpsServer = true; + +std::shared_ptr g_test_server; +std::shared_ptr g_test_https_server; std::atomic g_server_ready{ false }; +std::atomic g_https_server_ready{ false }; std::thread g_server_thread; +std::thread g_https_server_thread; + int port_global; std::string host_global; +int https_port_global; +std::string https_host_global; + +namespace { + + std::string normalizeHost(const std::string& host) { + return (host == "0.0.0.0") ? "127.0.0.1" : host; + } + + bool WaitForServer(const std::string& host, int port, int max_attempts = 30) { + boost::asio::io_context io; + boost::asio::ip::tcp::socket socket(io); + boost::asio::ip::tcp::endpoint endpoint( + boost::asio::ip::make_address(host), port); -bool WaitForServer(int port, int max_attempts = 30) { - boost::asio::io_context io_context; - boost::asio::ip::tcp::socket socket(io_context); - boost::asio::ip::tcp::endpoint endpoint( - boost::asio::ip::make_address(host_global), - port - ); - - for (int i = 0; i < max_attempts; ++i) { - boost::system::error_code ec; - socket.connect(endpoint, ec); - if (!ec) { - socket.close(); - return true; + for (int i = 0; i < max_attempts; ++i) { + boost::system::error_code ec; + socket.connect(endpoint, ec); + if (!ec) { + socket.close(); + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(200)); } - std::this_thread::sleep_for(std::chrono::milliseconds(200)); + return false; } - return false; -} + +} int main(int argc, char** argv) { try { @@ -52,51 +67,69 @@ int main(int argc, char** argv) { brazier::ConfigManager::initGlobal("config_test.json"); brazier::global_config->setAutoSave(false); - std::string server_host = brazier::global_config->get("server.host", "127.0.0.1"); - int server_port = brazier::global_config->get("server.port", 3502); + host_global = normalizeHost( + brazier::global_config->get("server.host", std::string("127.0.0.1"))); + port_global = brazier::global_config->get("server.port", 3502); - if (server_host == "0.0.0.0") { - server_host = "127.0.0.1"; - } + https_host_global = normalizeHost( + brazier::global_config->get("https_server.host", std::string("127.0.0.1"))); + https_port_global = brazier::global_config->get("https_server.port", 8443); - host_global = server_host; - port_global = server_port; + if (kStartHttpServer) { + g_test_server = std::make_shared(host_global, port_global); + g_server_thread = std::thread([]() { + try { + if (!g_test_server->initialize()) { + brazier::Logger::log("Failed to init HTTP test server", "ERROR"); + return; + } + g_server_ready = true; + brazier::Logger::log("HTTP test server initialized", "INFO"); + g_test_server->run(); + } + catch (const std::exception& e) { + brazier::Logger::log("HTTP test server error: " + + std::string(e.what()), "ERROR"); + } + }); - g_test_server = std::make_shared(server_host, server_port); + if (!WaitForServer(host_global, port_global)) { + brazier::Logger::log("HTTP server failed to start within timeout", "ERROR"); + } + } - g_server_thread = std::thread([]() { - try { - if (!g_test_server->initialize()) { - brazier::Logger::log("Failed to initialize test server", "ERROR"); - g_server_ready = false; - return; + if (kStartHttpsServer) { + g_test_https_server = std::make_shared( + https_host_global, https_port_global); + g_https_server_thread = std::thread([]() { + try { + if (!g_test_https_server->initialize()) { + brazier::Logger::log("Failed to init HTTPS test server", "ERROR"); + return; + } + g_https_server_ready = true; + brazier::Logger::log("HTTPS test server initialized", "INFO"); + g_test_https_server->run(); } - g_server_ready = true; - brazier::Logger::log("Test server initialized successfully", "INFO"); - g_test_server->run(); - } - catch (const std::exception& e) { - brazier::Logger::log("Server error: " + std::string(e.what()), "ERROR"); - g_server_ready = false; - } - }); + catch (const std::exception& e) { + brazier::Logger::log("HTTPS test server error: " + + std::string(e.what()), "ERROR"); + } + }); - if (!WaitForServer(server_port)) { - brazier::Logger::log("Server failed to start within timeout", "ERROR"); - return -1; + if (!WaitForServer(https_host_global, https_port_global)) { + brazier::Logger::log("HTTPS server failed to start within timeout", "ERROR"); + } } int result = RUN_ALL_TESTS(); - - if (g_test_server) { - g_test_server->stop(); - } - if (g_server_thread.joinable()) { - g_server_thread.join(); - } - return result; + if (g_test_server) g_test_server->stop(); + if (g_test_https_server) g_test_https_server->stop(); + if (g_server_thread.joinable()) g_server_thread.join(); + if (g_https_server_thread.joinable()) g_https_server_thread.join(); + return result; } catch (const std::exception& e) { brazier::Logger::log("Exception: " + std::string(e.what()), "ERROR"); diff --git a/brazier/tests/main.h b/brazier/tests/main.h index 79e4ee8..0b9594a 100644 --- a/brazier/tests/main.h +++ b/brazier/tests/main.h @@ -34,5 +34,10 @@ extern std::shared_ptr g_test_server; extern std::atomic g_server_ready; extern std::thread g_server_thread; +extern std::shared_ptr g_test_https_server; + extern int port_global; -extern std::string host_global; \ No newline at end of file +extern std::string host_global; + +extern int https_port_global; +extern std::string https_host_global; \ No newline at end of file diff --git a/brazier/tests/unit/routing/https_routing_test.cpp b/brazier/tests/unit/routing/https_routing_test.cpp new file mode 100644 index 0000000..3bce8cf --- /dev/null +++ b/brazier/tests/unit/routing/https_routing_test.cpp @@ -0,0 +1,539 @@ +/* + * Copyright (c) 2026 Kirill Sergeev, Nikolay Sugonyako, Andrey Agarkov, Gleb Safyannikov + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * This file is part of brazier. + * + * brazier is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * brazier is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with brazier; if not, see . + */ + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "../../../include/brazier/Core" +#include "../../../include/brazier/Http" +#include "main.h" +#include "tls_test_client.hpp" + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +namespace ssl = net::ssl; +using tcp = net::ip::tcp; + +using namespace brazier; + +namespace { + + constexpr int kMaxResponseTimeMs = 1500; + + constexpr int kClientTimeoutSec = 10; + + constexpr int kRouteRegistrationDelayMs = 200; + +} + +class TestController : public brazier::Controller { +public: + using Request = http::request; + using Response = http::response; + + net::awaitable show(const Request& req, Response& res, const Params& params) override { + res.result(http::status::ok); + res.set(http::field::content_type, "text/plain"); + res.body() = "TestController show method called"; + co_return; + } + + net::awaitable json_response(const Request& req, Response& res, + const Params& params) { + res.result(http::status::ok); + res.set(http::field::content_type, "application/json"); + res.body() = R"({"status":"success","message":"JSON response from TestController"})"; + co_return; + } + + net::awaitable echo_post(const Request& req, Response& res, + const Params& params) { + res.result(http::status::ok); + res.set(http::field::content_type, "application/json"); + res.body() = req.body(); + co_return; + } +}; + +template +auto RunAsync(AsyncOp&& op) { + net::io_context io; + auto future = net::co_spawn(io, std::forward(op), net::use_future); + io.run(); + return future.get(); +} + +bool IsHttpsServerReady() { + try { + net::io_context io; + ssl::context ctx(ssl::context::tls_client); + ctx.set_verify_mode(ssl::verify_none); + + ssl::stream stream(io, ctx); + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + net::connect(stream.next_layer(), results); + stream.handshake(ssl::stream_base::client); + + boost::system::error_code ec; + stream.shutdown(ec); + + return true; + } + catch (...) { + return false; + } +} + +std::string BaseUrl() { + return "https://" + https_host_global + ":" + + std::to_string(https_port_global); +} + +bool TryConnectWithTlsVersion(int version) { + try { + net::io_context io; + ssl::context ctx(ssl::context::tls_client); + ctx.set_verify_mode(ssl::verify_none); + + switch (version) { + case TLS1_VERSION: + ctx.set_options(ssl::context::no_tlsv1_1 | + ssl::context::no_tlsv1_2 | + ssl::context::no_tlsv1_3); + break; + case TLS1_1_VERSION: + ctx.set_options(ssl::context::no_tlsv1 | + ssl::context::no_tlsv1_2 | + ssl::context::no_tlsv1_3); + break; + case TLS1_2_VERSION: + ctx.set_options(ssl::context::no_tlsv1 | + ssl::context::no_tlsv1_1 | + ssl::context::no_tlsv1_3); + break; + case TLS1_3_VERSION: + ctx.set_options(ssl::context::no_tlsv1 | + ssl::context::no_tlsv1_1 | + ssl::context::no_tlsv1_2); + break; + default: + return false; + } + + ssl::stream stream(io, ctx); + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + net::connect(stream.next_layer(), results); + stream.handshake(ssl::stream_base::client); + return true; + } + catch (...) { + return false; + } +} + +class HttpsRoutingTest : public ::testing::Test { +protected: + static void SetUpTestSuite() { + auto test_controller = std::make_shared(); + + R(GET, "/test", test_controller, show); + R(GET, "/test/json", test_controller, json_response); + R(POST, "/test/echo", test_controller, echo_post); + + std::this_thread::sleep_for( + std::chrono::milliseconds(kRouteRegistrationDelayMs)); + } + + void SetUp() override { + if (!IsHttpsServerReady()) { + GTEST_SKIP() << "HTTPS server is not running on " + << https_host_global << ":" << https_port_global; + } + } + + brazier::HttpClient MakeClient() { + brazier::HttpClient client; + client.set_verify_ssl(false); + client.set_timeout(std::chrono::seconds(kClientTimeoutSec)); + return client; + } +}; + +TEST_F(HttpsRoutingTest, AddRouteAndGet) { + auto client = MakeClient(); + + try { + auto response = RunAsync([&]() -> net::awaitable { + co_return co_await client.get(BaseUrl() + "/test"); + }); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_EQ(response.body(), "TestController show method called"); + EXPECT_EQ(response[http::field::content_type], "text/plain"); + } + catch (const std::exception& e) { + FAIL() << "HTTPS request failed: " << e.what(); + } +} + +TEST_F(HttpsRoutingTest, JsonResponse) { + auto client = MakeClient(); + + try { + auto response = RunAsync([&]() -> net::awaitable { + co_return co_await client.get(BaseUrl() + "/test/json"); + }); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_EQ(response[http::field::content_type], "application/json"); + + auto json = nlohmann::json::parse(response.body()); + EXPECT_EQ(json["status"], "success"); + EXPECT_EQ(json["message"], "JSON response from TestController"); + } + catch (const std::exception& e) { + FAIL() << "HTTPS request failed: " << e.what(); + } +} + +TEST_F(HttpsRoutingTest, PostWithBody) { + auto client = MakeClient(); + + nlohmann::json request_body = { + {"name", "Test User"}, + {"age", 25}, + {"email", "test@example.com"} + }; + + try { + auto response = RunAsync([&]() -> net::awaitable { + co_return co_await client.post(BaseUrl() + "/test/echo", request_body); + }); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_EQ(response[http::field::content_type], "application/json"); + + auto response_json = nlohmann::json::parse(response.body()); + EXPECT_EQ(response_json["name"], "Test User"); + EXPECT_EQ(response_json["age"], 25); + EXPECT_EQ(response_json["email"], "test@example.com"); + } + catch (const std::exception& e) { + FAIL() << "HTTPS request failed: " << e.what(); + } +} + +TEST_F(HttpsRoutingTest, NotFound) { + auto client = MakeClient(); + + try { + auto response = RunAsync([&]() -> net::awaitable { + co_return co_await client.get(BaseUrl() + "/nonexistent"); + }); + + EXPECT_EQ(response.result_int(), 404); + } + catch (const std::exception& e) { + FAIL() << "HTTPS request failed: " << e.what(); + } +} + +TEST_F(HttpsRoutingTest, ResponseTime) { + auto client = MakeClient(); + auto start = std::chrono::steady_clock::now(); + + try { + auto response = RunAsync([&]() -> net::awaitable { + co_return co_await client.get(BaseUrl() + "/test"); + }); + + auto end = std::chrono::steady_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_LT(duration.count(), kMaxResponseTimeMs); + } + catch (const std::exception& e) { + FAIL() << "HTTPS request failed: " << e.what(); + } +} + +TEST_F(HttpsRoutingTest, MultipleRequests) { + constexpr int kParallel = 10; + + net::io_context io; + std::vector> futures; + + for (int i = 0; i < kParallel; ++i) { + auto future = net::co_spawn( + io, + [&]() -> net::awaitable { + brazier::HttpClient client; + client.set_verify_ssl(false); + client.set_timeout(std::chrono::seconds(kClientTimeoutSec)); + co_return co_await client.get(BaseUrl() + "/test"); + }, + net::use_future); + futures.push_back(std::move(future)); + } + + std::thread io_thread([&io]() { io.run(); }); + + bool has_failures = false; + std::string first_error; + for (auto& future : futures) { + try { + auto response = future.get(); + EXPECT_EQ(response.result_int(), 200); + EXPECT_EQ(response.body(), "TestController show method called"); + } + catch (const std::exception& e) { + if (!has_failures) { + first_error = e.what(); + has_failures = true; + } + } + } + + io.stop(); + io_thread.join(); + + if (has_failures) { + FAIL() << "HTTPS request failed: " << first_error; + } +} + +TEST_F(HttpsRoutingTest, HstsHeaderPresent) { + auto client = MakeClient(); + + auto response = RunAsync([&]() -> net::awaitable { + co_return co_await client.get(BaseUrl() + "/test"); + }); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_TRUE(response.count(http::field::strict_transport_security)); + EXPECT_NE(response[http::field::strict_transport_security].find("max-age="), + std::string::npos); +} + +TEST_F(HttpsRoutingTest, RejectsTls11) { + EXPECT_FALSE(TryConnectWithTlsVersion(TLS1_1_VERSION)) + << "Server should reject TLS 1.1"; +} + +TEST_F(HttpsRoutingTest, AcceptsTls12) { + EXPECT_TRUE(TryConnectWithTlsVersion(TLS1_2_VERSION)) + << "Server should accept TLS 1.2"; +} + +TEST_F(HttpsRoutingTest, AcceptsTls13) { + EXPECT_TRUE(TryConnectWithTlsVersion(TLS1_3_VERSION)) + << "Server should accept TLS 1.3"; +} + +TEST_F(HttpsRoutingTest, ServesExpectedCertificate) { + net::io_context io; + ssl::context ctx(ssl::context::tls_client); + ctx.set_verify_mode(ssl::verify_none); + + ssl::stream stream(io, ctx); + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + + ASSERT_NO_THROW(net::connect(stream.next_layer(), results)); + ASSERT_NO_THROW(stream.handshake(ssl::stream_base::client)); + + X509* cert = SSL_get_peer_certificate(stream.native_handle()); + ASSERT_NE(cert, nullptr) << "Server did not present a certificate"; + + char cn[256] = { 0 }; + X509_NAME* subject = X509_get_subject_name(cert); + X509_NAME_get_text_by_NID(subject, NID_commonName, cn, sizeof(cn)); + + EXPECT_STREQ(cn, "localhost") + << "Certificate CN mismatch. Got: " << cn; + + X509_free(cert); +} + +TEST_F(HttpsRoutingTest, PayloadTooLarge) { + ASSERT_NE(g_test_https_server, nullptr); + const std::size_t limit = g_test_https_server->getMaxBodySize(); + const std::size_t body_size = limit * 4; + + tls_test::TlsClient c(https_host_global, https_port_global); + ASSERT_TRUE(c.connect()); + + std::string headers = + "POST /test/echo HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Content-Type: application/json\r\n" + "Content-Length: " + std::to_string(body_size) + "\r\n" + "Connection: close\r\n" + "\r\n"; + + ASSERT_TRUE(c.send_raw(headers)); + auto res = c.read_response(5); + + if (res.has_value()) { + EXPECT_EQ(res->result_int(), 413); + } + else { + EXPECT_TRUE(c.is_connection_closed(2)) + << "No response and connection not closed"; + } +} + +TEST_F(HttpsRoutingTest, HeaderTooLarge) { + ASSERT_NE(g_test_https_server, nullptr); + const std::size_t limit = g_test_https_server->getMaxHeaderSize(); + const std::size_t header_size = limit * 4; + + tls_test::TlsClient c(https_host_global, https_port_global); + ASSERT_TRUE(c.connect()); + + std::string huge(header_size, 'x'); + std::string req = + "GET /test HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "X-Huge: " + huge + "\r\n" + "Connection: close\r\n" + "\r\n"; + + ASSERT_TRUE(c.send_raw(req)); + + auto res = c.read_response(5); + bool closed = c.is_connection_closed(3); + + bool acceptable = (res.has_value() && + res->result_int() >= 400 && + res->result_int() < 500) + || (!res.has_value() && closed); + + EXPECT_TRUE(acceptable) + << "Expected 4xx or close, got has_value=" << res.has_value() + << ", status=" << (res.has_value() ? res->result_int() : 0); +} + +TEST_F(HttpsRoutingTest, ConnectionLimit) { + ASSERT_NE(g_test_https_server, nullptr); + const int limit = g_test_https_server->getMaxConnections(); + + if (limit <= 0 || limit > 100) { + GTEST_SKIP() << "max_connections=" << limit + << " is not suitable. Set http.max_connections_testing to 10..100."; + } + + std::vector> held; + held.reserve(limit); + + for (int i = 0; i < limit; ++i) { + auto c = std::make_unique( + https_host_global, https_port_global); + ASSERT_TRUE(c->connect()) + << "Handshake #" << i << " failed within limit " << limit; + held.push_back(std::move(c)); + } + + tls_test::TlsClient extra(https_host_global, https_port_global); + const bool connected = extra.connect(); + + if (connected) { + EXPECT_TRUE(extra.is_connection_closed(3)) + << "Server accepted connection beyond limit " << limit + << " and did not close it"; + } +} + +TEST_F(HttpsRoutingTest, ConnectionsReleasedAfterClose) { + ASSERT_NE(g_test_https_server, nullptr); + const int limit = g_test_https_server->getMaxConnections(); + + if (limit <= 0 || limit > 100) { + GTEST_SKIP() << "max_connections=" << limit + << " is not suitable. Set http.max_connections_testing to 10..100."; + } + + const int iterations = limit + 5; + + for (int i = 0; i < iterations; ++i) { + tls_test::TlsClient c(https_host_global, https_port_global); + ASSERT_TRUE(c.connect()) + << "Connection #" << i << " rejected — " + "ConnectionGuard likely not releasing the counter"; + + ASSERT_TRUE(c.send_raw( + "GET /test HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Connection: close\r\n" + "\r\n")); + + auto res = c.read_response(); + ASSERT_TRUE(res.has_value()) << "No response at iteration " << i; + EXPECT_EQ(res->result_int(), 200); + } +} + +TEST_F(HttpsRoutingTest, ConnectionsReleasedAfterProtocolError) { + ASSERT_NE(g_test_https_server, nullptr); + const int limit = g_test_https_server->getMaxConnections(); + + if (limit <= 0 || limit > 100) { + GTEST_SKIP() << "max_connections=" << limit + << " is not suitable. Set http.max_connections_testing to 10..100."; + } + + const int iterations = limit + 5; + + for (int i = 0; i < iterations; ++i) { + tls_test::TlsClient c(https_host_global, https_port_global); + ASSERT_TRUE(c.connect()) + << "Connection #" << i << " rejected after protocol errors — " + "ConnectionGuard likely not releasing on exceptions"; + + c.send_raw("this is not an HTTP request\r\n\r\n"); + c.is_connection_closed(2); + } +} \ No newline at end of file diff --git a/brazier/tests/unit/routing/routing_test.cpp b/brazier/tests/unit/routing/routing_test.cpp index 73a9564..7389bcb 100644 --- a/brazier/tests/unit/routing/routing_test.cpp +++ b/brazier/tests/unit/routing/routing_test.cpp @@ -76,26 +76,38 @@ auto RunAsync(AsyncOp&& op) { } bool IsServerRunning() { - boost::asio::io_context io_context; - boost::asio::ip::tcp::socket socket(io_context); - boost::asio::ip::tcp::endpoint endpoint( - boost::asio::ip::make_address(host_global), - port_global - ); - boost::system::error_code ec; - socket.connect(endpoint, ec); - return !ec; + try { + net::io_context io; + beast::tcp_stream stream(io); + tcp::resolver resolver(io); + auto results = resolver.resolve(host_global, std::to_string(port_global)); + + stream.expires_after(std::chrono::milliseconds(500)); + stream.connect(results); + return true; + } + catch (...) { + return false; + } } class RoutingTest : public ::testing::Test { protected: + static void SetUpTestSuite() { + server_available_ = IsServerRunning(); + } + void SetUp() override { - if (!IsServerRunning()) { + if (!server_available_) { GTEST_SKIP() << "Server is not running"; } } + + static bool server_available_; }; +bool RoutingTest::server_available_ = false; + TEST_F(RoutingTest, AddRouteAndGet) { std::string host = host_global; std::string port = std::to_string(port_global); diff --git a/brazier/tests/unit/routing/tls_test_client.hpp b/brazier/tests/unit/routing/tls_test_client.hpp new file mode 100644 index 0000000..69c6cf0 --- /dev/null +++ b/brazier/tests/unit/routing/tls_test_client.hpp @@ -0,0 +1,118 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +namespace ssl = net::ssl; +using tcp = net::ip::tcp; + +namespace tls_test { + + inline constexpr int kSocketTimeoutSec = 5; + + struct TlsClient { + net::io_context io; + ssl::context ctx; + std::unique_ptr> stream; + + TlsClient(const std::string& host, unsigned short port, + bool with_client_cert = false, + const std::string& cert = "", + const std::string& key = "") + : host_(host), port_(port) + , ctx(ssl::context::tls_client) { + ctx.set_verify_mode(ssl::verify_none); + if (with_client_cert) { + ctx.use_certificate_chain_file(cert); + ctx.use_private_key_file(key, ssl::context::pem); + } + } + + bool connect(int timeout_sec = kSocketTimeoutSec) { + try { + stream = std::make_unique>(io, ctx); + tcp::resolver resolver(io); + auto results = resolver.resolve(host_, std::to_string(port_)); + + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(timeout_sec)); + beast::get_lowest_layer(*stream).connect(results); + beast::get_lowest_layer(*stream).expires_never(); + stream->handshake(ssl::stream_base::client); + return true; + } + catch (const std::exception&) { + return false; + } + } + + bool send_raw(const std::string& data) { + try { + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + net::write(*stream, net::buffer(data)); + return true; + } + catch (const std::exception&) { + return false; + } + } + + std::optional> + read_response(int timeout_sec = kSocketTimeoutSec) { + try { + beast::flat_buffer buffer; + http::response res; + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(timeout_sec)); + http::read(*stream, buffer, res); + return res; + } + catch (const std::exception&) { + return std::nullopt; + } + } + + bool is_connection_closed(int timeout_sec = kSocketTimeoutSec) { + if (!stream) return true; + try { + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(timeout_sec)); + char c; + beast::error_code ec; + auto n = stream->read_some(net::buffer(&c, 1), ec); + + if (ec == net::error::eof || + ec == net::error::connection_reset || + ec == ssl::error::stream_truncated || + ec == beast::error::timeout) { + return true; + } + if (ec == net::error::timed_out) return false; + if (!ec && n > 0) return false; + return true; + } + catch (const std::exception&) { + return true; + } + } + + private: + std::string host_; + unsigned short port_; + }; + +} \ No newline at end of file diff --git a/brazier/tests/unit/security/https_mtls_test.cpp b/brazier/tests/unit/security/https_mtls_test.cpp new file mode 100644 index 0000000..73e2bd4 --- /dev/null +++ b/brazier/tests/unit/security/https_mtls_test.cpp @@ -0,0 +1,250 @@ +/* + * Copyright (c) 2026 Kirill Sergeev, Nikolay Sugonyako, Andrey Agarkov, Gleb Safyannikov + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * This file is part of brazier. + * + * brazier is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * brazier is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with brazier; if not, see . + */ + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include "../../../include/brazier/Core" +#include "../../../include/brazier/Http" +#include "main.h" + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +namespace ssl = net::ssl; +using tcp = net::ip::tcp; + +namespace { + + constexpr unsigned short kMtlsPort = 9443; + constexpr int kSocketTimeoutSec = 5; + + const std::string kCaCert = "certs/ca.crt"; + const std::string kServerCert = "certs/server.crt"; + const std::string kServerKey = "certs/server.key"; + const std::string kClientCert = "certs/client.crt"; + const std::string kClientKey = "certs/client.key"; + + bool FileExists(const std::string& path) { + std::ifstream f(path); + return f.good(); + } + + bool MtlsCertsPresent() { + return FileExists(kCaCert) && FileExists(kServerCert) && + FileExists(kServerKey) && FileExists(kClientCert) && + FileExists(kClientKey); + } + + struct MtlsClient { + net::io_context io; + ssl::context ctx; + std::unique_ptr> stream; + + explicit MtlsClient(const std::string& cert = "", + const std::string& key = "") + : ctx(ssl::context::tls_client) { + ctx.set_verify_mode(ssl::verify_none); + if (!cert.empty() && !key.empty()) { + ctx.use_certificate_chain_file(cert); + ctx.use_private_key_file(key, ssl::context::pem); + } + } + + bool connect(int timeout_sec = kSocketTimeoutSec) { + try { + stream = std::make_unique< + ssl::stream>(io, ctx); + + tcp::resolver resolver(io); + auto results = resolver.resolve( + "127.0.0.1", std::to_string(kMtlsPort)); + + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(timeout_sec)); + beast::get_lowest_layer(*stream).connect(results); + beast::get_lowest_layer(*stream).expires_never(); + + stream->handshake(ssl::stream_base::client); + return true; + } + catch (const std::exception&) { + return false; + } + } + + bool send_request(const std::string& target = "/test") { + try { + std::string req = + "GET " + target + " HTTP/1.1\r\n" + "Host: localhost\r\n" + "Connection: close\r\n" + "\r\n"; + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + net::write(*stream, net::buffer(req)); + return true; + } + catch (const std::exception&) { + return false; + } + } + + std::optional> read_response( + int timeout_sec = kSocketTimeoutSec) { + try { + beast::flat_buffer buffer; + http::response res; + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(timeout_sec)); + http::read(*stream, buffer, res); + return res; + } + catch (const std::exception&) { + return std::nullopt; + } + } + }; + +} + +class HttpsMtlsTest : public ::testing::Test { +protected: + static std::unique_ptr server_; + static std::thread thread_; + + static void SetUpTestSuite() { + if (!MtlsCertsPresent()) { + return; + } + + brazier::HttpsServer::TlsConfig tls; + tls.cert_file = kServerCert; + tls.key_file = kServerKey; + tls.ca_file = kCaCert; + tls.require_client_cert = true; + tls.verify_client_cert = true; + tls.handshake_timeout = std::chrono::seconds(3); + + server_ = std::make_unique( + "127.0.0.1", kMtlsPort, tls); + + if (!server_->initialize()) { + server_.reset(); + return; + } + + thread_ = std::thread([] { server_->run(); }); + + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + } + + static void TearDownTestSuite() { + if (server_) { + server_->stop(); + if (thread_.joinable()) thread_.join(); + } + } + + void SetUp() override { + if (!server_) { + GTEST_SKIP() << "mTLS test server not started " + "(certs missing in certs/)"; + } + } +}; + +std::unique_ptr HttpsMtlsTest::server_; +std::thread HttpsMtlsTest::thread_; + +TEST_F(HttpsMtlsTest, RejectsClientWithoutCert) { + MtlsClient c; + + if (c.connect()) { + c.send_request("/test"); + auto res = c.read_response(3); + EXPECT_FALSE(res.has_value()) + << "Server responded to request from client without certificate"; + } +} + +TEST_F(HttpsMtlsTest, AcceptsClientWithValidCert) { + MtlsClient c(kClientCert, kClientKey); + + ASSERT_TRUE(c.connect()) + << "Server must accept handshake with valid client certificate"; + + X509* peer = SSL_get1_peer_certificate(c.stream->native_handle()); + ASSERT_NE(peer, nullptr) + << "Server did not present its own certificate"; + + char cn[256] = { 0 }; + X509_NAME* subj = X509_get_subject_name(peer); + X509_NAME_get_text_by_NID(subj, NID_commonName, cn, sizeof(cn)); + EXPECT_STREQ(cn, "localhost") + << "Unexpected server CN: " << cn; + + X509_free(peer); +} + +TEST_F(HttpsMtlsTest, RequestAfterMtlsHandshake) { + MtlsClient c(kClientCert, kClientKey); + ASSERT_TRUE(c.connect()); + ASSERT_TRUE(c.send_request("/test")); + + auto res = c.read_response(); + ASSERT_TRUE(res.has_value()) << "No response after mTLS handshake"; + EXPECT_EQ(res->result_int(), 200); +} + +TEST_F(HttpsMtlsTest, ManySequentialMtlsHandshakes) { + for (int i = 0; i < 10; ++i) { + MtlsClient c(kClientCert, kClientKey); + ASSERT_TRUE(c.connect()) << "Handshake #" << i << " failed"; + + ASSERT_TRUE(c.send_request("/test")); + auto res = c.read_response(); + ASSERT_TRUE(res.has_value()) << "No response #" << i; + EXPECT_EQ(res->result_int(), 200); + } +} + +TEST_F(HttpsMtlsTest, NoRequestWithoutClientCert) { + MtlsClient c; + + if (c.connect()) { + c.send_request("/test"); + auto res = c.read_response(3); + EXPECT_FALSE(res.has_value()); + } +} \ No newline at end of file diff --git a/brazier/tests/unit/security/https_security_test.cpp b/brazier/tests/unit/security/https_security_test.cpp new file mode 100644 index 0000000..7bb895b --- /dev/null +++ b/brazier/tests/unit/security/https_security_test.cpp @@ -0,0 +1,547 @@ +/* + * Copyright (c) 2026 Kirill Sergeev, Nikolay Sugonyako, Andrey Agarkov, Gleb Safyannikov + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * This file is part of brazier. + * + * brazier is free software; you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * brazier is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with brazier; if not, see . + */ + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include + +#include "../../../include/brazier/Core" +#include "../../../include/brazier/Http" +#include "main.h" + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +namespace ssl = net::ssl; +using tcp = net::ip::tcp; + +namespace { + + int envInt(const char* name, int fallback) { + if (const char* v = std::getenv(name)) { + try { return std::stoi(v); } + catch (...) {} + } + return fallback; + } + + const int kHandshakeTimeoutSec = envInt("BRAZIER_HANDSHAKE_TIMEOUT", 15); + const int kIdleTimeoutSec = envInt("BRAZIER_IDLE_TIMEOUT", 60); + + constexpr int kMaxTestableTimeoutSec = 30; + constexpr int kSocketTimeoutSec = 5; + +} + +namespace { + + struct TlsClient { + net::io_context io; + ssl::context ctx; + std::unique_ptr> stream; + + explicit TlsClient(bool with_client_cert = false, + const std::string& cert = "", + const std::string& key = "") + : ctx(ssl::context::tls_client) { + ctx.set_verify_mode(ssl::verify_none); + if (with_client_cert) { + ctx.use_certificate_chain_file(cert); + ctx.use_private_key_file(key, ssl::context::pem); + } + } + + bool connect(int timeout_sec = kSocketTimeoutSec) { + try { + stream = std::make_unique>(io, ctx); + + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(timeout_sec)); + + beast::get_lowest_layer(*stream).connect(results); + + beast::get_lowest_layer(*stream).expires_never(); + stream->handshake(ssl::stream_base::client); + return true; + } + catch (const std::exception&) { + return false; + } + } + + bool send_raw(const std::string& data) { + try { + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + net::write(*stream, net::buffer(data)); + return true; + } + catch (const std::exception&) { + return false; + } + } + + std::optional> + read_response(int timeout_sec = kSocketTimeoutSec) { + try { + beast::flat_buffer buffer; + http::response res; + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(timeout_sec)); + http::read(*stream, buffer, res); + return res; + } + catch (const std::exception&) { + return std::nullopt; + } + } + + bool is_connection_closed(int timeout_sec = kSocketTimeoutSec) { + if (!stream) return true; + try { + beast::get_lowest_layer(*stream).expires_after( + std::chrono::seconds(timeout_sec)); + char c; + beast::error_code ec; + auto n = stream->read_some(net::buffer(&c, 1), ec); + + if (ec == net::error::eof || + ec == net::error::connection_reset || + ec == ssl::error::stream_truncated || + ec == beast::error::timeout) { + return true; + } + if (ec == net::error::timed_out) return false; + if (!ec && n > 0) return false; + return true; + } + catch (const std::exception&) { + return true; + } + } + }; + + bool ServerRequiresClientCert() { + TlsClient c; + return !c.connect(); + } + + bool ClientCertExists(const std::string& cert, const std::string& key) { + return std::ifstream(cert).good() && std::ifstream(key).good(); + } + + const std::string kClientCert = "certs/client.crt"; + const std::string kClientKey = "certs/client.key"; + +} + +class HttpsSecurityTest : public ::testing::Test { +protected: + static void SetUpTestSuite() { + TlsClient c; + server_available_ = c.connect(); + } + + void SetUp() override { + if (!server_available_) { + GTEST_SKIP() << "HTTPS server is not running on " + << https_host_global << ":" << https_port_global; + } + } + + static bool server_available_; +}; + +bool HttpsSecurityTest::server_available_ = false; + +TEST_F(HttpsSecurityTest, HandshakeWithoutClientCert) { + TlsClient c; + bool connected = c.connect(); + + if (ServerRequiresClientCert()) { + EXPECT_FALSE(connected) + << "Server requires client cert, but handshake succeeded without one"; + } + else { + EXPECT_TRUE(connected) + << "Server does not require client cert, but handshake failed"; + } +} + +TEST_F(HttpsSecurityTest, HandshakeTimeout) { + if (kHandshakeTimeoutSec > kMaxTestableTimeoutSec) { + GTEST_SKIP() << "Handshake timeout is " << kHandshakeTimeoutSec + << "s, skipping to keep test fast"; + } + + net::io_context io; + beast::tcp_stream stream(io); + + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + stream.connect(results); + + stream.expires_after(std::chrono::seconds(kHandshakeTimeoutSec + 5)); + + beast::error_code ec; + char c; + stream.read_some(net::buffer(&c, 1), ec); + + bool closed = + (ec == net::error::eof) || + (ec == net::error::connection_reset) || + (ec == beast::error::timeout); + + EXPECT_TRUE(closed) + << "Server did not close idle TCP within " + << (kHandshakeTimeoutSec + 5) << "s, ec=" << ec.message(); +} + +TEST_F(HttpsSecurityTest, IdleTimeout) { + if (kIdleTimeoutSec > kMaxTestableTimeoutSec) { + GTEST_SKIP() << "Idle timeout is " << kIdleTimeoutSec + << "s, skipping to keep test fast"; + } + + TlsClient c; + ASSERT_TRUE(c.connect()) << "Initial TLS handshake failed"; + + EXPECT_TRUE(c.is_connection_closed(kIdleTimeoutSec + 5)) + << "Server did not close idle TLS within " + << (kIdleTimeoutSec + 5) << "s"; +} + +TEST_F(HttpsSecurityTest, ClientInitiatedGracefulClose) { + { + TlsClient c; + ASSERT_TRUE(c.connect()); + ASSERT_TRUE(c.send_raw( + "GET /test HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Connection: close\r\n" + "\r\n")); + + auto res = c.read_response(); + if (res.has_value()) { + EXPECT_EQ(res->result_int(), 200); + EXPECT_EQ((*res)[http::field::connection], "close"); + } + + } + + TlsClient c2; + EXPECT_TRUE(c2.connect()) + << "Server is not accepting connections after graceful close"; +} + +TEST_F(HttpsSecurityTest, ServerSendsCloseNotify) { + TlsClient c; + ASSERT_TRUE(c.connect()); + ASSERT_TRUE(c.send_raw( + "GET /test HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Connection: close\r\n" + "\r\n")); + + auto res = c.read_response(); + if (res.has_value()) { + EXPECT_EQ(res->result_int(), 200); + EXPECT_EQ((*res)[http::field::connection], "close"); + } + + EXPECT_TRUE(c.is_connection_closed(5)) + << "Server did not close after 'Connection: close'"; +} + +TEST_F(HttpsSecurityTest, GarbageRequest) { + TlsClient c; + ASSERT_TRUE(c.connect()); + ASSERT_TRUE(c.send_raw("this is not an HTTP request at all\r\n\r\n")); + + auto res = c.read_response(3); + bool closed = c.is_connection_closed(3); + + bool acceptable = (res.has_value() && res->result_int() == 400) || + (!res.has_value() && closed); + EXPECT_TRUE(acceptable) + << "Expected 400 or close, got has_value=" << res.has_value() + << ", status=" << (res.has_value() ? res->result_int() : 0) + << ", closed=" << closed; +} + +TEST_F(HttpsSecurityTest, InvalidMethod) { + TlsClient c; + ASSERT_TRUE(c.connect()); + ASSERT_TRUE(c.send_raw( + "INVALID_METHOD_XYZ /test HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Connection: close\r\n" + "\r\n")); + + auto res = c.read_response(); + + if (res.has_value()) { + EXPECT_NE(res->result_int(), 500) + << "Server returned 500 for invalid method"; + } + else { + EXPECT_TRUE(c.is_connection_closed(2)) + << "No response and connection not closed"; + } +} + +TEST_F(HttpsSecurityTest, VeryLongUri) { + TlsClient c; + ASSERT_TRUE(c.connect()); + + std::string long_path = "/" + std::string(16 * 1024, 'a'); + ASSERT_TRUE(c.send_raw( + "GET " + long_path + " HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Connection: close\r\n" + "\r\n")); + + auto res = c.read_response(); + + if (res.has_value()) { + EXPECT_NE(res->result_int(), 200) + << "Server accepted 16KB URI as valid"; + EXPECT_NE(res->result_int(), 500) + << "Server returned 500 on long URI"; + } + else { + EXPECT_TRUE(c.is_connection_closed(2)) + << "No response and connection not closed"; + } +} + +TEST_F(HttpsSecurityTest, IncompleteHeaders) { + if (kIdleTimeoutSec > kMaxTestableTimeoutSec) { + GTEST_SKIP() << "Idle timeout is " << kIdleTimeoutSec + << "s, skipping to keep test fast. " + "Set BRAZIER_IDLE_TIMEOUT env or keep-alive-timeout in config."; + } + + TlsClient c; + ASSERT_TRUE(c.connect()); + ASSERT_TRUE(c.send_raw( + "GET /test HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n")); + + auto res = c.read_response(5); + bool closed = c.is_connection_closed(1); + + bool acceptable = (res.has_value() && res->result_int() == 400) || + (!res.has_value() && closed); + EXPECT_TRUE(acceptable) + << "Server neither responded 400 nor closed on incomplete headers"; +} + +TEST_F(HttpsSecurityTest, BodyWithZeroContentLength) { + TlsClient c; + ASSERT_TRUE(c.connect()); + ASSERT_TRUE(c.send_raw( + "POST /test/echo HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "\r\n" + "extra body that should be ignored")); + + auto res = c.read_response(); + if (res.has_value()) { + EXPECT_NE(res->result_int(), 500); + } +} + +TEST_F(HttpsSecurityTest, ManySequentialHandshakes) { + for (int i = 0; i < 20; ++i) { + TlsClient c; + ASSERT_TRUE(c.connect()) << "Handshake #" << i << " failed"; + } +} + +TEST_F(HttpsSecurityTest, AbruptClientDisconnect) { + { + TlsClient c; + ASSERT_TRUE(c.connect()); + } + + TlsClient c2; + EXPECT_TRUE(c2.connect()) << "Server not accepting after abrupt disconnect"; +} + +TEST_F(HttpsSecurityTest, SessionResumptionTls12) { + net::io_context io; + ssl::context ctx(ssl::context::tls_client); + ctx.set_verify_mode(ssl::verify_none); + ctx.set_options(ssl::context::no_tlsv1 + | ssl::context::no_tlsv1_1 + | ssl::context::no_tlsv1_3); + + std::unique_ptr + session(nullptr, &SSL_SESSION_free); + + { + ssl::stream stream(io, ctx); + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + + beast::get_lowest_layer(stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + beast::get_lowest_layer(stream).connect(results); + beast::get_lowest_layer(stream).expires_never(); + stream.handshake(ssl::stream_base::client); + + EXPECT_FALSE(SSL_session_reused(stream.native_handle())) + << "First handshake must be full"; + + std::string req = "GET /test HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Connection: close\r\n\r\n"; + net::write(stream, net::buffer(req)); + + beast::flat_buffer buf; + http::response res; + beast::get_lowest_layer(stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + http::read(stream, buf, res); + EXPECT_EQ(res.result_int(), 200); + + session.reset(SSL_get1_session(stream.native_handle())); + ASSERT_NE(session.get(), nullptr) + << "Server did not issue a session"; + + beast::error_code ec; + stream.shutdown(ec); + } + + { + ssl::stream stream(io, ctx); + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + + beast::get_lowest_layer(stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + beast::get_lowest_layer(stream).connect(results); + beast::get_lowest_layer(stream).expires_never(); + + ASSERT_EQ(SSL_set_session(stream.native_handle(), session.get()), 1) + << "SSL_set_session failed"; + + stream.handshake(ssl::stream_base::client); + + EXPECT_TRUE(SSL_session_reused(stream.native_handle())) + << "Second handshake should be abbreviated (TLS 1.2)"; + + beast::error_code ec; + stream.shutdown(ec); + } +} + +TEST_F(HttpsSecurityTest, SessionResumptionTls13) { + net::io_context io; + ssl::context ctx(ssl::context::tls_client); + ctx.set_verify_mode(ssl::verify_none); + ctx.set_options(ssl::context::no_tlsv1 + | ssl::context::no_tlsv1_1 + | ssl::context::no_tlsv1_2); + + std::unique_ptr + session(nullptr, &SSL_SESSION_free); + + { + ssl::stream stream(io, ctx); + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + + beast::get_lowest_layer(stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + beast::get_lowest_layer(stream).connect(results); + beast::get_lowest_layer(stream).expires_never(); + stream.handshake(ssl::stream_base::client); + + EXPECT_FALSE(SSL_session_reused(stream.native_handle())) + << "First handshake must be full"; + + std::string req = "GET /test HTTP/1.1\r\n" + "Host: " + https_host_global + "\r\n" + "Connection: close\r\n\r\n"; + net::write(stream, net::buffer(req)); + + beast::flat_buffer buf; + http::response res; + beast::get_lowest_layer(stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + http::read(stream, buf, res); + EXPECT_EQ(res.result_int(), 200); + + session.reset(SSL_get1_session(stream.native_handle())); + ASSERT_NE(session.get(), nullptr) + << "Server did not issue a session (ticket)"; + + beast::error_code ec; + stream.shutdown(ec); + } + + { + ssl::stream stream(io, ctx); + tcp::resolver resolver(io); + auto results = resolver.resolve(https_host_global, + std::to_string(https_port_global)); + + beast::get_lowest_layer(stream).expires_after( + std::chrono::seconds(kSocketTimeoutSec)); + beast::get_lowest_layer(stream).connect(results); + beast::get_lowest_layer(stream).expires_never(); + + ASSERT_EQ(SSL_set_session(stream.native_handle(), session.get()), 1) + << "SSL_set_session failed"; + + stream.handshake(ssl::stream_base::client); + + EXPECT_TRUE(SSL_session_reused(stream.native_handle())) + << "Second handshake should be abbreviated (TLS 1.3)"; + + beast::error_code ec; + stream.shutdown(ec); + } +} \ No newline at end of file diff --git a/brazier/tests/unit/tls/ticket_key_store_test.cpp b/brazier/tests/unit/tls/ticket_key_store_test.cpp new file mode 100644 index 0000000..c7fccb3 --- /dev/null +++ b/brazier/tests/unit/tls/ticket_key_store_test.cpp @@ -0,0 +1,358 @@ +#include + +#include "../../../include/brazier/TLS/TicketKeyStore.hpp" + +#include +#include + +#if OPENSSL_VERSION_NUMBER < 0x30000000L +# include +#endif + +#include +#include + +using brazier::TicketKeyStore; +using Clock = TicketKeyStore::Clock; + +namespace { + + struct FakeTime { + Clock::time_point t = Clock::time_point{}; + Clock::time_point operator()() const { return t; } + void advance(std::chrono::seconds s) { t += s; } + void advance(std::chrono::hours h) { t += h; } + }; + + struct CipherPair { + EVP_CIPHER_CTX* cipher = EVP_CIPHER_CTX_new(); + +#if OPENSSL_VERSION_NUMBER >= 0x30000000L + EVP_MAC* mac_alg = EVP_MAC_fetch(nullptr, "HMAC", nullptr); + EVP_MAC_CTX* mac = mac_alg ? EVP_MAC_CTX_new(mac_alg) : nullptr; + + ~CipherPair() { + EVP_CIPHER_CTX_free(cipher); + EVP_MAC_CTX_free(mac); + EVP_MAC_free(mac_alg); + } +#else + HMAC_CTX* mac = HMAC_CTX_new(); + + ~CipherPair() { + EVP_CIPHER_CTX_free(cipher); + HMAC_CTX_free(mac); + } +#endif + + CipherPair() = default; + CipherPair(const CipherPair&) = delete; + CipherPair& operator=(const CipherPair&) = delete; + }; + +} + +TEST(TicketKeyStore, InitiallyEmpty) { + TicketKeyStore store; + EXPECT_EQ(store.key_count(), 0u); + EXPECT_TRUE(store.snapshot().empty()); +} + +TEST(TicketKeyStore, EnsureInitializedCreatesOneKey) { + TicketKeyStore store; + store.ensure_initialized(); + ASSERT_EQ(store.key_count(), 1u); + store.ensure_initialized(); + EXPECT_EQ(store.key_count(), 1u); +} + +TEST(TicketKeyStore, EnsureInitializedSetsTimestamp) { + FakeTime ft; + TicketKeyStore store([&] { return ft(); }); + + ft.advance(std::chrono::hours(100)); + store.ensure_initialized(); + + const auto snap = store.snapshot(); + ASSERT_EQ(snap.size(), 1u); + EXPECT_EQ(snap[0].created_at, ft.t); +} + +TEST(TicketKeyStore, EncryptReturnsOne) { + TicketKeyStore store; + unsigned char name[16] = {}; + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + CipherPair cp; + + EXPECT_EQ(store.handle(name, iv, cp.cipher, cp.mac, 1), 1); +} + +TEST(TicketKeyStore, EncryptFillsNameAndIv) { + TicketKeyStore store; + unsigned char name[16] = {}; + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + CipherPair cp; + + ASSERT_EQ(store.handle(name, iv, cp.cipher, cp.mac, 1), 1); + + const auto snap = store.snapshot(); + ASSERT_EQ(snap.size(), 1u); + EXPECT_EQ(std::memcmp(name, snap[0].name, 16), 0); + + bool all_zero = true; + for (auto b : iv) if (b != 0) { all_zero = false; break; } + EXPECT_FALSE(all_zero); +} + +TEST(TicketKeyStore, DecryptWithCurrentKeyReturnsOne) { + TicketKeyStore store; + unsigned char name[16] = {}; + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + CipherPair cp1; + + ASSERT_EQ(store.handle(name, iv, cp1.cipher, cp1.mac, 1), 1); + + CipherPair cp2; + EXPECT_EQ(store.handle(name, iv, cp2.cipher, cp2.mac, 0), 1); +} + +TEST(TicketKeyStore, DecryptWithUnknownKeyReturnsZero) { + TicketKeyStore store; + store.ensure_initialized(); + + unsigned char name[16]; + std::memset(name, 0xFF, sizeof(name)); + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + CipherPair cp; + + EXPECT_EQ(store.handle(name, iv, cp.cipher, cp.mac, 0), 0); +} + +TEST(TicketKeyStore, NoRotationBeforeInterval) { + FakeTime ft; + TicketKeyStore store([&] { return ft(); }); + store.ensure_initialized(); + + unsigned char name[16] = {}; + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + + ft.advance(std::chrono::hours(11)); + CipherPair cp; + ASSERT_EQ(store.handle(name, iv, cp.cipher, cp.mac, 1), 1); + EXPECT_EQ(store.key_count(), 1u); +} + +TEST(TicketKeyStore, RotationAfterInterval) { + FakeTime ft; + TicketKeyStore store([&] { return ft(); }); + store.ensure_initialized(); + + unsigned char first_raw[16]; + std::memcpy(first_raw, store.snapshot()[0].name, 16); + + ft.advance(std::chrono::hours(13)); + + unsigned char name[16] = {}; + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + CipherPair cp; + ASSERT_EQ(store.handle(name, iv, cp.cipher, cp.mac, 1), 1); + + ASSERT_EQ(store.key_count(), 2u); + const auto snap = store.snapshot(); + EXPECT_EQ(std::memcmp(name, snap[0].name, 16), 0); + EXPECT_EQ(std::memcmp(first_raw, snap[1].name, 16), 0); +} + +TEST(TicketKeyStore, RotateNowForcesRotation) { + TicketKeyStore store; + store.ensure_initialized(); + ASSERT_EQ(store.key_count(), 1u); + + unsigned char old_name[16]; + std::memcpy(old_name, store.snapshot()[0].name, 16); + + store.rotate_now(); + + ASSERT_EQ(store.key_count(), 2u); + const auto snap = store.snapshot(); + EXPECT_NE(std::memcmp(old_name, snap[0].name, 16), 0); + EXPECT_EQ(std::memcmp(old_name, snap[1].name, 16), 0); +} + +TEST(TicketKeyStore, OldKeyReturnsTwo) { + FakeTime ft; + TicketKeyStore store([&] { return ft(); }); + store.ensure_initialized(); + + unsigned char old_name[16]; + std::memcpy(old_name, store.snapshot()[0].name, 16); + + ft.advance(std::chrono::hours(13)); + store.rotate_now(); + ASSERT_EQ(store.key_count(), 2u); + + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + CipherPair cp; + EXPECT_EQ(store.handle(old_name, iv, cp.cipher, cp.mac, 0), 2); +} + +TEST(TicketKeyStore, CurrentKeyStillReturnsOneAfterRotation) { + FakeTime ft; + TicketKeyStore store([&] { return ft(); }); + store.ensure_initialized(); + + ft.advance(std::chrono::hours(13)); + store.rotate_now(); + + unsigned char current_name[16]; + std::memcpy(current_name, store.snapshot()[0].name, 16); + + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + CipherPair cp; + EXPECT_EQ(store.handle(current_name, iv, cp.cipher, cp.mac, 0), 1); +} + +TEST(TicketKeyStore, KeysOlderThanLifetimeAreRemoved) { + FakeTime ft; + TicketKeyStore store([&] { return ft(); }); + store.ensure_initialized(); + + for (int i = 0; i < 5; ++i) { + ft.advance(std::chrono::hours(13)); + store.rotate_now(); + } + + const auto snap = store.snapshot(); + EXPECT_LE(snap.size(), 4u); + EXPECT_GE(snap.size(), 2u); + + for (const auto& k : snap) { + EXPECT_LT(ft.t - k.created_at, TicketKeyStore::kKeyLifetime); + } +} + +TEST(TicketKeyStore, OldestKeyTimestampOrder) { + FakeTime ft; + TicketKeyStore store([&] { return ft(); }); + store.ensure_initialized(); + + ft.advance(std::chrono::hours(13)); + store.rotate_now(); + ft.advance(std::chrono::hours(13)); + store.rotate_now(); + + const auto snap = store.snapshot(); + ASSERT_GE(snap.size(), 3u); + + for (std::size_t i = 1; i < snap.size(); ++i) { + EXPECT_GE(snap[i - 1].created_at, snap[i].created_at); + } +} + +TEST(TicketKeyStore, TwoStoresHaveDifferentKeys) { + TicketKeyStore a; + TicketKeyStore b; + a.ensure_initialized(); + b.ensure_initialized(); + + const auto sa = a.snapshot(); + const auto sb = b.snapshot(); + ASSERT_EQ(sa.size(), 1u); + ASSERT_EQ(sb.size(), 1u); + + EXPECT_NE(std::memcmp(sa[0].name, sb[0].name, 16), 0); +} + +TEST(TicketKeyStore, KeyFromOneStoreNotAcceptedByOther) { + TicketKeyStore a; + TicketKeyStore b; + a.ensure_initialized(); + b.ensure_initialized(); + + unsigned char name[16] = {}; + unsigned char iv[EVP_MAX_IV_LENGTH] = {}; + CipherPair cp1; + ASSERT_EQ(a.handle(name, iv, cp1.cipher, cp1.mac, 1), 1); + + CipherPair cp2; + EXPECT_EQ(b.handle(name, iv, cp2.cipher, cp2.mac, 0), 0); +} + +TEST(TicketKeyStore, AttachRegistersStoreInExData) { + SSL_CTX* ctx = SSL_CTX_new(TLS_server_method()); + ASSERT_NE(ctx, nullptr); + + TicketKeyStore store; + store.ensure_initialized(); + + store.attach_to(ctx); + + const int idx = brazier::ticket_store_ex_index(); + ASSERT_GE(idx, 0); + + void* raw = SSL_CTX_get_ex_data(ctx, idx); + EXPECT_EQ(static_cast(raw), &store); + + SSL_CTX_free(ctx); +} + +TEST(TicketKeyStore, AttachIsIdempotentForSameStore) { + SSL_CTX* ctx = SSL_CTX_new(TLS_server_method()); + ASSERT_NE(ctx, nullptr); + + TicketKeyStore store; + store.ensure_initialized(); + + store.attach_to(ctx); + EXPECT_NO_THROW(store.attach_to(ctx)); + + SSL_CTX_free(ctx); +} + +TEST(TicketKeyStore, AttachThrowsIfDifferentStoreAlreadyAttached) { + SSL_CTX* ctx = SSL_CTX_new(TLS_server_method()); + ASSERT_NE(ctx, nullptr); + + TicketKeyStore a; + TicketKeyStore b; + a.ensure_initialized(); + b.ensure_initialized(); + + a.attach_to(ctx); + EXPECT_THROW(b.attach_to(ctx), std::runtime_error); + + SSL_CTX_free(ctx); +} + +TEST(TicketKeyStore, AttachThrowsOnNullCtx) { + TicketKeyStore store; + EXPECT_THROW(store.attach_to(nullptr), std::invalid_argument); +} + +TEST(TicketKeyStore, TwoContextsUseSeparateStores) { + SSL_CTX* ctx_a = SSL_CTX_new(TLS_server_method()); + SSL_CTX* ctx_b = SSL_CTX_new(TLS_server_method()); + ASSERT_NE(ctx_a, nullptr); + ASSERT_NE(ctx_b, nullptr); + + TicketKeyStore a; + TicketKeyStore b; + a.ensure_initialized(); + b.ensure_initialized(); + + a.attach_to(ctx_a); + b.attach_to(ctx_b); + + const int idx = brazier::ticket_store_ex_index(); + EXPECT_EQ(SSL_CTX_get_ex_data(ctx_a, idx), &a); + EXPECT_EQ(SSL_CTX_get_ex_data(ctx_b, idx), &b); + + SSL_CTX_free(ctx_a); + SSL_CTX_free(ctx_b); +} + +TEST(TicketKeyStore, NullNowProviderThrows) { + EXPECT_THROW( + TicketKeyStore(std::function{}), + std::invalid_argument); +} \ No newline at end of file