#include "web.hpp" #include #include #include #include #include #include #include #include #include namespace beast = boost::beast; // from namespace http = beast::http; // from namespace net = boost::asio; // from using tcp = net::ip::tcp; // from using namespace std::literals; namespace { // Used as the completion handler for coroutines in this module to print // failure reasons to the log. auto report_error(std::exception_ptr eptr) -> void { if (eptr) { try { std::rethrow_exception(eptr); } catch (const std::exception &e) { BOOST_LOG_TRIVIAL(error) << "HTTP coroutine failed: " << e.what(); } } } // Construct a simple, empty reply using the given status code. auto simple_response( http::status status, unsigned version, bool keep_alive ) -> http::message_generator { http::response res{status, version}; res.set(http::field::server, BOOST_BEAST_VERSION_STRING); res.keep_alive(keep_alive); res.content_length(0); return res; } // Compute the expected signature string for the POST body. auto compute_signature(const std::string_view secret, const std::string_view body) -> std::string { unsigned int digest_length = EVP_MAX_MD_SIZE; unsigned char digest[EVP_MAX_MD_SIZE]; HMAC(EVP_sha256(), secret.data(), secret.size(), reinterpret_cast(body.data()), body.size(), digest, &digest_length); std::stringstream ss; ss << "sha256="; ss << std::hex << std::setfill('0'); for (unsigned int i = 0; i < digest_length; i++) { ss << std::setw(2) << static_cast(digest[i]); } return ss.str(); } // This event is ready to actually announce auto announce_event( std::shared_ptr self, const ProjectSettings &project, const std::string full_name, const std::string_view event_name, const boost::json::value event ) -> void { const auto message = std::string{event_name} + " on " + full_name; self->send_notice(project.channel, std::move(message)); } // Determine if this event should be announced auto process_event( std::shared_ptr self, const std::string_view notify_user, const std::string_view event_name, const boost::json::value &json ) -> void { auto &event = json.as_object(); // Determine the project name. Repositories use: user/project. Organization events use: organization std::string full_name; if (event.contains("repository")) { full_name = std::string{event.at("repository").as_object().at("full_name").as_string()}; } else if (event.contains("organization")) { full_name = std::string{event.at("organization").as_object().at("login").as_string()}; } else { BOOST_LOG_TRIVIAL(warning) << "No repository or organization detected"; return; } const auto &settings = self->settings_.projects.at(full_name); // Ensure that this sender is authorized to send for this project if (settings.credential_name != notify_user) { BOOST_LOG_TRIVIAL(warning) << "Credential mismatch for " << full_name << " wanted: " << settings.credential_name << " got: " << notify_user; return; } if (not settings.enabled || not settings.events.contains(std::string{event_name})) { // quietly ignore events we don't care about return; } announce_event(self, settings, std::move(full_name), event_name, std::move(event)); } // Process the HTTP request validating its structure and signature. template auto handle_request( std::shared_ptr self, http::request> &&req ) -> http::message_generator { BOOST_LOG_TRIVIAL(info) << "HTTP request " << req.method_string() << " " << req.target(); if (not req.target().starts_with("/notify/")) { BOOST_LOG_TRIVIAL(warning) << "HTTP Bad target: " << req.target(); return simple_response(http::status::not_found, req.version(), req.keep_alive()); } std::string notify_user = req.target().substr(8); if (req.method() != http::verb::post) { BOOST_LOG_TRIVIAL(warning) << "HTTP Bad method: " << req.method_string(); return simple_response(http::status::method_not_allowed, req.version(), req.keep_alive()); } const auto event = req["x-github-event"]; const auto signature = req["x-hub-signature-256"]; if (event.empty() || signature.empty()) { BOOST_LOG_TRIVIAL(warning) << "HTTP Missing headers"; return simple_response(http::status::bad_request, req.version(), req.keep_alive()); } const auto credential_cursor = self->settings_.credentials.find(notify_user); if (credential_cursor == self->settings_.credentials.end()) { BOOST_LOG_TRIVIAL(warning) << "HTTP Unknown user: " << notify_user; return simple_response(http::status::unauthorized, req.version(), req.keep_alive()); } const auto &secret = credential_cursor->second; const auto expected_signature = compute_signature(secret, req.body()); if (signature != expected_signature) { BOOST_LOG_TRIVIAL(warning) << "HTTP Bad signature: " << signature << " expected: " << expected_signature; return simple_response(http::status::unauthorized, req.version(), req.keep_alive()); } try { process_event(self, notify_user, event, boost::json::parse(req.body())); } catch (const boost::system::system_error &e) { BOOST_LOG_TRIVIAL(error) << "HTTP Failed to process event: " << e.what(); return simple_response(http::status::internal_server_error, req.version(), req.keep_alive()); } return simple_response(http::status::ok, req.version(), req.keep_alive()); } // Repeatedly read HTTP requests off a socket and reply to them auto read_loop(tcp::socket socket, std::shared_ptr self) -> boost::asio::awaitable { beast::tcp_stream stream{std::move(socket)}; beast::flat_buffer buffer; http::request req; bool keep_alive = true; while (keep_alive) { req.clear(); stream.expires_after(30s); boost::system::error_code ec; co_await http::async_read(stream, buffer, req, net::redirect_error(net::use_awaitable, ec)); if (ec == http::error::end_of_stream) { break; } else if (ec) { throw boost::system::system_error{ec}; } keep_alive = req.keep_alive(); auto msg = handle_request(self, std::move(req)); co_await beast::async_write(stream, std::move(msg), net::use_awaitable); } stream.socket().shutdown(tcp::socket::shutdown_both); } // Repeatedly accept new connections on a listening socket auto accept_loop( tcp::acceptor acceptor, std::shared_ptr self ) -> boost::asio::awaitable { for (;;) { auto socket = co_await acceptor.async_accept(net::use_awaitable); boost::asio::co_spawn( acceptor.get_executor(), read_loop(std::move(socket), self), report_error ); } } // Launch the listening sockets auto spawn_webhook( boost::asio::io_context &io, const std::shared_ptr webhook ) -> boost::asio::awaitable { tcp::resolver resolver{io}; auto results = co_await resolver.async_resolve(webhook->settings_.host, webhook->settings_.service, tcp::resolver::passive, boost::asio::use_awaitable); for (auto &&result : results) { const auto endpoint = result.endpoint(); BOOST_LOG_TRIVIAL(info) << "HTTP: Listening on " << endpoint; tcp::acceptor acceptor{io}; acceptor.open(endpoint.protocol()); acceptor.set_option(net::socket_base::reuse_address(true)); acceptor.bind(endpoint); acceptor.listen(net::socket_base::max_listen_connections); boost::asio::co_spawn(io, accept_loop(std::move(acceptor), webhook), report_error); } } } // namespace auto start_webhook( boost::asio::io_context &io, const char *webhook_settings_filename ) -> std::shared_ptr { std::ifstream webhook_settings_file{webhook_settings_filename}; if (!webhook_settings_file) { BOOST_LOG_TRIVIAL(error) << "Unable to open webhook settings file"; std::exit(1); } auto webhook_settings = toml::parse(webhook_settings_file); WebhookSettings settings = WebhookSettings::from_toml(webhook_settings); BOOST_LOG_TRIVIAL(info) << "Webhook settings: " << settings.to_toml(); auto webhook = std::make_shared(std::move(settings), webhook_settings_filename); boost::asio::co_spawn(io, spawn_webhook(io, webhook), report_error); return webhook; } auto Webhooks::save_settings() const -> void { std::ofstream webhook_settings_file{settings_file}; if (!webhook_settings_file) { BOOST_LOG_TRIVIAL(error) << "Unable to open webhook settings file"; return; } webhook_settings_file << settings_.to_toml(); } auto ProjectSettings::from_toml(const toml::table &v) -> ProjectSettings { ProjectSettings result; result.channel = v["channel"].value_or(""s); result.credential_name = v["credential_name"].value_or(""s); result.enabled = v["enabled"].value_or(false); if (const auto events = v["events"].as_array()) { for (const auto &event : *events) { result.events.insert(event.value_or(""s)); } } if (const auto accounts = v["authorized_accounts"].as_array()) { for (const auto &account : *accounts) { result.authorized_accounts.insert(account.value_or(""s)); } } return result; } auto ProjectSettings::to_toml() const -> toml::table { toml::array events_array; for (const auto &event : events) { events_array.emplace_back(event); } toml::array authorized_accounts_array; for (const auto &account : authorized_accounts) { authorized_accounts_array.emplace_back(account); } return toml::table{ {"channel", channel}, {"credential_name", credential_name}, {"enabled", enabled}, {"events", std::move(events_array)}, {"authorized_accounts", std::move(authorized_accounts_array)} }; } auto WebhookSettings::from_toml(const toml::table &v) -> WebhookSettings { WebhookSettings result; result.host = v["host"].value_or(""s); result.service = v["service"].value_or("http"s); if (const auto credentials = v["credentials"].as_array()) { for (const auto &credential : *credentials) { if (auto credential_table = credential.as_table()) { result.credentials.emplace( (*credential_table)["name"].value_or(""s), (*credential_table)["key"].value_or(""s) ); } } } if (const auto projects = v["projects"].as_array()) { for (const auto &project : *projects) { if (auto project_table = project.as_table()) { result.projects.emplace( (*project_table)["name"].value_or(""s), ProjectSettings::from_toml(*project_table) ); } } } return result; } auto WebhookSettings::to_toml() const -> toml::table { toml::array credential_tables; for (const auto &[name, key] : credentials) { credential_tables.emplace_back(toml::table{ {"name", name}, {"key", key} }); } toml::array project_tables; for (const auto &[name, project] : projects) { auto tab = project.to_toml(); tab.emplace("name", name); project_tables.emplace_back(std::move(tab)); } return toml::table{ {"host", host}, {"service", service}, {"credentials", std::move(credential_tables)}, {"projects", std::move(project_tables)} }; } // Either emit the event now or save it until a connection is set auto Webhooks::send_notice(std::string_view target, std::string message) -> void { if (connection_) { connection_->send_notice(target, message); } else { events_.emplace_back(std::string(target), std::move(message)); } } auto Webhooks::set_connection(std::shared_ptr connection) -> void { connection_ = std::move(connection); for (auto &&[target, message] : std::move(events_)) { connection_->send_notice(target, message); } events_.clear(); } auto Webhooks::clear_connection() -> void { connection_.reset(); } static auto reply_to(std::shared_ptr webhooks, const myirc::Bot::Command &cmd, std::string message) -> void { if (cmd.target.starts_with("#")) { webhooks->send_notice(cmd.target, std::move(message)); } else { webhooks->send_notice(cmd.nick(), std::move(message)); } } // Operators are authorized for all projects otherwise nickserv account names can be added to individual projects. static auto authorized_for_project( const myirc::Bot::Command &cmd, const ProjectSettings &project, const std::string_view nick ) -> bool { return !cmd.oper.empty() || project.authorized_accounts.contains(std::string{nick}); } std::map, const myirc::Bot::Command &)> webhook_commands{ {"add-credential", [](std::shared_ptr webhooks, const myirc::Bot::Command &cmd) { if (cmd.oper.empty()) { return; } std::istringstream iss{std::string{cmd.arguments}}; std::string name, key; if (iss >> name >> key) { webhooks->settings_.credentials.insert_or_assign(name, key); webhooks->save_settings(); reply_to(webhooks, cmd, "Added credential " + name); } }}, {"drop-credential", [](std::shared_ptr webhooks, const myirc::Bot::Command &cmd) { if (cmd.oper.empty()) { return; } std::istringstream iss{std::string{cmd.arguments}}; std::string name; if (iss >> name) { webhooks->settings_.credentials.erase(name); webhooks->save_settings(); reply_to(webhooks, cmd, "Dropped credential " + name); } }}, {"enable-project", [](std::shared_ptr webhooks, const myirc::Bot::Command &cmd) { std::istringstream iss{std::string{cmd.arguments}}; std::string name; if (iss >> name) { auto &project = webhooks->settings_.projects.at(name); if (not authorized_for_project(cmd, project, cmd.account)) { return; } project.enabled = true; webhooks->save_settings(); reply_to(webhooks, cmd, "Enabled project " + name); } }}, {"disable-project", [](std::shared_ptr webhooks, const myirc::Bot::Command &cmd) { std::istringstream iss{std::string{cmd.arguments}}; std::string name; if (iss >> name) { auto &project = webhooks->settings_.projects.at(name); if (not authorized_for_project(cmd, project, cmd.account)) { return; } project.enabled = false; webhooks->save_settings(); reply_to(webhooks, cmd, "Disabled project " + name); } }}, {"add-access", [](std::shared_ptr webhooks, const myirc::Bot::Command &cmd) { if (cmd.oper.empty()) { return; } std::istringstream iss{std::string{cmd.arguments}}; std::string name, account; if (iss >> name >> account) { auto &project = webhooks->settings_.projects.at(name); if (not authorized_for_project(cmd, project, cmd.account)) { return; } auto [_, inserted] = project.authorized_accounts.insert(account); if (inserted) { webhooks->save_settings(); reply_to(webhooks, cmd, "Access added"); } else { reply_to(webhooks, cmd, "Access already set"); } } }}, {"drop-access", [](std::shared_ptr webhooks, const myirc::Bot::Command &cmd) { if (cmd.oper.empty()) { return; } std::istringstream iss{std::string{cmd.arguments}}; std::string name, account; if (iss >> name >> account) { auto &project = webhooks->settings_.projects.at(name); if (not authorized_for_project(cmd, project, cmd.account)) { return; } auto removed = project.authorized_accounts.erase(account); if (removed) { webhooks->save_settings(); reply_to(webhooks, cmd, "Access dropped"); } else { reply_to(webhooks, cmd, "Access not found"); } } }}, };