oxen-core/src/rpc/lmq_server.cpp

380 lines
17 KiB
C++

#include "lmq_server.h"
#include "oxenmq/oxenmq.h"
#undef OXEN_DEFAULT_LOG_CATEGORY
#define OXEN_DEFAULT_LOG_CATEGORY "daemon.rpc"
namespace cryptonote { namespace rpc {
using oxenmq::AuthLevel;
namespace {
// TODO: all of this --lmq-blah options really should be renamed to --omq-blah, but then we *also*
// need some sort of backwards compatibility shim, and that is a nuissance.
const command_line::arg_descriptor<std::vector<std::string>> arg_omq_public{
"lmq-public",
"Adds a public, unencrypted OxenMQ RPC listener (with restricted capabilities) at the given "
"address; can be specified multiple times. Examples: tcp://0.0.0.0:5555 (listen on port 5555), "
"tcp://198.51.100.42:5555 (port 5555 on specific IPv4 address), tcp://[::]:5555, "
"tcp://[2001:db8::abc]:5555 (IPv6), or ipc:///path/to/socket to listen on a unix domain socket"};
const command_line::arg_descriptor<std::vector<std::string>> arg_omq_curve_public{
"lmq-curve-public",
"Adds a curve-encrypted OxenMQ RPC listener at the given address that accepts (restricted) rpc "
"commands from any client. Clients must already know this server's public x25519 key to "
"establish an encrypted connection."};
const command_line::arg_descriptor<std::vector<std::string>> arg_omq_curve{
"lmq-curve",
"Adds a curve-encrypted OxenMQ RPC listener at the given address that only accepts client connections from whitelisted client x25519 pubkeys. "
"Clients must already know this server's public x25519 key to establish an encrypted connection. When running in service node mode "
"the quorumnet port is already listening as if specified with --lmq-curve."};
const command_line::arg_descriptor<std::vector<std::string>> arg_omq_admin{
"lmq-admin",
"Adds an x25519 pubkey of a client permitted to connect to the --lmq-curve, --lmq-curve-public, or quorumnet address(es) with unrestricted (admin) capabilities."};
const command_line::arg_descriptor<std::vector<std::string>> arg_omq_user{
"lmq-user",
"Specifies an x25519 pubkey of a client permitted to connect to the --lmq-curve or quorumnet address(es) with restricted capabilities"};
const command_line::arg_descriptor<std::vector<std::string>> arg_omq_local_control{
"lmq-local-control",
"Adds an unencrypted OxenMQ RPC listener with full, unrestricted capabilities and no authentication at the given address. "
#ifndef _WIN32
"Listens at ipc://<data-dir>/oxend.sock if not specified. Specify 'none' to disable the default. "
#endif
"WARNING: Do not use this on a publicly accessible address!"};
#ifndef _WIN32
const command_line::arg_descriptor<std::string> arg_omq_umask{
"lmq-umask",
"Sets the umask to apply to any listening ipc:///path/to/sock LMQ sockets, in octal.",
"0007"};
#endif
void check_omq_listen_addr(std::string_view addr) {
// Crude check for basic validity; you can specify all sorts of invalid things, but at least
// we can check the prefix for something that looks zmq-y.
if (addr.size() < 7 || (addr.substr(0, 6) != "tcp://" && addr.substr(0, 6) != "ipc://"))
throw std::runtime_error("Error: omq listen address '" + std::string(addr) + "' is invalid: expected tcp://IP:PORT, tcp://[IPv6]:PORT or ipc:///path/to/socket");
}
auto as_x_pubkeys(const std::vector<std::string>& pk_strings) {
std::vector<crypto::x25519_public_key> pks;
pks.reserve(pk_strings.size());
for (const auto& pkstr : pk_strings) {
if (pkstr.size() != 64 || !oxenc::is_hex(pkstr))
throw std::runtime_error("Invalid LMQ login pubkey: '" + pkstr + "'; expected 64-char hex pubkey");
pks.emplace_back();
oxenc::to_hex(pkstr.begin(), pkstr.end(), reinterpret_cast<char *>(&pks.back()));
}
return pks;
}
// LMQ RPC responses consist of [CODE, DATA] for code we (partially) mimic HTTP error codes: 200
// means success, anything else means failure. (We don't have codes for Forbidden or Not Found
// because those happen at the LMQ protocol layer).
constexpr std::string_view
LMQ_OK{"200"sv},
LMQ_BAD_REQUEST{"400"sv},
LMQ_ERROR{"500"sv};
} // end anonymous namespace
void init_omq_options(boost::program_options::options_description& desc)
{
command_line::add_arg(desc, arg_omq_public);
command_line::add_arg(desc, arg_omq_curve_public);
command_line::add_arg(desc, arg_omq_curve);
command_line::add_arg(desc, arg_omq_admin);
command_line::add_arg(desc, arg_omq_user);
command_line::add_arg(desc, arg_omq_local_control);
#ifndef _WIN32
command_line::add_arg(desc, arg_omq_umask);
#endif
}
omq_rpc::omq_rpc(cryptonote::core& core, core_rpc_server& rpc, const boost::program_options::variables_map& vm)
: core_{core}, rpc_{rpc}
{
auto& omq = core.get_omq();
auto& auth = core._omq_auth_level_map();
// Set up any requested listening sockets. (Note: if we are a service node, we'll already have
// the quorumnet listener set up in cryptonote_core).
for (const auto &addr : command_line::get_arg(vm, arg_omq_public)) {
check_omq_listen_addr(addr);
MGINFO("LMQ listening on " << addr << " (public unencrypted)");
omq.listen_plain(addr,
[&core](std::string_view ip, std::string_view pk, bool /*sn*/) { return core.omq_allow(ip, pk, AuthLevel::basic); });
}
for (const auto &addr : command_line::get_arg(vm, arg_omq_curve_public)) {
check_omq_listen_addr(addr);
MGINFO("LMQ listening on " << addr << " (public curve)");
omq.listen_curve(addr,
[&core](std::string_view ip, std::string_view pk, bool /*sn*/) { return core.omq_allow(ip, pk, AuthLevel::basic); });
}
for (const auto &addr : command_line::get_arg(vm, arg_omq_curve)) {
check_omq_listen_addr(addr);
MGINFO("LMQ listening on " << addr << " (curve restricted)");
omq.listen_curve(addr,
[&core](std::string_view ip, std::string_view pk, bool /*sn*/) { return core.omq_allow(ip, pk, AuthLevel::denied); });
}
auto locals = command_line::get_arg(vm, arg_omq_local_control);
if (locals.empty()) {
// FIXME: this requires unix sockets and so probably won't work on older Windows 10 or pre-Win10
// windows. In theory we could do some runtime detection to see if the Windows version is new
// enough to support unix domain sockets, but for now the Windows default is just "don't listen"
#ifndef _WIN32
// Push default .oxen/oxend.sock
locals.push_back("ipc://" + core.get_config_directory().u8string() + "/" + CRYPTONOTE_NAME + "d.sock");
// Pushing old default lokid.sock onto the list. A symlink from .loki -> .oxen so the user should be able
// to communicate via the old .loki/lokid.sock
locals.push_back("ipc://" + core.get_config_directory().u8string() + "/lokid.sock");
#endif
} else if (locals.size() == 1 && locals[0] == "none") {
locals.clear();
}
for (const auto &addr : locals) {
check_omq_listen_addr(addr);
MGINFO("LMQ listening on " << addr << " (unauthenticated local admin)");
omq.listen_plain(addr,
[&core](std::string_view ip, std::string_view pk, bool /*sn*/) { return core.omq_allow(ip, pk, AuthLevel::admin); });
}
#ifndef _WIN32
auto umask_str = command_line::get_arg(vm, arg_omq_umask);
try {
int umask = -1;
size_t len = 0;
umask = std::stoi(umask_str, &len, 8);
if (len != umask_str.size())
throw std::invalid_argument("not an octal value");
if (umask < 0 || umask > 0777)
throw std::invalid_argument("invalid umask value");
omq.STARTUP_UMASK = umask;
} catch (const std::exception& e) {
throw std::invalid_argument("Invalid --lmq-umask value '" + umask_str + "': value must be an octal value between 0 and 0777");
}
#endif
// Insert our own pubkey so that, e.g., console commands from localhost automatically get full access
{
crypto::x25519_public_key my_pubkey;
const std::string& pk = omq.get_pubkey();
std::copy(pk.begin(), pk.end(), my_pubkey.data);
auth.emplace(std::move(my_pubkey), AuthLevel::admin);
}
// User-specified admin/user pubkeys
for (auto& pk : as_x_pubkeys(command_line::get_arg(vm, arg_omq_admin)))
auth.emplace(std::move(pk), AuthLevel::admin);
for (auto& pk : as_x_pubkeys(command_line::get_arg(vm, arg_omq_user)))
auth.emplace(std::move(pk), AuthLevel::basic);
// basic (non-admin) rpc commands go into the "rpc." category (e.g. 'rpc.get_info')
omq.add_category("rpc", AuthLevel::basic, 0 /*no reserved threads*/, 1000 /*max queued requests*/);
// Admin rpc commands go into "admin.". We also always keep one (potential) thread reserved for
// admin RPC commands; that way even if there are loads of basic commands being processed we'll
// still have room to invoke an admin command without waiting for the basic ones to finish.
constexpr unsigned int admin_reserved_threads = 1;
omq.add_category("admin", AuthLevel::admin, admin_reserved_threads);
for (auto& cmd : rpc_commands) {
omq.add_request_command(cmd.second->is_public ? "rpc" : "admin", cmd.first,
[name=std::string_view{cmd.first}, &call=*cmd.second, this](oxenmq::Message& m) {
if (m.data.size() > 1)
m.send_reply(LMQ_BAD_REQUEST, "Bad request: RPC commands must have at most one data part "
"(received " + std::to_string(m.data.size()) + ")");
rpc_request request{};
request.context.admin = m.access.auth >= AuthLevel::admin;
request.context.source = rpc_source::omq;
request.context.remote = m.remote;
request.body = m.data.empty() ? ""sv : m.data[0];
try {
m.send_reply(LMQ_OK, call.invoke(std::move(request), rpc_));
return;
} catch (const parse_error& e) {
// This isn't really WARNable as it's the client fault; log at info level instead.
//
// TODO: for various parsing errors there are still some stupid forced ERROR-level
// warnings that get generated deep inside epee, for example when passing a string or
// number instead of a JSON object. If you want to find some, `grep number2 epee` (for
// real).
MINFO("LMQ RPC request '" << (call.is_public ? "rpc." : "admin.") << name << "' called with invalid/unparseable data: " << e.what());
m.send_reply(LMQ_BAD_REQUEST, "Unable to parse request: "s + e.what());
return;
} catch (const rpc_error& e) {
MWARNING("LMQ RPC request '" << (call.is_public ? "rpc." : "admin.") << name << "' failed with: " << e.what());
m.send_reply(LMQ_ERROR, e.what());
return;
} catch (const std::exception& e) {
MWARNING("LMQ RPC request '" << (call.is_public ? "rpc." : "admin.") << name << "' "
"raised an exception: " << e.what());
} catch (...) {
MWARNING("LMQ RPC request '" << (call.is_public ? "rpc." : "admin.") << name << "' "
"raised an unknown exception");
}
// Don't include the exception message in case it contains something that we don't want go
// back to the user. If we want to support it eventually we could add some sort of
// `rpc::user_visible_exception` that carries a message to send back to the user.
m.send_reply(LMQ_ERROR, "An exception occured while processing your request");
});
}
// Subscription commands
// The "subscribe" category is for public subscriptions; i.e. anyone on a public RPC node, or
// anyone on a private RPC node with public access level.
omq.add_category("sub", AuthLevel::basic);
// TX mempool subscriptions: [sub.mempool, blink] or [sub.mempool, all] to subscribe to new
// approved mempool blink txes, or to all new mempool txes. You get back a reply of "OK" or
// "ALREADY" -- the former indicates that you are newly subscribed for tx updates (either because
// you weren't subscribed before, or your subscription type changed); the latter indicates that
// you were already subscribed for the request tx types. Any other value should be considered an
// error.
//
// Subscriptions expire after 30 minutes. It is recommended that the client periodically
// re-subscribe on a much shorter interval than this (perhaps once per minute) and use "OK"
// replies as a indicator that there was some server-side interruption (such as a restart) that
// might necessitate the client rechecking the mempool.
//
// When a tx arrives the node sends back [notify.mempool, txhash, txblob] every time a new
// transaction is added to the mempool (minus some additions that aren't really new transactions
// such as txes that came from an existing block during a rollback). Note that both txhash and
// txblob are binary: in particular, txhash is *not* hex-encoded.
//
omq.add_request_command("sub", "mempool", [this](oxenmq::Message& m) {
if (m.data.size() != 1) {
m.send_reply("Invalid subscription request: no subscription type given");
return;
}
mempool_sub_type sub_type;
if (m.data[0] == "blink"sv)
sub_type = mempool_sub_type::blink;
else if (m.data[0] == "all"sv)
sub_type = mempool_sub_type::all;
else {
m.send_reply("Invalid mempool subscription type '" + std::string{m.data[0]} + "'");
return;
}
{
std::unique_lock lock{subs_mutex_};
auto expiry = std::chrono::steady_clock::now() + 30min;
auto result = mempool_subs_.emplace(m.conn, mempool_sub{expiry, sub_type});
if (!result.second) {
result.first->second.expiry = expiry;
if (result.first->second.type == sub_type) {
MTRACE("Renewed mempool subscription request from conn id " << m.conn << " @ " << m.remote);
m.send_reply("ALREADY");
return;
}
result.first->second.type = sub_type;
}
MDEBUG("New " << (sub_type == mempool_sub_type::blink ? "blink" : "all") << " mempool subscription request from conn " << m.conn << " @ " << m.remote);
m.send_reply("OK");
}
});
// New block subscriptions: [sub.block]. This sends a notification every time a new block is
// added to the blockchain.
//
// TODO: make this support [sub.block, sn] so that we can receive notification only for blocks
// that change the SN composition.
//
// The subscription request returns the current [height, blockhash] as a reply.
//
// The block notification for new blocks consists of a message [notify.block, height, blockhash]
// containing the latest height/hash. (Note that blockhash is the hash in bytes, *not* the hex
// encoded block hash).
omq.add_request_command("sub", "block", [this](oxenmq::Message& m) {
std::unique_lock lock{subs_mutex_};
auto expiry = std::chrono::steady_clock::now() + 30min;
auto result = block_subs_.emplace(m.conn, block_sub{expiry});
if (!result.second) {
result.first->second.expiry = expiry;
MTRACE("Renewed block subscription request from conn id " << m.conn << " @ " << m.remote);
m.send_reply("ALREADY");
} else {
MDEBUG("New block subscription request from conn " << m.conn << " @ " << m.remote);
m.send_reply("OK");
}
});
core_.get_blockchain_storage().hook_block_added(*this);
core_.get_pool().add_notify([this](const crypto::hash& id, const transaction& tx, const std::string& blob, const tx_pool_options& opts) {
send_mempool_notifications(id, tx, blob, opts);
});
}
template <typename Mutex, typename Subs, typename Call>
static void send_notifies(Mutex& mutex, Subs& subs, const char* desc, Call call) {
std::vector<oxenmq::ConnectionID> remove;
{
std::shared_lock lock{mutex};
if (subs.empty())
return;
auto now = std::chrono::steady_clock::now();
for (const auto& sub_pair : subs) {
auto& conn = sub_pair.first;
auto& sub = sub_pair.second;
if (sub.expiry < now) {
remove.push_back(conn);
continue;
} else {
call(conn, sub);
}
}
}
if (remove.empty())
return;
std::unique_lock lock{mutex};
auto now = std::chrono::steady_clock::now();
for (auto& conn : remove) {
auto it = subs.find(conn);
if (it != subs.end() && it->second.expiry < now /* recheck: client might have resubscribed in between locks */) {
MDEBUG("Removing " << conn << " from " << desc << " subscriptions: subscription timed out");
subs.erase(it);
}
}
}
bool omq_rpc::block_added(const block& block, const std::vector<transaction>& txs, const checkpoint_t *)
{
auto& omq = core_.get_omq();
std::string height = std::to_string(get_block_height(block));
send_notifies(subs_mutex_, block_subs_, "block", [&](auto& conn, auto& sub) {
omq.send(conn, "notify.block", height, std::string_view{block.hash.data, sizeof(block.hash.data)});
});
return true;
}
void omq_rpc::send_mempool_notifications(const crypto::hash& id, const transaction& tx, const std::string& blob, const tx_pool_options& opts)
{
auto& omq = core_.get_omq();
send_notifies(subs_mutex_, mempool_subs_, "mempool", [&](auto& conn, auto& sub) {
if (sub.type == mempool_sub_type::all || opts.approved_blink)
omq.send(conn, "notify.mempool", std::string_view{id.data, sizeof(id.data)}, blob);
});
}
}} // namespace cryptonote::rpc