diff --git a/lib/remote/CMakeLists.txt b/lib/remote/CMakeLists.txt index 2271abff6..d8d3298c5 100644 --- a/lib/remote/CMakeLists.txt +++ b/lib/remote/CMakeLists.txt @@ -27,6 +27,7 @@ set(remote_SOURCES eventshandler.cpp eventshandler.hpp filterutility.cpp filterutility.hpp httphandler.cpp httphandler.hpp + httpmessage.cpp httpmessage.hpp httpserverconnection.cpp httpserverconnection.hpp httputility.cpp httputility.hpp infohandler.cpp infohandler.hpp diff --git a/lib/remote/httpmessage.cpp b/lib/remote/httpmessage.cpp new file mode 100644 index 000000000..5e7745890 --- /dev/null +++ b/lib/remote/httpmessage.cpp @@ -0,0 +1,235 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#include "remote/httpmessage.hpp" +#include "remote/httputility.hpp" +#include "remote/url.hpp" +#include "base/json.hpp" +#include "base/logger.hpp" +#include +#include +#include + +using namespace icinga; + +/** + * Adapter class for Boost Beast HTTP messages body to be used with the @c JsonEncoder. + * + * This class implements the @c nlohmann::detail::output_adapter_protocol<> interface and provides + * a way to write JSON data directly into the body of a Boost Beast HTTP message. The adapter is designed + * to work with Boost Beast HTTP messages that conform to the Beast HTTP message interface and must provide + * a body type that has a publicly accessible `reader` type that satisfies the Beast BodyReader [^1] requirements. + * + * @ingroup base + * + * [^1]: https://www.boost.org/doc/libs/1_85_0/libs/beast/doc/html/beast/concepts/BodyReader.html + */ +class HttpResponseJsonWriter : public AsyncJsonWriter +{ +public: + explicit HttpResponseJsonWriter(HttpResponse& msg) : m_Reader(msg.base(), msg.body()), m_Message{msg} + { + boost::system::error_code ec; + // This never returns an actual error, except when overflowing the max + // buffer size, which we don't do here. + m_Reader.init(m_MinPendingBufferSize, ec); + ASSERT(!ec); + } + + ~HttpResponseJsonWriter() override + { + boost::system::error_code ec; + // Same here as in the constructor, all the standard Beast HTTP message reader implementations + // never return an error here, it's just there to satisfy the interface requirements. + m_Reader.finish(ec); + ASSERT(!ec); + } + + void write_character(char c) override + { + write_characters(&c, 1); + } + + void write_characters(const char* s, std::size_t length) override + { + boost::system::error_code ec; + boost::asio::const_buffer buf{s, length}; + while (buf.size()) { + std::size_t w = m_Reader.put(buf, ec); + ASSERT(!ec); + buf += w; + } + m_PendingBufferSize += length; + } + + void MayFlush(boost::asio::yield_context& yield) override + { + if (m_PendingBufferSize >= m_MinPendingBufferSize) { + m_Message.Flush(yield); + m_PendingBufferSize = 0; + } + } + +private: + HttpResponse::body_type::reader m_Reader; + HttpResponse& m_Message; + // The size of the pending buffer to avoid unnecessary writes + std::size_t m_PendingBufferSize{0}; + // Minimum size of the pending buffer before we flush the data to the underlying stream + static constexpr std::size_t m_MinPendingBufferSize = 4096; +}; + +HttpRequest::HttpRequest(Shared::Ptr stream) + : m_Stream(std::forward(stream)){} + +void HttpRequest::ParseHeader(boost::beast::flat_buffer & buf, boost::asio::yield_context yc) +{ + boost::beast::http::async_read_header(*m_Stream, buf, m_Parser, yc); + base() = m_Parser.get().base(); +} + +void HttpRequest::ParseBody(boost::beast::flat_buffer & buf, boost::asio::yield_context yc) +{ + boost::beast::http::async_read(*m_Stream, buf, m_Parser, yc); + body() = std::move(m_Parser.release().body()); +} + +ApiUser::Ptr HttpRequest::User() const +{ + return m_User; +} + +void HttpRequest::User(const ApiUser::Ptr& user) +{ + m_User = user; +} + +Url::Ptr HttpRequest::Url() const +{ + return m_Url; +} + +void HttpRequest::DecodeUrl() +{ + m_Url = new icinga::Url(std::string(target())); +} + +Dictionary::Ptr HttpRequest::Params() const +{ + return m_Params; +} + +void HttpRequest::DecodeParams() +{ + if (!body().empty()) { + Log(LogDebug, "HttpUtility") + << "Request body: '" << body() << '\''; + + m_Params = JsonDecode(body()); + } + + if (!m_Params) { + m_Params = new Dictionary(); + } + + if (!m_Url) { + DecodeUrl(); + } + + std::map query; + for (const auto& kv : Url()->GetQuery()) { + query[kv.first].emplace_back(kv.second); + } + + for (auto& [key, val] : query) { + m_Params->Set(key, new Array{std::move(val)}); + } +} + +Value HttpRequest::GetLastParameter(const String& key) const +{ + return HttpUtility::GetLastParameter(Params(), key); +} + +bool HttpRequest::IsPretty() const +{ + return GetLastParameter("pretty"); +} + +bool HttpRequest::IsVerbose() const +{ + return GetLastParameter("verbose"); +} + +HttpResponse::HttpResponse(Shared::Ptr stream) + : m_Stream(std::move(stream)) +{ +} + +void HttpResponse::Flush(boost::asio::yield_context yc) +{ + if (!chunked()) { + ASSERT(!m_Serializer.is_header_done()); + prepare_payload(); + } + + boost::system::error_code ec; + boost::beast::http::async_write(*m_Stream, m_Serializer, yc[ec]); + if (ec && ec != boost::beast::http::error::need_buffer) { + if (yc.ec_) { + *yc.ec_ = ec; + return; + } else { + BOOST_THROW_EXCEPTION(boost::system::system_error{ec}); + } + } + m_Stream->async_flush(yc); + + ASSERT(chunked() || m_Serializer.is_done()); +} + +void HttpResponse::StartStreaming() +{ + ASSERT(body().Size() == 0 && !m_Serializer.is_header_done()); + body().Start(); + chunked(true); +} + +void HttpResponse::SendJsonBody(const Value& val, bool pretty) +{ + namespace http = boost::beast::http; + + set(http::field::content_type, "application/json"); + GetJsonEncoder().Encode(val); +} + +void HttpResponse::SendJsonError(int code, String info, String diagInfo, bool pretty, bool verbose) +{ + ASSERT(!m_Serializer.is_header_done()); + + Dictionary::Ptr response = new Dictionary({ { "error", code } }); + + if (!info.IsEmpty()) { + response->Set("status", std::move(info)); + } + + if (verbose && !diagInfo.IsEmpty()) { + response->Set("diagnostic_information", std::move(diagInfo)); + } + + result(code); + + SendJsonBody(response, pretty); +} + +void HttpResponse::SendJsonError(const Dictionary::Ptr& params, int code, String info, String diagInfo) +{ + bool verbose = params && HttpUtility::GetLastParameter(params, "verbose"); + bool pretty = params && HttpUtility::GetLastParameter(params, "pretty"); + SendJsonError(code, std::move(info), std::move(diagInfo), pretty, verbose); +} + +JsonEncoder HttpResponse::GetJsonEncoder(bool pretty) +{ + auto adapter = std::make_shared(*this); + return JsonEncoder{adapter, pretty}; +} diff --git a/lib/remote/httpmessage.hpp b/lib/remote/httpmessage.hpp new file mode 100644 index 000000000..4063780a5 --- /dev/null +++ b/lib/remote/httpmessage.hpp @@ -0,0 +1,314 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#ifndef HTTPMESSAGE_H +#define HTTPMESSAGE_H + +#include "base/dictionary.hpp" +#include "base/tlsstream.hpp" +#include "base/json.hpp" +#include "remote/url.hpp" +#include "remote/apiuser.hpp" +#include +#include + +namespace icinga +{ + +/** + * A custom body_type for a @c boost::beast::http::message + * + * It combines the memory management of @c boost::beast::http::dynamic_body, + * which uses a multi_buffer, with the ability to continue serialization when + * new data arrives of the @c boost::beast::http::buffer_body. + * + * @tparam DynamicBuffer A buffer conforming to the boost::beast interface of the same name + * + * @ingroup remote + */ +template +struct SerializableBody +{ + class reader; + class writer; + + class value_type + { + public: + template + value_type& operator<<(T && right) + { + /* Preferably, we would return an ostream object here instead. However + * there seems to be a bug in boost::beast where if the ostream, or rather its + * streambuf object is moved into the return value, the chunked encoding gets + * mangled, leading to the client disconnecting. + * + * A workaround would have been to construct the boost::beast::detail::ostream_helper + * with the last parameter set to false, indicating that the streambuf object is not + * movable, but that is an implementation detail we'd rather not use directly in our + * code. + * + * This version has a certain overhead of the ostream being constructed on every call + * to the operator, which leads to an individual append for each time, whereas if the + * object could be kept until the entire chain of output operators is finished, only + * a single call to prepare()/commit() would have been needed. + * + * However, since this operator is mostly used for small error messages and the big + * responses are handled via a reader instance, this shouldn't be too much of a + * problem. + */ + boost::beast::ostream(m_Buffer) << std::forward(right); + return *this; + } + + std::size_t Size() const { return m_Buffer.size(); } + + void Finish() { m_More = false; } + void Start() { m_More = true; } + + friend class reader; + friend class writer; + + private: + /* This defaults to false so the body does not require any special handling + * for simple messages and can still be written with http::async_write(). + */ + bool m_More = false; + DynamicBuffer m_Buffer; + }; + + static std::uint64_t size(const value_type& body) { return body.Size(); } + + /** + * Implement the boost::beast BodyReader interface for this body type + * + * This is used by the @c boost::beast::http::parser (which we don't use for responses) + * or in the @c BeastHttpMessageAdapter for our own @c JsonEncoder to write JSON directly + * into the body of a HTTP response message. + * + * The reader automatically sets the body's `more` flag on the call to its init() function + * and resets it when its finish() function is called. Regarding the usage in the + * @c JsonEncoder above, this means that the message is automatically marked as complete + * when the encoder object is destroyed. + */ + class reader + { + public: + template + explicit reader(boost::beast::http::header& h, value_type& b) : m_Body(b) + { + } + + void init(const boost::optional& n, boost::beast::error_code& ec) + { + ec = {}; + m_Body.Start(); +#if BOOST_VERSION >= 107000 + if (n) { + m_Body.m_Buffer.reserve(*n); + } +#endif /* BOOST_VERSION */ + } + + template + std::size_t put(const ConstBufferSequence& buffers, boost::beast::error_code& ec) + { + auto size = boost::asio::buffer_size(buffers); + if (size > m_Body.m_Buffer.max_size() - m_Body.Size()) { + ec = boost::beast::http::error::buffer_overflow; + return 0; + } + + auto const wBuf = m_Body.m_Buffer.prepare(size); + boost::asio::buffer_copy(wBuf, buffers); + m_Body.m_Buffer.commit(size); + return size; + } + + void finish(boost::beast::error_code& ec) + { + ec = {}; + m_Body.Finish(); + } + + private: + value_type& m_Body; + }; + + /** + * Implement the boost::beast BodyWriter interface for this body type + * + * This is used (for example) by the @c boost::beast::http::serializer to write out the + * message over the TLS stream. The logic is similar to the writer of the + * @c boost::beast::http::buffer_body. + * + * On the every call, it will free up the buffer range that has previously been written, + * then return a buffer containing data the has become available in the meantime. Otherwise, + * if there is more data expected in the future, for example because a corresponding reader + * has not yet finished filling the body, a `need_buffer` error is returned, to inform the + * serializer to abort writing for now, which in turn leads to the outer call to + * `http::async_write` to call their completion handlers with a `need_buffer` error, to + * notify that more data is required for another call to `http::async_write`. + */ + class writer + { + public: + using const_buffers_type = typename decltype(value_type::m_Buffer)::const_buffers_type; + + template + explicit writer(const boost::beast::http::header& h, value_type& b) + : m_Body(b) + { + } + + /** + * This constructor is needed specifically for boost-1.66, which was the first version + * the beast library was introduced and is still used on older (supported) distros. + */ + template + explicit writer(const boost::beast::http::message& msg) + : m_Body(const_cast(msg.body())) + { + } + + void init(boost::beast::error_code& ec) { ec = {}; } + + boost::optional> get(boost::beast::error_code& ec) + { + using namespace boost::beast::http; + + if (m_SizeWritten > 0) { + m_Body.m_Buffer.consume(std::exchange(m_SizeWritten, 0)); + } + + if (m_Body.m_Buffer.size()) { + ec = {}; + m_SizeWritten = m_Body.m_Buffer.size(); + return {{m_Body.m_Buffer.data(), m_Body.m_More}}; + } + + if (m_Body.m_More) { + ec = {make_error_code(error::need_buffer)}; + } else { + ec = {}; + } + return boost::none; + } + + private: + value_type& m_Body; + std::size_t m_SizeWritten = 0; + }; +}; + +/** + * A wrapper class for a boost::beast HTTP request + * + * @ingroup remote + */ +class HttpRequest: public boost::beast::http::request +{ +public: + using ParserType = boost::beast::http::request_parser; + + explicit HttpRequest(Shared::Ptr stream); + + /** + * Parse the header of the response using the internal parser object. + * + * This first performs an @f async_read_header() into the parser, then copies + * the parsed header into this object. + */ + void ParseHeader(boost::beast::flat_buffer& buf, boost::asio::yield_context yc); + + /** + * Parse the body of the response using the internal parser object. + * + * This first performs an async_read() into the parser, then moves the parsed body + * into this object. + * + * @param buf The buffer used to track the state of the connection + * @param yc The yield_context for this operation + */ + void ParseBody(boost::beast::flat_buffer& buf, boost::asio::yield_context yc); + + ParserType& Parser() { return m_Parser; } + + ApiUser::Ptr User() const; + void User(const ApiUser::Ptr& user); + + icinga::Url::Ptr Url() const; + void DecodeUrl(); + + Dictionary::Ptr Params() const; + void DecodeParams(); + + Value GetLastParameter(const String& key) const; + + /** + * Return true if pretty printing was requested. + */ + bool IsPretty() const; + + /** + * Return true if a verbose response was requested. + */ + bool IsVerbose() const; + +private: + ApiUser::Ptr m_User; + Url::Ptr m_Url; + Dictionary::Ptr m_Params; + + ParserType m_Parser; + + Shared::Ptr m_Stream; +}; + +/** + * A wrapper class for a boost::beast HTTP response + * + * @ingroup remote + */ +class HttpResponse: public boost::beast::http::response> +{ +public: + explicit HttpResponse(Shared::Ptr stream); + + /** + * Writes as much of the response as is currently available. + * + * Uses chunk-encoding if the content_length has not been set by the time this is called + * for the first time. + * + * The caller needs to ensure that the header is finished before calling this for the + * first time as changes to the header afterwards will not have any effect. + * + * @param yc The yield_context for this operation + */ + void Flush(boost::asio::yield_context yc); + + bool HasSerializationStarted () { return m_Serializer.is_header_done(); } + + /** + * Enables chunked encoding. + */ + void StartStreaming(); + + void SendJsonBody(const Value& val, bool pretty = false); + void SendJsonError(const int code, String info = String(), String diagInfo = String(), + bool pretty = false, bool verbose = false); + void SendJsonError(const Dictionary::Ptr& params, const int code, + String info = String(), String diagInfo = String()); + + JsonEncoder GetJsonEncoder(bool pretty = false); + +private: + using Serializer = boost::beast::http::response_serializer; + Serializer m_Serializer{*this}; + + Shared::Ptr m_Stream; +}; + +} + +#endif /* HTTPUTILITY_H */