From 3c016acc442b59ab3db8f39ab42e859b55a81490 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Meusel?= Date: Tue, 17 Oct 2023 22:21:23 +0200 Subject: [PATCH] Rewrite ./botan tls_http_server w/ boost beast and coroutines Co-Authored-By: Fabian Albert --- src/cli/tls_http_server.cpp | 542 ++++++++++++------------------------ src/scripts/test_cli.py | 15 +- 2 files changed, 194 insertions(+), 363 deletions(-) diff --git a/src/cli/tls_http_server.cpp b/src/cli/tls_http_server.cpp index 3135dd8e583..77d9aab4f6a 100644 --- a/src/cli/tls_http_server.cpp +++ b/src/cli/tls_http_server.cpp @@ -1,7 +1,11 @@ /* -* (C) 2014,2015,2017,2019 Jack Lloyd +* (C) 2014,2015,2017,2019,2023 Jack Lloyd * (C) 2016 Matthias Gierlings -* (C) 2023 René Meusel, Rohde & Schwarz Cybersecurity +* (C) 2023 Fabian Albert, René Meusel - Rohde & Schwarz Cybersecurity +* +* This implementation is roughly based on this BSL-licensed example +* by Klemens D. Morgenstern: +* www.boost.org/doc/libs/1_83_0/libs/beast/example/http/server/awaitable/http_server_awaitable.cpp * * Botan is released under the Simplified BSD License (see license.txt) */ @@ -9,29 +13,31 @@ #include "cli.h" #if defined(BOTAN_HAS_TLS) && defined(BOTAN_HAS_BOOST_ASIO) && defined(BOTAN_TARGET_OS_HAS_FILESYSTEM) + #include +#endif + +// If your boost version is too old, this might not be defined despite +// your toolchain supporting C++20 co_await. +#if defined(BOOST_ASIO_HAS_CO_AWAIT) - #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 #if defined(BOTAN_HAS_TLS_SQLITE3_SESSION_MANAGER) #include @@ -43,305 +49,51 @@ namespace Botan_CLI { namespace { -using boost::asio::ip::tcp; - -template -boost::asio::io_context& get_io_service(T& s) { - #if BOOST_VERSION >= 107000 - return static_cast((s).get_executor().context()); - #else - return s.get_io_service(); - #endif -} - -inline void log_error(const char* msg) { - std::cout << msg << std::endl; -} - -inline void log_exception(const char* where, const std::exception& e) { - std::cout << where << ' ' << e.what() << std::endl; -} - -class ServerStatus { - public: - ServerStatus(size_t max_clients) : m_max_clients(max_clients), m_clients_serviced(0) {} - - bool should_exit() const { - if(m_max_clients == 0) { - return false; - } - - return clients_serviced() >= m_max_clients; - } - - void client_serviced() { m_clients_serviced++; } +namespace beast = boost::beast; // from +namespace http = beast::http; // from +namespace net = boost::asio; // from +using tcp = boost::asio::ip::tcp; // from - size_t clients_serviced() const { return m_clients_serviced.load(); } +using tcp_stream = typename beast::tcp_stream::rebind_executor< + net::use_awaitable_t<>::executor_with_default>::other; +class Logger final { private: - size_t m_max_clients; - std::atomic m_clients_serviced; -}; - -/* -* This is an incomplete and highly buggy HTTP request parser. It is just -* barely sufficient to handle a GET request sent by a browser. -*/ -class HTTP_Parser final { - public: - class Request { - public: - const std::string& verb() const { return m_verb; } - - const std::string& location() const { return m_location; } - - const std::map& headers() const { return m_headers; } - - Request(const std::string& verb, - const std::string& location, - const std::map& headers) : - m_verb(verb), m_location(location), m_headers(headers) {} - - private: - std::string m_verb; - std::string m_location; - std::map m_headers; - }; - - class Callbacks { - public: - virtual void handle_http_request(const Request& request) = 0; - - virtual ~Callbacks() = default; - - Callbacks() = default; - - Callbacks(const Callbacks& other) = delete; - Callbacks(Callbacks&& other) = delete; - Callbacks& operator=(const Callbacks& other) = delete; - Callbacks& operator=(Callbacks&&) = delete; - }; - - HTTP_Parser(Callbacks& cb) : m_cb(cb) {} - - void consume_input(std::span buf) { - m_req_buf.append(reinterpret_cast(buf.data()), buf.size()); - - std::istringstream strm(m_req_buf); + auto timestamp() const { + const auto t = std::time(nullptr); + struct tm tm; - std::string http_version; - std::string verb; - std::string location; - std::map headers; - - strm >> verb >> location >> http_version; - - if(verb.empty() || location.empty()) { - return; - } - - while(true) { - std::string header_line; - std::getline(strm, header_line); - - if(header_line == "\r") { - continue; - } - - auto delim = header_line.find(": "); - if(delim == std::string::npos) { - break; - } - - const std::string hdr_name = header_line.substr(0, delim); - const std::string hdr_val = header_line.substr(delim + 2, std::string::npos); - - headers[hdr_name] = hdr_val; - - if(headers.size() > 1024) { - throw Botan::Invalid_Argument("Too many HTTP headers sent in request"); - } - } + #if defined(BOTAN_BUILD_COMPILER_IS_MSVC) || defined(BOTAN_TARGET_OS_IS_MINGW) || \ + defined(BOTAN_TARGET_OS_IS_CYGWIN) || defined(BOTAN_TARGET_OS_IS_WINDOWS) + localtime_s(&tm, &t); + #else + localtime_r(&t, &tm); + #endif - if(!verb.empty() && !location.empty()) { - Request req(verb, location, headers); - m_cb.handle_http_request(req); - m_req_buf.clear(); - } + return std::put_time(&tm, "%c"); } - private: - Callbacks& m_cb; - std::string m_req_buf; -}; - -const size_t READBUF_SIZE = 4096; - -class TLS_Asio_HTTP_Session final : public std::enable_shared_from_this, - public Botan::TLS::Callbacks, - public HTTP_Parser::Callbacks { public: - typedef std::shared_ptr pointer; - - static pointer create(boost::asio::io_service& io, - const std::shared_ptr& session_manager, - const std::shared_ptr& credentials, - const std::shared_ptr& policy) { - auto session = std::make_shared(io); + Logger(std::ostream& out, std::ostream& err) : m_out(out), m_err(err) {} - // Defer the setup of the TLS server to make use of - // shared_from_this() which wouldn't work in the c'tor. - session->setup(session_manager, credentials, policy); - - return session; + void log(std::string_view out) { + std::scoped_lock lk(m_mutex); + m_out << Botan::fmt("[{}] {}", timestamp(), out) << std::endl; } - tcp::socket& client_socket() { return m_client_socket; } - - void start() { - m_c2s.resize(READBUF_SIZE); - client_read(boost::system::error_code(), 0); // start read loop - } - - void stop() { - if(!m_tls) { - // Server is already closed - return; - } - - m_tls->close(); - - // Need to explicitly destroy the TLS::Server object to break the - // circular ownership of shared_from_this() and the shared_ptr of - // this kept inside the TLS::Channel. - m_tls.reset(); + void error(std::string_view err) { + std::scoped_lock lk(m_mutex); + m_err << Botan::fmt("[{}] {}", timestamp(), err) << std::endl; } - TLS_Asio_HTTP_Session(boost::asio::io_service& io) : m_strand(io), m_client_socket(io), m_rng(cli_make_rng()) {} - private: - void setup(const std::shared_ptr& session_manager, - const std::shared_ptr& credentials, - const std::shared_ptr& policy) { - m_tls = std::make_unique(shared_from_this(), session_manager, credentials, policy, m_rng); - } - - void client_read(const boost::system::error_code& error, size_t bytes_transferred) { - if(error) { - return stop(); - } - - if(!m_tls) { - log_error("Received client data after close"); - return; - } - - try { - m_tls->received_data(&m_c2s[0], bytes_transferred); - } catch(Botan::Exception& e) { - log_exception("TLS connection failed", e); - return stop(); - } - if(m_tls->is_closed_for_reading()) { - return stop(); - } - - m_client_socket.async_read_some(boost::asio::buffer(&m_c2s[0], m_c2s.size()), - m_strand.wrap(boost::bind(&TLS_Asio_HTTP_Session::client_read, - shared_from_this(), - boost::asio::placeholders::error, - boost::asio::placeholders::bytes_transferred))); - } - - void handle_client_write_completion(const boost::system::error_code& error) { - if(error) { - return stop(); - } - - m_s2c.clear(); - - if(m_s2c_pending.empty() && (!m_tls || m_tls->is_closed_for_writing())) { - m_client_socket.close(); - } - tls_emit_data({}); // initiate another write if needed - } - - std::string tls_server_choose_app_protocol(const std::vector& /*client_protos*/) override { - return "http/1.1"; - } - - void tls_record_received(uint64_t /*rec_no*/, std::span buf) override { - if(!m_http_parser) { - m_http_parser = std::make_unique(*this); - } - - m_http_parser->consume_input(buf); - } - - std::string summarize_request(const HTTP_Parser::Request& request) { - std::ostringstream strm; - - strm << "Client " << client_socket().remote_endpoint().address().to_string() << " requested " << request.verb() - << " " << request.location() << "\n"; - - if(request.headers().empty() == false) { - strm << "Client HTTP headers:\n"; - for(const auto& kv : request.headers()) { - strm << " " << kv.first << ": " << kv.second << "\n"; - } - } - - return strm.str(); - } - - void handle_http_request(const HTTP_Parser::Request& request) override { - if(!m_tls) { - log_error("Received client data after close"); - return; - } - std::ostringstream response; - if(request.verb() == "GET") { - if(request.location() == "/" || request.location() == "/status") { - const std::string http_summary = summarize_request(request); - - const std::string report = m_connection_summary + m_session_summary + m_chello_summary + http_summary; - - response << "HTTP/1.0 200 OK\r\n"; - response << "Server: " << Botan::version_string() << "\r\n"; - response << "Content-Type: text/plain\r\n"; - response << "Content-Length: " << report.size() << "\r\n"; - response << "\r\n"; - - response << report; - } else { - response << "HTTP/1.0 404 Not Found\r\n\r\n"; - } - } else { - response << "HTTP/1.0 405 Method Not Allowed\r\n\r\n"; - } - - const std::string response_str = response.str(); - m_tls->send(response_str); - m_tls->close(); - } - - void tls_emit_data(std::span buf) override { - if(!buf.empty()) { - m_s2c_pending.insert(m_s2c_pending.end(), buf.begin(), buf.end()); - } - - // no write now active and we still have output pending - if(m_s2c.empty() && !m_s2c_pending.empty()) { - std::swap(m_s2c_pending, m_s2c); - - boost::asio::async_write(m_client_socket, - boost::asio::buffer(&m_s2c[0], m_s2c.size()), - m_strand.wrap(boost::bind(&TLS_Asio_HTTP_Session::handle_client_write_completion, - shared_from_this(), - boost::asio::placeholders::error))); - } - } + std::mutex m_mutex; + std::ostream& m_out; + std::ostream& m_err; +}; +class TlsHttpCallbacks final : public Botan::TLS::StreamCallbacks { + public: void tls_session_activated() override { std::ostringstream strm; @@ -393,79 +145,147 @@ class TLS_Asio_HTTP_Session final : public std::enable_shared_from_thisclose(); - } else { - std::cout << "Alert " << alert.type_string() << std::endl; - } + std::string summary() const { + BOTAN_STATE_CHECK(!m_connection_summary.empty() && !m_session_summary.empty() && !m_chello_summary.empty()); + return m_connection_summary + m_session_summary + m_chello_summary; } - boost::asio::io_service::strand m_strand; - - tcp::socket m_client_socket; - - std::shared_ptr m_rng; - std::unique_ptr m_tls; + private: std::string m_chello_summary; std::string m_connection_summary; std::string m_session_summary; - std::unique_ptr m_http_parser; - - std::vector m_c2s; - std::vector m_s2c; - std::vector m_s2c_pending; }; -class TLS_Asio_HTTP_Server final { - public: - typedef TLS_Asio_HTTP_Session session; - - TLS_Asio_HTTP_Server(boost::asio::io_service& io, - unsigned short port, - std::shared_ptr creds, - std::shared_ptr policy, - std::shared_ptr session_mgr, - size_t max_clients) : - m_acceptor(io, tcp::endpoint(tcp::v4(), port)), - m_creds(std::move(creds)), - m_policy(std::move(policy)), - m_session_manager(std::move(session_mgr)), - m_status(max_clients) { - serve_one_session(); +std::string summarize_request(const Botan::TLS::Stream& tls_stream, + const http::request& req) { + std::ostringstream strm; + + const auto& remote = tls_stream.next_layer().socket().remote_endpoint(); + + strm << "Client " << remote.address().to_string() << " requested " << req.method_string() << " " << req.target() + << "\n"; + + if(std::distance(req.begin(), req.end()) > 0) { + strm << "Client HTTP headers:\n"; + for(const auto& header : req) { + strm << " " << header.name_string() << ": " << header.value() << "\n"; } + } - private: - void serve_one_session() { - auto new_session = session::create(get_io_service(m_acceptor), m_session_manager, m_creds, m_policy); + return strm.str(); +} - m_acceptor.async_accept( - new_session->client_socket(), - boost::bind(&TLS_Asio_HTTP_Server::handle_accept, this, new_session, boost::asio::placeholders::error)); +auto make_final_completion_handler(const std::shared_ptr& logger, const std::string& context) { + return [=](std::exception_ptr e) { + if(e) { + try { + std::rethrow_exception(std::move(e)); + } catch(const std::exception& ex) { + logger->error(Botan::fmt("{}: {}", context, ex.what())); + } } + }; +} - void handle_accept(const session::pointer& new_session, const boost::system::error_code& error) { - if(!error) { - new_session->start(); - m_status.client_serviced(); +std::shared_ptr> handle_response(const http::request& req, + const std::shared_ptr& callbacks, + const Botan::TLS::Stream& tls_stream, + const std::shared_ptr& logger) { + logger->log(Botan::fmt("{} {}", req.method_string(), req.target())); + + auto response = [&] { + if(req.method() != http::verb::get) { + auto resp = + std::make_shared>(http::status::method_not_allowed, req.version()); + resp->body() = std::string("Unsupported HTTP verb\n"); + return resp; + } else if(req.target() == "/" || req.target() == "/status") { + auto resp = std::make_shared>(http::status::ok, req.version()); + resp->body() = callbacks->summary() + summarize_request(tls_stream, req); + return resp; + } else { + auto resp = std::make_shared>(http::status::not_found, req.version()); + resp->body() = std::string("Not found"); + return resp; + } + }(); - if(!m_status.should_exit()) { - serve_one_session(); - } + response->set(http::field::content_type, "text/plain"); + response->prepare_payload(); + response->keep_alive(req.keep_alive()); + + return response; +} + +net::awaitable do_session(tcp_stream stream, + std::shared_ptr tls_ctx, + std::shared_ptr logger) { + // This buffer is required to persist across reads + beast::flat_buffer buffer; + + // Set up Botan's TLS stack + auto callbacks = std::make_shared(); + Botan::TLS::Stream tls_stream(std::move(stream), std::move(tls_ctx), callbacks); + + try { + // Perform a TLS handshake with the peer + co_await tls_stream.async_handshake(Botan::TLS::Connection_Side::Server); + + while(true) { + // Set the timeout. + tls_stream.next_layer().expires_after(std::chrono::seconds(30)); + + // Read a request + http::request req; + co_await http::async_read(tls_stream, buffer, req); + + // Handle the request + auto response = handle_response(req, callbacks, tls_stream, logger); + + // Send the response + co_await http::async_write(tls_stream, *response); + + // Determine if we should close the connection + if(!response->keep_alive()) { + // This means we should close the connection, usually because + // the response indicated the "Connection: close" semantic. + break; } } + } catch(boost::system::system_error& se) { + if(se.code() != http::error::end_of_stream) { + throw; + } + } - tcp::acceptor m_acceptor; + // Shut down the connection gracefully + co_await tls_stream.async_shutdown(); + beast::error_code ec; + tls_stream.next_layer().socket().shutdown(tcp::socket::shutdown_send, ec); - std::shared_ptr m_creds; - std::shared_ptr m_policy; - std::shared_ptr m_session_manager; - ServerStatus m_status; -}; + // At this point the connection is closed gracefully + // we ignore the error because the client might have + // dropped the connection already. +} + +net::awaitable do_listen(tcp::endpoint endpoint, + std::shared_ptr tls_ctx, + size_t max_clients, + std::shared_ptr logger) { + auto acceptor = net::use_awaitable.as_default_on(tcp::acceptor(co_await net::this_coro::executor)); + acceptor.open(endpoint.protocol()); + acceptor.set_option(net::socket_base::reuse_address(true)); + acceptor.bind(endpoint); + acceptor.listen(net::socket_base::max_listen_connections); + + // If max_clients is zero in the beginning, we'll serve forever + // otherwise we'll count down and stop eventually. + do { + boost::asio::co_spawn(acceptor.get_executor(), + do_session(tcp_stream(co_await acceptor.async_accept()), tls_ctx, logger), + make_final_completion_handler(logger, "Session")); + } while(max_clients == 0 || --max_clients > 0); +} } // namespace @@ -522,9 +342,17 @@ class TLS_HTTP_Server final : public Command { session_mgr.reset(new Botan::TLS::Session_Manager_In_Memory(rng_as_shared())); } - boost::asio::io_service io; - - TLS_Asio_HTTP_Server server(io, listen_port, creds, policy, session_mgr, max_clients); + auto logger = std::make_shared(output(), error_output()); + + net::io_context io{static_cast(num_threads)}; + auto address = net::ip::make_address("0.0.0.0"); + boost::asio::co_spawn( + io, + do_listen(tcp::endpoint{address, listen_port}, + std::make_shared(creds, rng_as_shared(), session_mgr, policy), + max_clients, + logger), + make_final_completion_handler(logger, "Acceptor")); std::vector> threads; diff --git a/src/scripts/test_cli.py b/src/scripts/test_cli.py index e6568cf943e..13fe9311d9c 100755 --- a/src/scripts/test_cli.py +++ b/src/scripts/test_cli.py @@ -1260,7 +1260,7 @@ def cli_tls_http_server_tests(tmp_dir): context.maximum_version = tls_version conn = HTTPSConnection('localhost', port=server_port, context=context) - conn.request("GET", "/") + conn.request("GET", "/", headers={"Connection": "close"}) resp = conn.getresponse() if resp.status != 200: @@ -1271,16 +1271,19 @@ def cli_tls_http_server_tests(tmp_dir): if body.find('TLS negotiation with Botan 3.') < 0: logging.error('Unexpected response body %s', body) - conn.request("POST", "/logout") + conn.request("POST", "/logout", headers={"Connection": "close"}) resp = conn.getresponse() if resp.status != 405: logging.error('Unexpected response status %d', resp.status) - rc = tls_server.wait(5) - - if rc != 0: - logging.error("Unexpected return code from https_server %d", rc) + try: + rc = tls_server.wait(5) + if rc != 0: + logging.error("Unexpected return code from https_server %d", rc) + except subprocess.TimeoutExpired: + logging.error("server did not terminate as expected") + tls_server.kill() def cli_tls_proxy_tests(tmp_dir): if not run_socket_tests() or not check_for_command("tls_proxy"):