Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
#
FROM docker.io/psedoc/anyhttp:0.28

# ==================================================================================================

#
# install some more interactive utils in the devcontainer
#
Expand All @@ -12,6 +14,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*

# ==================================================================================================

#
# TEST: claude code
#
Expand All @@ -24,6 +28,8 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \
# Install Claude Code
# RUN npm install -g @anthropic-ai/claude-code

# ==================================================================================================

#
# enable persistent bash history in container
#
Expand Down
6 changes: 4 additions & 2 deletions .devcontainer/base/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -120,14 +120,16 @@ RUN git clone --recursive --depth 1 --branch ${NGTCP2_VERSION} https://github.co

# ==================================================================================================

RUN git clone --depth 1 -b v1.6.3 https://github.com/libbpf/libbpf && \
RUN git clone --depth 1 -b v1.7.0 https://github.com/libbpf/libbpf && \
cd libbpf && \
PREFIX=/usr/local LIBDIR=/usr/local/lib make -j$(nproc) -C src install && \
cd .. && rm -rf libbpf

#
# nghttp2 with HTTP/3 support (based on OpenSSL)
#
# --enable-debug (very noisy)
#
ARG NGHTTP2_VERSION=v1.70.0
RUN git clone --recursive --depth 1 --branch ${NGHTTP2_VERSION} https://github.com/nghttp2/nghttp2.git && \
cd nghttp2 && \
Expand All @@ -150,7 +152,7 @@ RUN cd opt && \
git clone --depth 1 https://github.com/curl/curl.git && \
cd curl && autoreconf -fi && \
./configure --with-openssl --without-libpsl --with-nghttp2 \
--with-ngtcp2 --with-nghttp3 \
--with-ngtcp2 --with-nghttp3 --enable-ssls-export \
--prefix /usr/local && \
make -j$(nproc) && make install && \
cd .. && rm -rf curl
Expand Down
28 changes: 28 additions & 0 deletions include/anyhttp/detail/h2_session_details.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,34 @@ awaitable<void> ServerSession<Stream>::do_session(Buffer&& buffer)
nghttp2_submit_settings(session, NGHTTP2_FLAG_NONE, &ent, 1);
#endif

//
// Continue an HTTP/1.1 request upgraded to h2c as stream 1. It has been received completely,
// so nghttp2 opens it half-closed (remote). No HEADERS frame will arrive for it, so do what
// on_begin_headers_callback() and on_frame_recv_callback() would have done.
//
if (m_upgrade)
{
const auto& settings = m_upgrade->settings;
const bool head_request = m_upgrade->method == "HEAD";
if (auto rv = nghttp2_session_upgrade2(session,
reinterpret_cast<const uint8_t*>(settings.data()),
settings.size(), head_request, nullptr))
{
mloge("nghttp2_session_upgrade2: {}", nghttp2_strerror(rv));
nghttp2_session_terminate_session(session, NGHTTP2_PROTOCOL_ERROR);
}
else
{
auto stream = this->create_stream(1);
stream->method = std::move(m_upgrade->method);
stream->url = std::move(m_upgrade->url);
mlogd("upgraded from HTTP/1.1: {} {}", stream->method, stream->url.buffer());
stream->on_request();
stream->on_eof(session, 1);
}
m_upgrade.reset();
}

//
// Let NGHTTP2 parse what we have received so far.
// This must happen after submitting the server settings.
Expand Down
5 changes: 5 additions & 0 deletions include/anyhttp/h1_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,13 @@ class ServerSession : public ServerSessionBase, public BeastSession<Stream>
public:
ServerSession(server::Server::Impl& parent, any_io_executor executor, Stream&& stream);

void destroy() noexcept override;
void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override;
awaitable<void> do_session(Buffer&& data) override;

private:
/// Takes over the stream after an upgrade to h2c, see do_session().
std::shared_ptr<Session::Impl> m_upgraded;
};

// -------------------------------------------------------------------------------------------------
Expand Down
25 changes: 25 additions & 0 deletions include/anyhttp/h2_backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
#include <boost/asio/any_io_executor.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl/stream.hpp>
#include <boost/url/url.hpp>

#include <memory>
#include <string>

