Search code examples
c++boostc++17boost-beast

Boost.beast http::read() returns `bad version`


Based on the two official examples /example/http/client/sync and /example/http/client/sync-ssl1 I am trying to build an HTTP client class that is able to handle both HTTP and HTTPS. As I am fairly new to boost.beast I reached out to figure out what the best/intended way of doing this is. It turned out that using a polymorphic approach with an abstract transport class should work well:

My polymorphic transport class design

I've implemented my client and the client_transport class based on the two examples. Making play text requests (HTTP) works well. However, when I set my encryption mode to encryption::tls the http::read() function returns a bad version error. I can successfully run the official sync-ssl example on my machine which indicates to me that dependencies such as OpenSSL shouldn't play a role here but that instead I simply overlooked something. Unfortunately, I didn't make much progress in figuring this out so I'd appreciate any kind of help.

main.cpp:

#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl/error.hpp>
#include <cstdlib>
#include <iostream>
#include <string>
#include "http/client.hpp"

namespace beast = boost::beast; // from <boost/beast.hpp>
namespace http = beast::http;   // from <boost/beast/http.hpp>
namespace net = boost::asio;    // from <boost/asio.hpp>
namespace ssl = net::ssl;       // from <boost/asio/ssl.hpp>
using tcp = net::ip::tcp;       // from <boost/asio/ip/tcp.hpp>

// Performs an HTTP GET and prints the response
int main(int argc, char** argv)
{
    // Check command line arguments.
    if(argc != 4 && argc != 5)
    {
        std::cerr <<
                  "Usage: http-client-sync-ssl <host> <port> <target> [<HTTP version: 1.0 or 1.1(default)>]\n" <<
                  "Example:\n" <<
                  "    http-client-sync-ssl www.example.com 443 /\n" <<
                  "    http-client-sync-ssl www.example.com 443 / 1.0\n";
        return EXIT_FAILURE;
    }
    auto const host = argv[1];
    auto const port = argv[2];
    auto const target = argv[3];
    int version = argc == 5 && !std::strcmp("1.0", argv[4]) ? 10 : 11;

    try {

        // IO context
        boost::asio::io_context io_ctx;

        // Build request
        elx::http::client::request req = {
            .host = host,
            .port = static_cast<uint16_t>(std::stoi(port)),
            .target = target,
            .encryption = elx::http::client::request::encryption::tls
        };

        // Perform synchronous GET request
        elx::http::client client(io_ctx);
        auto response = client.synchronous_get(req);

        // Show the response body
        std::cout << "response = " << response.body_string() << std::endl;

    }
    catch(std::exception const& e) {
        std::cerr << "Error: " << e.what() << std::endl;
        return EXIT_FAILURE;
    }
    return EXIT_SUCCESS;
}

client.hpp:

#pragma once

#include <boost/beast/http.hpp>
#include <boost/beast/core/buffers_to_string.hpp>
#include <boost/beast/core/tcp_stream.hpp>
#include "authorization.hpp"
#include "client_transport.hpp"

namespace http = boost::beast::http;

namespace elx::http
{
    class client
    {
    public:
        struct request
        {
            enum class encryption {
                none,
                tls
            };

            std::string host;
            uint16_t port;
            std::string target;
            uint8_t http_version = 11;
            authorization::auth auth;
            encryption encryption = encryption::none;
        };

        struct response
        {
            boost::beast::http::response<boost::beast::http::dynamic_body> raw;

            std::string body_string() const
            {
                return boost::beast::buffers_to_string(raw.body().data());
            }
        };

        // Construction
        explicit client(boost::asio::io_context& ioc);
        client(const client& other) = delete;
        client(client&& other) = delete;
        virtual ~client() = default;

        [[nodiscard]] static enum request::encryption determine_encryption(const std::string& host);
        [[nodiscard]] response synchronous_get(const request& req);

    private:
        boost::asio::io_context& m_io_ctx;
        boost::asio::ip::tcp::resolver m_resolver;
        std::unique_ptr<client_transport> m_transport;

    };
}

client.cpp:

#include <boost/beast/core.hpp>
#include <boost/beast/http.hpp>
#include <boost/beast/version.hpp>
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/strand.hpp>
#include "client.hpp"
#include "client_transport.hpp"

using namespace elx::http;

client::client(boost::asio::io_context& ioc) :
    m_io_ctx(ioc),
    m_resolver(boost::asio::make_strand(ioc))
{
}

