xbot/driver/web.cpp

605 lines
19 KiB
C++
Raw Normal View History

2025-02-01 11:04:33 -08:00
#include "web.hpp"
2025-02-01 20:57:57 -08:00
#include <boost/beast.hpp>
2025-02-02 12:40:55 -08:00
#include <boost/json.hpp>
2025-02-01 20:57:57 -08:00
#include <boost/log/trivial.hpp>
2025-02-02 12:40:55 -08:00
#include <boost/system/system_error.hpp>
#include <openssl/hmac.h>
2025-02-01 20:57:57 -08:00
#include <chrono>
#include <fstream>
2025-02-02 12:40:55 -08:00
#include <sstream>
2025-02-01 20:57:57 -08:00
#include <vector>
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>
using tcp = net::ip::tcp; // from <boost/asio/ip/tcp.hpp>
using namespace std::literals;
namespace {
2025-02-02 19:46:11 -08:00
std::map<std::string, void(*)(std::shared_ptr<Webhooks>, const ProjectSettings &, std::string_view, const boost::json::object &)> formatters {
{"push", [](std::shared_ptr<Webhooks> webhooks, const ProjectSettings &project, std::string_view full_name, const boost::json::object &body) {
webhooks->send_notice(project.channel, "push");
}},
};
2025-02-02 16:20:52 -08:00
// Used as the completion handler for coroutines in this module to print
// failure reasons to the log.
2025-02-01 20:57:57 -08:00
auto report_error(std::exception_ptr eptr) -> void
{
if (eptr)
{
try
{
std::rethrow_exception(eptr);
}
catch (const std::exception &e)
{
2025-02-02 16:20:52 -08:00
BOOST_LOG_TRIVIAL(error) << "HTTP coroutine failed: " << e.what();
2025-02-01 20:57:57 -08:00
}
}
}
2025-02-02 16:20:52 -08:00
// Construct a simple, empty reply using the given status code.
auto simple_response(
http::status status,
unsigned version,
bool keep_alive
) -> http::message_generator
2025-02-02 12:40:55 -08:00
{
http::response<http::string_body> res{status, version};
res.set(http::field::server, BOOST_BEAST_VERSION_STRING);
res.keep_alive(keep_alive);
res.content_length(0);
return res;
}
2025-02-02 16:20:52 -08:00
// Compute the expected signature string for the POST body.
auto compute_signature(const std::string_view secret, const std::string_view body) -> std::string
2025-02-02 12:40:55 -08:00
{
unsigned int digest_length = EVP_MAX_MD_SIZE;
unsigned char digest[EVP_MAX_MD_SIZE];
2025-02-05 09:24:47 -08:00
HMAC(EVP_sha256(), secret.data(), static_cast<int>(secret.size()), reinterpret_cast<const unsigned char *>(body.data()), body.size(), digest, &digest_length);
2025-02-02 12:40:55 -08:00
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<int>(digest[i]);
}
return ss.str();
}
2025-02-02 16:20:52 -08:00
// This event is ready to actually announce
auto announce_event(
std::shared_ptr<Webhooks> 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(
2025-02-02 15:02:08 -08:00
std::shared_ptr<Webhooks> self,
2025-02-02 12:40:55 -08:00
const std::string_view notify_user,
2025-02-02 19:46:11 -08:00
const std::string event_name,
2025-02-02 12:40:55 -08:00
const boost::json::value &json
) -> void
{
2025-02-02 16:20:52 -08:00
auto &event = json.as_object();
2025-02-02 12:40:55 -08:00
2025-02-02 16:20:52 -08:00
// Determine the project name. Repositories use: user/project. Organization events use: organization
2025-02-02 12:40:55 -08:00
std::string full_name;
2025-02-02 16:20:52 -08:00
if (event.contains("repository"))
2025-02-02 12:40:55 -08:00
{
2025-02-02 16:20:52 -08:00
full_name = std::string{event.at("repository").as_object().at("full_name").as_string()};
2025-02-02 12:40:55 -08:00
}
2025-02-02 16:20:52 -08:00
else if (event.contains("organization"))
2025-02-02 12:40:55 -08:00
{
2025-02-02 16:20:52 -08:00
full_name = std::string{event.at("organization").as_object().at("login").as_string()};
2025-02-02 12:40:55 -08:00
}
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;
}
2025-02-02 19:46:11 -08:00
if (not settings.enabled || not settings.events.contains(event_name))
2025-02-02 12:40:55 -08:00
{
2025-02-02 16:20:52 -08:00
// quietly ignore events we don't care about
2025-02-02 12:40:55 -08:00
return;
}
2025-02-02 19:46:11 -08:00
auto formatter_cursor = formatters.find(event_name);
if (formatter_cursor != formatters.end()) {
formatter_cursor->second(self, settings, full_name, event);
}
2025-02-02 12:40:55 -08:00
}
2025-02-02 16:20:52 -08:00
// Process the HTTP request validating its structure and signature.
2025-02-01 20:57:57 -08:00
template <class Body, class Allocator>
2025-02-02 16:20:52 -08:00
auto handle_request(
2025-02-02 15:02:08 -08:00
std::shared_ptr<Webhooks> self,
2025-02-01 20:57:57 -08:00
http::request<Body, http::basic_fields<Allocator>> &&req
) -> http::message_generator
{
2025-02-02 12:40:55 -08:00
BOOST_LOG_TRIVIAL(info) << "HTTP request " << req.method_string() << " " << req.target();
2025-02-01 20:57:57 -08:00
2025-02-02 12:40:55 -08:00
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());
}
2025-02-01 20:57:57 -08:00
2025-02-02 12:40:55 -08:00
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());
}
2025-02-01 20:57:57 -08:00
2025-02-02 12:40:55 -08:00
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());
2025-02-01 20:57:57 -08:00
}
2025-02-02 16:20:52 -08:00
// Repeatedly read HTTP requests off a socket and reply to them
2025-02-02 15:02:08 -08:00
auto read_loop(tcp::socket socket, std::shared_ptr<Webhooks> self) -> boost::asio::awaitable<void>
2025-02-01 20:57:57 -08:00
{
beast::tcp_stream stream{std::move(socket)};
beast::flat_buffer buffer;
http::request<http::string_body> req;
2025-02-02 16:20:52 -08:00
bool keep_alive = true;
2025-02-01 20:57:57 -08:00
2025-02-02 16:20:52 -08:00
while (keep_alive)
2025-02-01 20:57:57 -08:00
{
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)
{
2025-02-02 16:20:52 -08:00
break;
2025-02-01 20:57:57 -08:00
}
else if (ec)
{
2025-02-02 12:40:55 -08:00
throw boost::system::system_error{ec};
2025-02-01 20:57:57 -08:00
}
2025-02-02 16:20:52 -08:00
keep_alive = req.keep_alive();
2025-02-01 20:57:57 -08:00
auto msg = handle_request(self, std::move(req));
co_await beast::async_write(stream, std::move(msg), net::use_awaitable);
}
2025-02-02 16:20:52 -08:00
stream.socket().shutdown(tcp::socket::shutdown_both);
2025-02-01 20:57:57 -08:00
}
2025-02-02 16:20:52 -08:00
// Repeatedly accept new connections on a listening socket
2025-02-01 20:57:57 -08:00
auto accept_loop(
tcp::acceptor acceptor,
2025-02-02 15:02:08 -08:00
std::shared_ptr<Webhooks> self
2025-02-01 20:57:57 -08:00
) -> boost::asio::awaitable<void>
{
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
);
}
}
2025-02-02 16:20:52 -08:00
// Launch the listening sockets
2025-02-01 20:57:57 -08:00
auto spawn_webhook(
boost::asio::io_context &io,
2025-02-02 15:02:08 -08:00
const std::shared_ptr<Webhooks> webhook
2025-02-01 20:57:57 -08:00
) -> boost::asio::awaitable<void>
{
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,
2025-02-02 12:40:55 -08:00
const char *webhook_settings_filename
2025-02-02 15:02:08 -08:00
) -> std::shared_ptr<Webhooks>
2025-02-01 20:57:57 -08:00
{
2025-02-03 09:35:50 -08:00
auto webhook = std::make_shared<Webhooks>(webhook_settings_filename);
webhook->load_settings();
boost::asio::co_spawn(io, spawn_webhook(io, webhook), report_error);
return webhook;
}
auto Webhooks::load_settings() -> void
{
std::ifstream webhook_settings_file{settings_filename_};
2025-02-01 20:57:57 -08:00
if (!webhook_settings_file)
{
BOOST_LOG_TRIVIAL(error) << "Unable to open webhook settings file";
}
auto webhook_settings = toml::parse(webhook_settings_file);
2025-02-03 09:35:50 -08:00
settings_ = WebhookSettings::from_toml(webhook_settings);
2025-02-01 20:57:57 -08:00
}
2025-02-02 15:02:08 -08:00
auto Webhooks::save_settings() const -> void
2025-02-01 20:57:57 -08:00
{
2025-02-03 09:35:50 -08:00
std::ofstream webhook_settings_file{settings_filename_};
2025-02-02 14:56:34 -08:00
if (!webhook_settings_file)
{
BOOST_LOG_TRIVIAL(error) << "Unable to open webhook settings file";
return;
}
2025-02-03 09:35:50 -08:00
webhook_settings_file << settings_.to_toml() << "\n";
2025-02-01 20:57:57 -08:00
}
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);
}
2025-02-02 12:40:55 -08:00
2025-02-01 20:57:57 -08:00
toml::array authorized_accounts_array;
for (const auto &account : authorized_accounts)
{
authorized_accounts_array.emplace_back(account);
}
2025-02-02 12:40:55 -08:00
2025-02-01 20:57:57 -08:00
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);
2025-02-02 12:40:55 -08:00
2025-02-01 20:57:57 -08:00
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),
2025-02-02 12:40:55 -08:00
(*credential_table)["key"].value_or(""s)
);
2025-02-01 20:57:57 -08:00
}
}
}
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),
2025-02-02 12:40:55 -08:00
ProjectSettings::from_toml(*project_table)
);
2025-02-01 20:57:57 -08:00
}
}
}
return result;
}
auto WebhookSettings::to_toml() const -> toml::table
{
toml::array credential_tables;
for (const auto &[name, key] : credentials)
{
2025-02-02 12:40:55 -08:00
credential_tables.emplace_back(toml::table{
2025-02-01 20:57:57 -08:00
{"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)}
};
2025-02-02 12:40:55 -08:00
}
2025-02-02 14:56:34 -08:00
// Either emit the event now or save it until a connection is set
2025-02-02 16:20:52 -08:00
auto Webhooks::send_notice(std::string_view target, std::string message) -> void
2025-02-02 14:56:34 -08:00
{
2025-02-03 09:35:50 -08:00
if (client_)
2025-02-02 14:56:34 -08:00
{
2025-02-05 09:24:47 -08:00
client_->send_notice(target, message);
2025-02-02 14:56:34 -08:00
}
else
{
2025-02-02 16:20:52 -08:00
events_.emplace_back(std::string(target), std::move(message));
2025-02-02 14:56:34 -08:00
}
}
2025-02-03 09:35:50 -08:00
auto Webhooks::set_client(std::shared_ptr<myirc::Client> client) -> void
2025-02-02 14:56:34 -08:00
{
2025-02-03 09:35:50 -08:00
client_ = std::move(client);
2025-02-02 16:20:52 -08:00
for (auto &&[target, message] : std::move(events_))
2025-02-02 14:56:34 -08:00
{
2025-02-05 09:24:47 -08:00
client_->send_notice(target, message);
2025-02-02 14:56:34 -08:00
}
events_.clear();
}
2025-02-03 09:35:50 -08:00
auto Webhooks::clear_client() -> void
2025-02-02 14:56:34 -08:00
{
2025-02-03 09:35:50 -08:00
client_.reset();
2025-02-02 14:56:34 -08:00
}
2025-02-02 15:02:08 -08:00
static auto reply_to(std::shared_ptr<Webhooks> webhooks, const myirc::Bot::Command &cmd, std::string message) -> void
2025-02-02 14:56:34 -08:00
{
if (cmd.target.starts_with("#"))
{
2025-02-02 16:20:52 -08:00
webhooks->send_notice(cmd.target, std::move(message));
2025-02-02 14:56:34 -08:00
}
else
{
2025-02-02 16:20:52 -08:00
webhooks->send_notice(cmd.nick(), std::move(message));
2025-02-02 14:56:34 -08:00
}
}
2025-02-02 16:20:52 -08:00
// 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
2025-02-02 14:56:34 -08:00
{
2025-02-02 16:20:52 -08:00
return !cmd.oper.empty() || project.authorized_accounts.contains(std::string{nick});
2025-02-02 14:56:34 -08:00
}
2025-02-02 15:02:08 -08:00
std::map<std::string, void (*)(std::shared_ptr<Webhooks>, const myirc::Bot::Command &)> webhook_commands{
2025-02-03 09:35:50 -08:00
{"announce", [](std::shared_ptr<Webhooks> webhooks, const myirc::Bot::Command &cmd) {
2025-02-02 14:56:34 -08:00
std::istringstream iss{std::string{cmd.arguments}};
2025-02-03 09:35:50 -08:00
std::string name, mode;
if (iss >> name >> mode)
2025-02-02 14:56:34 -08:00
{
2025-02-02 19:20:15 -08:00
auto &project = webhooks->settings_.projects.at(name);
2025-02-02 16:20:52 -08:00
if (not authorized_for_project(cmd, project, cmd.account))
2025-02-02 14:56:34 -08:00
{
return;
}
2025-02-03 09:35:50 -08:00
if (mode == "on") {
project.enabled = true;
reply_to(webhooks, cmd, "Enabled project " + name);
} else if (mode == "off") {
project.enabled = false;
reply_to(webhooks, cmd, "Disabled project " + name);
} else {
2025-02-02 14:56:34 -08:00
return;
}
webhooks->save_settings();
}
}},
2025-02-03 09:35:50 -08:00
{"event", [](std::shared_ptr<Webhooks> webhooks, const myirc::Bot::Command &cmd) {
2025-02-02 19:46:11 -08:00
std::istringstream iss{std::string{cmd.arguments}};
2025-02-03 09:35:50 -08:00
std::string name, mode;
if (iss >> name >> mode)
2025-02-02 19:46:11 -08:00
{
auto &project = webhooks->settings_.projects.at(name);
if (not authorized_for_project(cmd, project, cmd.account))
{
return;
}
2025-02-03 09:35:50 -08:00
if (mode == "list") {
std::stringstream ss;
ss << "Events for " << name << ":";
for (auto &&event : project.events) {
ss << " " << event;
2025-02-02 19:46:11 -08:00
}
2025-02-03 09:35:50 -08:00
reply_to(webhooks, cmd, ss.str());
2025-02-02 19:46:11 -08:00
return;
}
2025-02-03 09:35:50 -08:00
unsigned n_added = 0, n_removed = 0, n_skipped = 0, n_unknown = 0;
if (mode == "add") {
while (iss >> name) {
if (formatters.contains(name)) {
const auto [_, added] = project.events.insert(name);
if (added) { n_added++; } else { n_skipped++; }
} else {
n_unknown++;
}
}
} else if (mode == "del") {
while (iss >> name) {
if (formatters.contains(name)) {
const auto removed = project.events.erase(name);
if (removed) { n_removed++; } else { n_skipped++; }
} else {
n_unknown++;
}
2025-02-02 19:46:11 -08:00
}
}
webhooks->save_settings();
std::stringstream ss;
2025-02-03 09:35:50 -08:00
ss << "Events updated:";
if (n_added) { ss << " added " << n_added; }
if (n_removed) { ss << " removed " << n_removed; }
if (n_skipped) { ss << " skipped " << n_skipped; }
if (n_unknown) { ss << " unknown " << n_unknown; }
2025-02-02 19:46:11 -08:00
reply_to(webhooks, cmd, ss.str());
}
}},
2025-02-03 09:35:50 -08:00
{"auth", [](std::shared_ptr<Webhooks> webhooks, const myirc::Bot::Command &cmd) {
2025-02-02 19:20:15 -08:00
if (cmd.oper.empty())
{
return;
}
std::istringstream iss{std::string{cmd.arguments}};
2025-02-03 09:35:50 -08:00
std::string name, mode;
if (iss >> name >> mode)
2025-02-02 19:20:15 -08:00
{
auto &project = webhooks->settings_.projects.at(name);
2025-02-03 09:35:50 -08:00
if (mode == "list") {
std::stringstream ss;
ss << "Authorized accounts:";
for (auto &&event : project.authorized_accounts) {
ss << " " << event;
}
reply_to(webhooks, cmd, ss.str());
2025-02-02 19:20:15 -08:00
return;
}
2025-02-03 09:35:50 -08:00
unsigned n_added = 0, n_removed = 0, n_skipped = 0;
if (mode == "add") {
while (iss >> name) {
const auto [_, added] = project.authorized_accounts.insert(name);
if (added) { n_added++; } else { n_skipped++; }
}
} else if (mode == "del") {
while (iss >> name) {
const auto removed = project.authorized_accounts.erase(name);
if (removed) { n_removed++; } else { n_skipped++; }
}
2025-02-02 19:20:15 -08:00
}
2025-02-03 09:35:50 -08:00
webhooks->save_settings();
std::stringstream ss;
ss << "Authorized accounts updated:";
if (n_added) { ss << " added " << n_added; }
if (n_removed) { ss << " removed " << n_removed; }
if (n_skipped) { ss << " skipped " << n_skipped; }
reply_to(webhooks, cmd, ss.str());
2025-02-02 19:20:15 -08:00
}
}},
2025-02-03 09:35:50 -08:00
{"setchannel", [](std::shared_ptr<Webhooks> webhooks, const myirc::Bot::Command &cmd) {
2025-02-02 19:46:11 -08:00
if (cmd.oper.empty())
{
return;
}
std::istringstream iss{std::string{cmd.arguments}};
std::string name, channel;
if (iss >> name >> channel)
{
auto &project = webhooks->settings_.projects.at(name);
project.channel = channel;
webhooks->save_settings();
reply_to(webhooks, cmd, "Channel assigned");
}
}},
2025-02-03 09:35:50 -08:00
{"rehash", [](std::shared_ptr<Webhooks> webhooks, const myirc::Bot::Command &cmd) {
if (cmd.oper.empty())
{
return;
}
webhooks->load_settings();
reply_to(webhooks, cmd, "Rehashed");
}},
2025-02-02 14:56:34 -08:00
};