namespace anyhttp::nghttp2
{
Expand All @@ -25,6 +27,18 @@ namespace anyhttp::nghttp2

using SslStream = boost::asio::ssl::stream<boost::asio::ip::tcp::socket>;

/**
* A request received as HTTP/1.1 with "Upgrade: h2c" (RFC 7540, section 3.2) that has been answered
* with "101 Switching Protocols". The HTTP/2 session continues it as stream 1. Only requests without
* a body are upgraded, so the stream starts out half-closed (remote).
*/
struct Upgrade
{
std::string settings; ///< decoded payload of the HTTP2-Settings header
std::string method;
boost::urls::url url;
};

std::shared_ptr<Session::Impl> make_server_session(server::Server::Impl& server,
boost::asio::any_io_executor executor,
boost::asio::ip::tcp::socket&& socket);
Expand All @@ -37,6 +51,17 @@ std::shared_ptr<Session::Impl> make_server_session(server::Server::Impl& server,
boost::asio::any_io_executor executor,
AnyAsyncStream&& stream);

// Cleartext only: there is no upgrade to HTTP/2 over TLS, that is what ALPN is for.

std::shared_ptr<Session::Impl> make_server_session(server::Server::Impl& server,
boost::asio::any_io_executor executor,
boost::asio::ip::tcp::socket&& socket,
Upgrade&& upgrade);

std::shared_ptr<Session::Impl> make_server_session(server::Server::Impl& server,
boost::asio::any_io_executor executor,
AnyAsyncStream&& stream, Upgrade&& upgrade);

// -------------------------------------------------------------------------------------------------

std::shared_ptr<Session::Impl> make_client_session(client::Client::Impl& client,
Expand Down
5 changes: 5 additions & 0 deletions include/anyhttp/h2_session.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

#include "anyhttp/common.hpp"
#include "client_impl.hpp"
#include "h2_backend.hpp"
#include "h2_stream.hpp"
#include "server_impl.hpp"
#include "session_impl.hpp"
Expand All @@ -12,6 +13,7 @@
#include <boost/beast/core/stream_traits.hpp>

#include <map>
#include <optional>

#include "nghttp2/nghttp2.h"

Expand Down Expand Up @@ -181,6 +183,9 @@ class ServerSession : public ServerReference, public NGHttp2SessionImpl<Stream>
ServerSession(server::Server::Impl& parent, any_io_executor executor, Stream&& stream);

awaitable<void> do_session(Buffer&& data) override;

/// Set if this session continues an HTTP/1.1 request that has been upgraded to h2c.
std::optional<Upgrade> m_upgrade;
};

// =================================================================================================
Expand Down
133 changes: 132 additions & 1 deletion src/h1_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#include "anyhttp/common.hpp"
#include "anyhttp/formatter.hpp" // IWYU pragma: keep
#include "anyhttp/h1_backend.hpp"
#include "anyhttp/h2_backend.hpp"
#include "anyhttp/server.hpp"

#include <boost/asio/any_io_executor.hpp>
Expand All @@ -17,6 +18,7 @@
#include <boost/asio/ip/tcp.hpp>

#include <boost/beast/core.hpp>
#include <boost/beast/core/detail/base64.hpp>
#include <boost/beast/core/buffer_traits.hpp>
#include <boost/beast/core/error.hpp>
#include <boost/beast/core/stream_traits.hpp>
Expand All @@ -37,6 +39,9 @@

#include <boost/url/parse.hpp>

#include <algorithm>
#include <optional>
#include <stdexcept>
#include <string_view>

using namespace std::chrono_literals;
Expand Down Expand Up @@ -319,7 +324,7 @@ class WriterBase : public Parent
message.body().data = buffer.size() ? const_cast<void*>(buffer.data()) : nullptr;
#else
message.body().data = const_cast<void*>(buffer.data());
#endif
#endif
message.body().size = buffer.size();
message.body().more = !eof;

Expand Down Expand Up @@ -661,6 +666,106 @@ void BeastSession<Stream>::destroy() noexcept
// });
}

template <typename Stream>
void ServerSession<Stream>::destroy() noexcept
{
if (m_upgraded)
m_upgraded->destroy(); // the stream has been moved there
else
super::destroy();
}

// =================================================================================================

/**
* Returns what the HTTP/2 session needs to continue \p request as stream 1, if the request asks
* for an upgrade to h2c (RFC 7540, section 3.2) and it can be granted. Otherwise, the upgrade is
* ignored and the request is served as HTTP/1.1, which is always a valid response to it.
*
* Only requests that are complete after their header are upgraded: A request body would have to
* be read in HTTP/1.1 first, before switching protocols. Cleartext only, as h2 over TLS is
* negotiated by ALPN instead.
*/
static std::optional<nghttp2::Upgrade> h2c_upgrade(const http::request<http::buffer_body>& request,
const boost::urls::url& url, bool complete)
{
const auto has_token = [](std::string_view list, std::string_view token)
{
for (auto item : http::token_list(list))
if (beast::iequals(item, token))
return true;
return false;
};

if (!has_token(request[http::field::upgrade], "h2c") || url.scheme() != "http")
return std::nullopt;

if (!has_token(request[http::field::connection], "upgrade") ||
!has_token(request[http::field::connection], "http2-settings"))
{
logw("upgrade: ignoring h2c upgrade, 'Connection' misses 'Upgrade' or 'HTTP2-Settings'");
return std::nullopt;
}

// exactly one HTTP2-Settings header, containing base64url without padding
if (request.count("HTTP2-Settings") != 1)
{
logw("upgrade: ignoring h2c upgrade, need exactly one 'HTTP2-Settings' header");
return std::nullopt;
}

if (!complete)
{
logw("upgrade: ignoring h2c upgrade for request with body");
return std::nullopt;
}

std::string encoded(request["HTTP2-Settings"]);
std::ranges::replace(encoded, '-', '+');
std::ranges::replace(encoded, '_', '/');

nghttp2::Upgrade upgrade;
upgrade.settings.resize(beast::detail::base64::decoded_size(encoded.size()));
auto [written, read] =
beast::detail::base64::decode(upgrade.settings.data(), encoded.data(), encoded.size());
upgrade.settings.resize(written);

// a SETTINGS payload is a sequence of 6-byte entries
if (read != encoded.size() || upgrade.settings.size() % 6 != 0)
{
logw("upgrade: ignoring h2c upgrade, invalid 'HTTP2-Settings' header");
return std::nullopt;
}

upgrade.method = request.method_string();
upgrade.url = url;
return upgrade;
}