client::response client::synchronous_get(const request& req)
{
    namespace http = boost::beast::http;

    // Setup SSL context (in case we need that)
    boost::asio::ssl::context ssl_ctx(boost::asio::ssl::context::tlsv12_client);

    // Setup encryption
    switch (req.encryption) {
        case request::encryption::none:
            m_transport = std::make_unique<client_transport_plain>(m_io_ctx);
            break;
tls
        case request::encryption::tls:
            ssl_ctx.set_default_verify_paths();
#warning "ToDo: We definitely want to verify the peer"
            ssl_ctx.set_verify_mode(boost::asio::ssl::verify_none);
            m_transport = std::make_unique<client_transport_tls>(m_io_ctx, ssl_ctx);
            m_transport->set_hostname(req.host);
            break;
    }

    // Sanity check
    if (not m_transport)
        return { };

    // Look up the domain name
    auto const results = m_resolver.resolve(req.host, std::to_string(req.port));

    // Make the connection on the IP address we get from a lookup
    m_transport->connect(results->endpoint());

    // Perform handshake
    m_transport->handshake();

    // Set up an HTTP GET request message
    http::request<boost::beast::http::string_body> beast_req;
    beast_req.set(http::field::host, req.host);
    beast_req.set(http::field::user_agent, BOOST_BEAST_VERSION_STRING);
    if (not req.auth.is_empty())
        beast_req.set(http::field::authorization, req.auth.to_string());
    beast_req.method(http::verb::get);
    beast_req.version(req.http_version);
    beast_req.target(req.target);

    // Send the HTTP beast_request to the remote host
    http::write(m_transport->stream(), beast_req);

    // This buffer is used for reading and must be persisted
    boost::beast::flat_buffer buffer;

    // Declare a container to hold the response
    response res;

    // Receive the HTTP response
    boost::beast::error_code ec;
    http::read(m_transport->stream(), buffer, res.raw, ec);
    if (ec) {
        throw boost::beast::system_error{ec};
    }

    // Gracefully close the socket
    m_transport->stream().socket().shutdown(boost::asio::ip::tcp::socket::shutdown_both, ec);

    // not_connected happens sometimes
    // so don't bother reporting it.
    //
    if(ec && ec != boost::beast::errc::not_connected)
        throw boost::beast::system_error{ec};

    // If we get here then the connection is closed gracefully

    return res;
}

client_transport.hpp:

#pragma once

#include <boost/asio/ip/resolver_base.hpp>
#include <boost/asio/ssl/stream.hpp>
#include <boost/beast/core/tcp_stream.hpp>
#include <boost/beast/ssl.hpp>

namespace elx::http
{
    class client_transport
    {
    public:
        [[nodiscard]] virtual boost::beast::tcp_stream& stream() = 0;

        virtual boost::beast::error_code set_hostname(const std::string& hostname) { return { }; }
        virtual void handshake() { }

        void connect(const boost::beast::tcp_stream::endpoint_type& endpoint)
        {
            stream().connect(endpoint);
        }
    };

    class client_transport_plain :
        public client_transport
    {
    public:
        client_transport_plain(boost::asio::io_context& io_ctx) :
            m_stream(io_ctx)
        {
        }

        [[nodiscard]] boost::beast::tcp_stream& stream() override
        {
            return m_stream;
        }

    private:
        boost::beast::tcp_stream m_stream;
    };

    class client_transport_tls :
        public client_transport
    {
    public:
        client_transport_tls(boost::asio::io_context& io_ctx, boost::asio::ssl::context& ssl_ctx) :
            m_io_ctx(io_ctx),
            m_ssl_ctx(ssl_ctx),
            m_stream(io_ctx, ssl_ctx)
        {
        }

        [[nodiscard]] boost::beast::tcp_stream& stream() override
        {
            return m_stream.next_layer();
        }

        boost::beast::error_code set_hostname(const std::string& hostname) override
        {
            // Set SNI Hostname (many hosts need this to handshake successfully)
            if (not SSL_set_tlsext_host_name(m_stream.native_handle(), hostname.c_str())) {
                return boost::beast::error_code{static_cast<int>(::ERR_get_error()), boost::asio::error::get_ssl_category()};
            }

            return { };
        }

        void handshake() override
        {
            m_stream.handshake(boost::asio::ssl::stream_base::client);
        }

    private:
        boost::asio::io_context& m_io_ctx;
        boost::asio::ssl::context& m_ssl_ctx;
        boost::beast::ssl_stream<boost::beast::tcp_stream> m_stream;
    };
}

Solution

  • The problem is that when you send the request and receive the response, you are doing so directly on the stream. You will need to implement write and read as polymorphic overrides so that the http request/response can be performed on the correct stream type.

    Corrected code here: https://github.com/test-scenarios/beast-issue-1849