static std::shared_ptr<Session::Impl> make_h2c_session(server::Server::Impl& server,
any_io_executor executor,
tcp_stream& stream,
nghttp2::Upgrade&& upgrade)
{
return nghttp2::make_server_session(server, std::move(executor), stream.release_socket(),
std::move(upgrade));
}

static std::shared_ptr<Session::Impl> make_h2c_session(server::Server::Impl& server,
any_io_executor executor,
AnyAsyncStream& stream,
nghttp2::Upgrade&& upgrade)
{
return nghttp2::make_server_session(server, std::move(executor), std::move(stream),
std::move(upgrade));
}

static std::shared_ptr<Session::Impl> make_h2c_session(server::Server::Impl&, any_io_executor,
ssl::stream<socket>&, nghttp2::Upgrade&&)
{
throw std::logic_error("h2c upgrade over TLS"); // rejected by h2c_upgrade()
}

// =================================================================================================

/**
Expand Down Expand Up @@ -749,6 +854,32 @@ awaitable<void> ServerSession<Stream>::do_session(Buffer&& buffer)
for (auto& header : request)
mlogd(" \x1b[1;34m{}\x1b[0m: {}", header.name_string(), header.value());

//
// Upgrade to h2c, if requested: Answer with "101 Switching Protocols" and hand over the
// stream to an HTTP/2 session, which continues this request as stream 1. Anything that
// follows in the buffer (the client preface) is already HTTP/2.
//
if (auto upgrade = h2c_upgrade(request, reader->m_url, parser.is_done()))
{
http::response<http::empty_body> res{http::status::switching_protocols, request.version()};
res.set(http::field::connection, "Upgrade");
res.set(http::field::upgrade, "h2c");
reader.reset(); // owns the parser and thereby 'request'

if (auto [ec, n] = co_await http::async_write(m_stream, res, as_tuple); ec)
{
mlogw("upgrade: writing 101 response: {}", ec.message());
break;
}

mlogi("upgrading to h2c, {} bytes in buffer", m_buffer.size());
m_upgraded = make_h2c_session(server(), super::get_executor(), m_stream,
std::move(*upgrade));
co_await m_upgraded->do_session(std::move(m_buffer));
mlogi("h2c session done, served {} requests before upgrade", requestCounter - 1);
co_return;
}

//
// Prepare response.
//
Expand Down
21 changes: 21 additions & 0 deletions src/h2_session.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,27 @@ std::shared_ptr<Session::Impl> make_server_session(server::Server::Impl& server,
std::move(socket));
}

std::shared_ptr<Session::Impl> make_server_session(server::Server::Impl& server,
asio::any_io_executor executor,
asio::ip::tcp::socket&& socket,
Upgrade&& upgrade)
{
auto session = std::make_shared<ServerSession<asio::ip::tcp::socket>>(
server, std::move(executor), std::move(socket));
session->m_upgrade = std::move(upgrade);
return session;
}

std::shared_ptr<Session::Impl> make_server_session(server::Server::Impl& server,
asio::any_io_executor executor,
AnyAsyncStream&& stream, Upgrade&& upgrade)
{
auto session = std::make_shared<ServerSession<AnyAsyncStream>>(server, std::move(executor),
std::move(stream));
session->m_upgrade = std::move(upgrade);
return session;
}

std::shared_ptr<Session::Impl> make_client_session(client::Client::Impl& client,
asio::any_io_executor executor,
asio::ip::tcp::socket&& socket)
Expand Down
1 change: 1 addition & 0 deletions src/h3_stream.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ void Http3Stream::bind_write_cancellation(WriteHandler& handler, uint64_t token)
});
}

// https://nghttp2.org/nghttp3/types.html#c.nghttp3_read_data_callback
nghttp3_ssize Http3Stream::data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags)
{
if (veccnt == 0)
Expand Down
3 changes: 3 additions & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ add_executable(test_all ${SRC_FILES})
target_link_libraries(test_all PRIVATE GTest::GTest GTest::gmock anyhttp)
target_link_libraries(test_all PRIVATE spdlog::spdlog_header_only)

# the h2c upgrade tests drive an HTTP/2 client session by hand
target_link_libraries(test_all PRIVATE PkgConfig::NGHTTP2)

# the tests connect over TLS and pass pki/out/root.pem to curl, so the PKI must exist first
add_dependencies(test_all pki)

Expand Down
Loading