mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-31 14:01:14 +00:00
* feat: group-shareable local sockets, stale-socket reaper, bind-failure detection
The QtRO local transport binds each module's unix socket at 0777 & ~umask
(0755) with no way for a second OS user to reach it, discards the listen
result so a failed bind surfaces only as clients hanging, and never cleans up
the socket file — a hard-killed logos_host leaks it forever.
Add a Qt-free helper (logos_socket_paths.{h,cpp}) usable from both the qt_remote
and plain transport paths:
- applySocketPerms(path): chgrp + chmod a bound socket per LOGOS_SOCKET_GROUP /
LOGOS_SOCKET_MODE (chgrp-then-chmod so a half-applied policy is only ever
too strict). No-op when unset, so default behaviour is unchanged. Connecting
to an AF_UNIX socket needs write permission, so 0660 is what lets a group
member in.
- isSocketDead(path): S_ISSOCK && owned-by-us && non-blocking connect returns
ECONNREFUSED/ENOENT. Fails closed on any other outcome, so it never reports
a live socket or a regular file dead.
- reapStaleSockets(dir, prefix): unlink only the dead sockets, never a regular
file that shares the prefix (e.g. a *.lgx build artefact).
Wire it into RemoteTransportHost::publishObject and QtRemoteRegistry:
- construct QRemoteObjectRegistryHost empty and listen via setRegistryUrl() so
a bind failure is observed and logged (with lastError() + the socket path)
instead of leaving a silently-broken host;
- apply the socket-access policy to the freshly-bound local: socket.
The env-driven policy means every process in a node's tree (daemon, logos_host
subprocesses, their children) applies the same rule to every socket it binds
without threading config through each layer — the daemon exports the vars once.
Adds test_socket_paths.cpp (8 gtests): mode/group application, no-op default,
bad-mode rejection, live/dead/regular-file classification, and the reaper
keeping live sockets and regular files while removing only dead ones.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* review: harden socket helpers (gid overflow, socket-owner check, empty-prefix guard, dedup path)
Addressing automated review feedback on the socket helpers:
- resolveGid(): validate strtoul() errno/range so an out-of-range numeric
LOGOS_SOCKET_GROUP is rejected instead of silently truncating to a wrong gid.
- applySocketPerms(): when a policy is requested, stat the path first and refuse
unless it's a socket we own (S_ISSOCK + st_uid == geteuid()), so a malformed
URL can never chmod/chown a stray file. No-op fast path when the env is unset.
- reapStaleSockets(): refuse an empty prefix (would make every dead socket the
process owns a deletion candidate).
- Extract the duplicated `localSocketFilePath()` (Qt QLocalServer name->path
rule) into a shared qt_remote/qt_socket_path.h so RemoteTransportHost and
QtRemoteRegistry can't drift.
Adds tests: non-socket path refused (mode unchanged), empty-prefix reaper no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* feat: transport-aware token validator hook on ModuleProxy (#22)
* feat: transport-aware token validator hook on ModuleProxy
Adds an injectable authorizer so a host (the logoscore daemon) can accept tokens
the built-in issued-token scan doesn't know — specifically operator-issued named
tokens validated against a persistent store — with per-token expiry and
local_only enforced against the transport the call arrived on.
- ModuleProxy::setTokenValidator(std::function<bool(token, transportProtocol)>).
isAuthorized() consults it ONLY after the existing m_tokens + TokenManager
scan fails, so installing a validator is purely additive: it can grant, never
revoke, access the built-in path already allows. Empty (default) = today's
behaviour exactly.
- callRemoteMethod() gains a defaulted `transportProtocol` ("local"). The QtRO
local path (RemoteTransportHost) uses the default; PlainTransportHost::onCall
passes the real wire ("tcp" | "tcp_ssl", fail-closed to non-local on an
unexpected protocol) so a local_only token presented over the network is
rejected. One ModuleProxy is shared across a provider's transports, so the
transport can't be inferred — it must be threaded per call, which the defaulted
arg does without changing the QtRO replica's 3-arg call.
The daemon backs the validator with TokenStore::lookupByToken; other modules
keep the default (no validator) and are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* review: split callRemoteMethod into explicit 3-arg + 4-arg overloads; include <utility>
Addressing review feedback:
- Replace the defaulted transportProtocol argument with two explicit Q_INVOKABLE
overloads. The Qt meta-object system matches methods by their full parameter
list and doesn't apply C++ default arguments, so the QtRO/local 3-arg call
must remain a real 3-arg method rather than relying on moc's reduced-arity
generation. The 3-arg form forwards to the transport-aware 4-arg form with
"local"; PlainTransportHost keeps calling the 4-arg form with the real wire.
- Include <utility> explicitly in module_proxy.h for std::move rather than
relying on an indirect include.
Full protocol suite green (160/160).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
478 lines
18 KiB
C++
478 lines
18 KiB
C++
#include "plain_transport_host.h"
|
|
|
|
#include "cbor_codec.h"
|
|
#include "io_context_pool.h"
|
|
#include "json_codec.h"
|
|
#include "qvariant_rpc_value.h"
|
|
|
|
#include "../../module_proxy.h"
|
|
|
|
#include <QDebug>
|
|
#include <QMetaObject>
|
|
|
|
#include <atomic>
|
|
|
|
#include <boost/asio/ssl/context.hpp>
|
|
#include <boost/version.hpp>
|
|
#include <openssl/ssl.h>
|
|
#include <openssl/err.h>
|
|
#include <openssl/opensslv.h>
|
|
#include <openssl/x509.h>
|
|
#include <openssl/evp.h>
|
|
|
|
namespace logos::plain {
|
|
|
|
namespace {
|
|
|
|
std::shared_ptr<IWireCodec> makeCodec(LogosWireCodec kind)
|
|
{
|
|
switch (kind) {
|
|
case LogosWireCodec::Cbor: return std::make_shared<CborCodec>();
|
|
case LogosWireCodec::Json:
|
|
default: return std::make_shared<JsonCodec>();
|
|
}
|
|
}
|
|
|
|
// Print the last OpenSSL error to qWarning, clearing the error stack.
|
|
// Used after any SSL_CTX_set_* call that returned 0 — silent failures
|
|
// were how we missed the cipher-list misconfig for multiple rounds.
|
|
void dumpSslErrors(const char* where)
|
|
{
|
|
unsigned long e;
|
|
while ((e = ERR_get_error()) != 0) {
|
|
char buf[256];
|
|
ERR_error_string_n(e, buf, sizeof(buf));
|
|
qWarning() << "buildSslCtx/" << where << ":" << buf;
|
|
}
|
|
}
|
|
|
|
boost::asio::ssl::context buildSslCtx(const LogosTransportConfig& cfg, bool server)
|
|
{
|
|
// One-time diagnostic: print Boost + OpenSSL build-vs-runtime
|
|
// versions on first call. We've spent multiple rounds on
|
|
// TLS configuration that *appeared* to take effect (strings
|
|
// baked into the binary) but didn't change runtime behaviour;
|
|
// a version mismatch between compile-time headers and runtime
|
|
// libs would do that, and this prints the smoking gun.
|
|
static bool versionsLogged = false;
|
|
if (!versionsLogged) {
|
|
versionsLogged = true;
|
|
qInfo().nospace()
|
|
<< "buildSslCtx versions: "
|
|
<< "Boost build=" << BOOST_VERSION
|
|
<< " (" << BOOST_LIB_VERSION << "), "
|
|
<< "OpenSSL build=0x" << Qt::hex << OPENSSL_VERSION_NUMBER
|
|
<< Qt::dec << " (" << OPENSSL_VERSION_TEXT << "), "
|
|
<< "runtime=" << OpenSSL_version(OPENSSL_VERSION);
|
|
}
|
|
|
|
qInfo().nospace() << "buildSslCtx: cfg.certFile='"
|
|
<< QString::fromStdString(cfg.certFile)
|
|
<< "' cfg.keyFile='"
|
|
<< QString::fromStdString(cfg.keyFile)
|
|
<< "' cfg.caFile='"
|
|
<< QString::fromStdString(cfg.caFile)
|
|
<< "' role=" << (server ? "server" : "client");
|
|
|
|
boost::asio::ssl::context ctx(server
|
|
? boost::asio::ssl::context::tls_server
|
|
: boost::asio::ssl::context::tls_client);
|
|
ctx.set_options(boost::asio::ssl::context::default_workarounds
|
|
| boost::asio::ssl::context::no_sslv2
|
|
| boost::asio::ssl::context::no_sslv3
|
|
| boost::asio::ssl::context::single_dh_use);
|
|
|
|
// Require TLS 1.2+. Check return values on every set_* below and
|
|
// dump any pending OpenSSL errors — previous silent-fail behaviour
|
|
// was the root cause of multiple debugging rounds landing no fix.
|
|
if (!SSL_CTX_set_min_proto_version(ctx.native_handle(), TLS1_2_VERSION)) {
|
|
qWarning() << "buildSslCtx: SSL_CTX_set_min_proto_version(TLS1_2) failed";
|
|
dumpSslErrors("set_min_proto_version");
|
|
}
|
|
if (!SSL_CTX_set_max_proto_version(ctx.native_handle(), TLS1_3_VERSION)) {
|
|
qWarning() << "buildSslCtx: SSL_CTX_set_max_proto_version(TLS1_3) failed";
|
|
dumpSslErrors("set_max_proto_version");
|
|
}
|
|
if (!SSL_CTX_set1_groups_list(ctx.native_handle(),
|
|
"X25519:P-256:P-384:P-521")) {
|
|
qWarning() << "buildSslCtx: SSL_CTX_set1_groups_list failed";
|
|
dumpSslErrors("set1_groups_list");
|
|
}
|
|
if (!SSL_CTX_set_ciphersuites(ctx.native_handle(),
|
|
"TLS_AES_128_GCM_SHA256:"
|
|
"TLS_AES_256_GCM_SHA384:"
|
|
"TLS_CHACHA20_POLY1305_SHA256")) {
|
|
qWarning() << "buildSslCtx: SSL_CTX_set_ciphersuites failed";
|
|
dumpSslErrors("set_ciphersuites");
|
|
}
|
|
if (!SSL_CTX_set_cipher_list(ctx.native_handle(),
|
|
"ECDHE+AESGCM:ECDHE+CHACHA20:"
|
|
"DHE+AESGCM:DHE+CHACHA20:"
|
|
"!aNULL:!MD5:!DSS:!RC4:!3DES")) {
|
|
qWarning() << "buildSslCtx: SSL_CTX_set_cipher_list failed";
|
|
dumpSslErrors("set_cipher_list");
|
|
}
|
|
|
|
// Log what stuck. If min/max_proto read back as 0, the platform
|
|
// doesn't support bounded proto versions (very old OpenSSL) and
|
|
// nothing we did above will have capped anything. Cipher count is
|
|
// the second-most-likely silent failure: a non-zero count from
|
|
// SSL_CTX_get_ciphers means the TLS 1.2 cipher list got applied.
|
|
{
|
|
auto* sk = SSL_CTX_get_ciphers(ctx.native_handle());
|
|
const int n = sk ? sk_SSL_CIPHER_num(sk) : 0;
|
|
QString first;
|
|
if (n > 0) {
|
|
const SSL_CIPHER* c = sk_SSL_CIPHER_value(sk, 0);
|
|
first = QString::fromLatin1(SSL_CIPHER_get_name(c));
|
|
}
|
|
qInfo().nospace() << "buildSslCtx: role="
|
|
<< (server ? "server" : "client")
|
|
<< " min_proto=0x" << Qt::hex
|
|
<< SSL_CTX_get_min_proto_version(ctx.native_handle())
|
|
<< " max_proto=0x"
|
|
<< SSL_CTX_get_max_proto_version(ctx.native_handle())
|
|
<< " options=0x"
|
|
<< SSL_CTX_get_options(ctx.native_handle())
|
|
<< Qt::dec
|
|
<< " cipher_count=" << n
|
|
<< " first_cipher=" << first;
|
|
}
|
|
|
|
if (!cfg.certFile.empty()) {
|
|
ctx.use_certificate_chain_file(cfg.certFile);
|
|
dumpSslErrors("use_certificate_chain_file");
|
|
}
|
|
if (!cfg.keyFile.empty()) {
|
|
ctx.use_private_key_file(cfg.keyFile, boost::asio::ssl::context::pem);
|
|
dumpSslErrors("use_private_key_file");
|
|
}
|
|
if (!cfg.caFile.empty()) {
|
|
ctx.load_verify_file(cfg.caFile);
|
|
dumpSslErrors("load_verify_file");
|
|
}
|
|
if (cfg.verifyPeer && !server) {
|
|
ctx.set_verify_mode(boost::asio::ssl::verify_peer);
|
|
} else if (!server) {
|
|
ctx.set_verify_mode(boost::asio::ssl::verify_none);
|
|
}
|
|
|
|
// Final check: is a cert + matching key actually attached to the
|
|
// SSL_CTX? "no shared cipher" / "unsupported protocol" can both
|
|
// result from a server that has no usable cert at all (no PKI
|
|
// cipher suites can negotiate without one). use_certificate_*
|
|
// / use_private_key_* throw on outright failure but can leave the
|
|
// ctx in a "loaded but the CTX-level slot is empty" state if the
|
|
// file's first PEM block was something other than a CERTIFICATE.
|
|
{
|
|
X509* serverCert = SSL_CTX_get0_certificate(ctx.native_handle());
|
|
EVP_PKEY* serverKey = SSL_CTX_get0_privatekey(ctx.native_handle());
|
|
const int checkOk = SSL_CTX_check_private_key(ctx.native_handle());
|
|
qInfo().nospace() << "buildSslCtx: cert_attached="
|
|
<< (serverCert ? "yes" : "no")
|
|
<< " key_attached=" << (serverKey ? "yes" : "no")
|
|
<< " check_private_key=" << checkOk;
|
|
if (!checkOk) dumpSslErrors("SSL_CTX_check_private_key");
|
|
}
|
|
return ctx;
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
PlainTransportHost::PlainTransportHost(LogosTransportConfig cfg)
|
|
: m_cfg(std::move(cfg))
|
|
{
|
|
}
|
|
|
|
PlainTransportHost::~PlainTransportHost()
|
|
{
|
|
// The two stop() calls below are blocking: they tear down all
|
|
// open RpcConnection sessions, each of which calls
|
|
// IncomingCallHandler::onConnectionClosed() — which re-acquires
|
|
// `m_mu` to remove its publisher mapping. Holding m_mu across
|
|
// stop() therefore self-deadlocks.
|
|
//
|
|
// Move the published map and the listeners out under the lock,
|
|
// then drop it before driving the shutdowns. Once we've moved
|
|
// them, no other thread can reach this object's data through
|
|
// m_published / m_tcp / m_ssl.
|
|
decltype(m_published) published;
|
|
decltype(m_tcp) tcp;
|
|
decltype(m_ssl) ssl;
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
published = std::move(m_published);
|
|
tcp = std::move(m_tcp);
|
|
ssl = std::move(m_ssl);
|
|
}
|
|
for (auto& [name, pub] : published) {
|
|
QObject::disconnect(pub.eventConn);
|
|
}
|
|
if (tcp) tcp->stop();
|
|
if (ssl) ssl->stop();
|
|
}
|
|
|
|
bool PlainTransportHost::start()
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
if (m_started) return true;
|
|
auto codec = makeCodec(m_cfg.codec);
|
|
auto& ioc = IoContextPool::shared().ioContext();
|
|
|
|
if (m_cfg.protocol == LogosProtocol::Tcp) {
|
|
m_tcp = std::make_shared<RpcServerTcp>(ioc, m_cfg.host, m_cfg.port, codec, this);
|
|
if (!m_tcp->start()) {
|
|
qCritical() << "PlainTransportHost: TCP bind failed on"
|
|
<< QString::fromStdString(m_cfg.host) << m_cfg.port;
|
|
m_tcp.reset();
|
|
return false;
|
|
}
|
|
m_boundPort = m_tcp->boundPort();
|
|
} else if (m_cfg.protocol == LogosProtocol::TcpSsl) {
|
|
try {
|
|
auto ctx = buildSslCtx(m_cfg, /*server=*/true);
|
|
m_ssl = std::make_shared<RpcServerSsl>(ioc, m_cfg.host, m_cfg.port,
|
|
std::move(ctx), codec, this);
|
|
if (!m_ssl->start()) {
|
|
qCritical() << "PlainTransportHost: TLS bind failed";
|
|
m_ssl.reset();
|
|
return false;
|
|
}
|
|
m_boundPort = m_ssl->boundPort();
|
|
} catch (const std::exception& e) {
|
|
qCritical() << "PlainTransportHost: SSL context setup failed:" << e.what();
|
|
return false;
|
|
}
|
|
} else {
|
|
qCritical() << "PlainTransportHost: unsupported protocol";
|
|
return false;
|
|
}
|
|
m_started = true;
|
|
return true;
|
|
}
|
|
|
|
QString PlainTransportHost::endpoint() const
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
if (m_boundPort == 0) return QString();
|
|
return QString("tcp://%1:%2")
|
|
.arg(QString::fromStdString(m_cfg.host))
|
|
.arg(m_boundPort);
|
|
}
|
|
|
|
QString PlainTransportHost::bindUrl(const QString& /*instanceId*/,
|
|
const QString& /*moduleName*/)
|
|
{
|
|
// One PlainTransportHost listens on a single host:port and serves every
|
|
// published module over the same socket; URL is independent of module.
|
|
return endpoint();
|
|
}
|
|
|
|
bool PlainTransportHost::publishObject(const QString& name, QObject* object)
|
|
{
|
|
if (!object) return false;
|
|
auto* proxy = qobject_cast<ModuleProxy*>(object);
|
|
if (!proxy) {
|
|
qWarning() << "PlainTransportHost::publishObject: expected ModuleProxy for"
|
|
<< name << "(plain transport only publishes ModuleProxy for now)";
|
|
return false;
|
|
}
|
|
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
Published pub;
|
|
pub.object = object;
|
|
const std::string stdName = name.toStdString();
|
|
|
|
// Hook the QObject's eventResponse(QString, QVariantList) signal so every
|
|
// Q_INVOKABLE-style event emission fans out to subscribed connections.
|
|
pub.eventConn = QObject::connect(proxy, &ModuleProxy::eventResponse,
|
|
[this, stdName](const QString& eventName, const QVariantList& data) {
|
|
EventMessage msg;
|
|
msg.object = stdName;
|
|
msg.eventName = eventName.toStdString();
|
|
msg.data = qvariantListToRpcList(data);
|
|
fanOutEvent(stdName, std::move(msg));
|
|
});
|
|
|
|
m_published[stdName] = std::move(pub);
|
|
return true;
|
|
}
|
|
|
|
void PlainTransportHost::unpublishObject(const QString& name)
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
auto it = m_published.find(name.toStdString());
|
|
if (it == m_published.end()) return;
|
|
QObject::disconnect(it->second.eventConn);
|
|
m_published.erase(it);
|
|
}
|
|
|
|
void PlainTransportHost::fanOutEvent(const std::string& name, EventMessage msg)
|
|
{
|
|
std::vector<EventSink> sinks;
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
auto it = m_published.find(name);
|
|
if (it == m_published.end()) return;
|
|
// Named subscribers + wildcard ("") subscribers get the event.
|
|
for (auto which : {msg.eventName, std::string{}}) {
|
|
auto evtIt = it->second.sinksByEvent.find(which);
|
|
if (evtIt == it->second.sinksByEvent.end()) continue;
|
|
for (auto& [key, sink] : evtIt->second) sinks.push_back(sink);
|
|
}
|
|
}
|
|
for (auto& sink : sinks) {
|
|
try { sink(msg); } catch (...) {}
|
|
}
|
|
}
|
|
|
|
void PlainTransportHost::onCall(const CallMessage& req, CallReply reply)
|
|
{
|
|
QObject* obj = nullptr;
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
auto it = m_published.find(req.object);
|
|
if (it != m_published.end()) obj = it->second.object;
|
|
}
|
|
if (!obj) {
|
|
ResultMessage res; res.id = req.id; res.ok = false;
|
|
res.err = "object not published: " + req.object;
|
|
res.errCode = "MODULE_NOT_LOADED";
|
|
reply(std::move(res));
|
|
return;
|
|
}
|
|
|
|
QString authToken = QString::fromStdString(req.authToken);
|
|
QString methodName = QString::fromStdString(req.method);
|
|
QVariantList args = rpcListToQVariantList(req.args);
|
|
uint64_t id = req.id;
|
|
|
|
// The wire this call arrived on, so the provider can enforce local_only
|
|
// tokens. This host only ever serves the remote plain protocols (LocalSocket
|
|
// is handled by RemoteTransportHost). Default to the non-local label so an
|
|
// unexpected protocol fails closed for a local_only token.
|
|
QString transportProtocol = QStringLiteral("tcp");
|
|
if (m_cfg.protocol == LogosProtocol::TcpSsl)
|
|
transportProtocol = QStringLiteral("tcp_ssl");
|
|
|
|
QMetaObject::invokeMethod(obj, [obj, authToken, methodName, args, transportProtocol, id, reply]() {
|
|
QVariant ret;
|
|
bool ok = QMetaObject::invokeMethod(obj, "callRemoteMethod",
|
|
Qt::DirectConnection,
|
|
Q_RETURN_ARG(QVariant, ret),
|
|
Q_ARG(QString, authToken),
|
|
Q_ARG(QString, methodName),
|
|
Q_ARG(QVariantList, args),
|
|
Q_ARG(QString, transportProtocol));
|
|
ResultMessage res;
|
|
res.id = id;
|
|
if (ok) {
|
|
res.ok = true;
|
|
res.value = qvariantToRpcValue(ret);
|
|
} else {
|
|
res.ok = false;
|
|
res.err = "callRemoteMethod failed";
|
|
res.errCode = "METHOD_FAILED";
|
|
}
|
|
reply(std::move(res));
|
|
}, Qt::QueuedConnection);
|
|
}
|
|
|
|
void PlainTransportHost::onMethods(const MethodsMessage& req, MethodsReply reply)
|
|
{
|
|
QObject* obj = nullptr;
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
auto it = m_published.find(req.object);
|
|
if (it != m_published.end()) obj = it->second.object;
|
|
}
|
|
if (!obj) {
|
|
MethodsResultMessage res; res.id = req.id; res.ok = false;
|
|
res.err = "object not published";
|
|
reply(std::move(res));
|
|
return;
|
|
}
|
|
uint64_t id = req.id;
|
|
QMetaObject::invokeMethod(obj, [obj, id, reply]() {
|
|
QJsonArray arr;
|
|
QMetaObject::invokeMethod(obj, "getPluginMethods",
|
|
Qt::DirectConnection,
|
|
Q_RETURN_ARG(QJsonArray, arr));
|
|
MethodsResultMessage res;
|
|
res.id = id;
|
|
res.ok = true;
|
|
res.methods = methodsFromJsonArray(arr);
|
|
reply(std::move(res));
|
|
}, Qt::QueuedConnection);
|
|
}
|
|
|
|
void PlainTransportHost::onSubscribe(const SubscribeMessage& req, EventSink sink,
|
|
const void* connectionId)
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
auto it = m_published.find(req.object);
|
|
if (it == m_published.end()) return;
|
|
// Sinks are keyed by the originating connection so that
|
|
// onUnsubscribe / onConnectionClosed can remove only sinks
|
|
// belonging to that connection — sub/unsub frames don't carry a
|
|
// subscriber id on the wire.
|
|
it->second.sinksByEvent[req.eventName][connectionId] = std::move(sink);
|
|
}
|
|
|
|
void PlainTransportHost::onUnsubscribe(const UnsubscribeMessage& req,
|
|
const void* connectionId)
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
auto it = m_published.find(req.object);
|
|
if (it == m_published.end()) return;
|
|
auto evtIt = it->second.sinksByEvent.find(req.eventName);
|
|
if (evtIt == it->second.sinksByEvent.end()) return;
|
|
// Only drop the requesting connection's sink — other clients
|
|
// subscribed to the same (object, event) keep theirs. Previously
|
|
// this erased the entire eventName entry, taking every other
|
|
// subscriber down with it.
|
|
evtIt->second.erase(connectionId);
|
|
if (evtIt->second.empty()) it->second.sinksByEvent.erase(evtIt);
|
|
}
|
|
|
|
void PlainTransportHost::onConnectionClosed(const void* connectionId)
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
// Sweep every published object's per-event sink table and drop any
|
|
// entries belonging to the closed connection. Without this, a
|
|
// crashing/disconnecting client (which never sends Unsubscribe)
|
|
// leaves dead sinks in the table forever.
|
|
for (auto& [_name, pub] : m_published) {
|
|
for (auto evtIt = pub.sinksByEvent.begin(); evtIt != pub.sinksByEvent.end(); ) {
|
|
evtIt->second.erase(connectionId);
|
|
if (evtIt->second.empty()) evtIt = pub.sinksByEvent.erase(evtIt);
|
|
else ++evtIt;
|
|
}
|
|
}
|
|
}
|
|
|
|
void PlainTransportHost::onToken(const TokenMessage& req)
|
|
{
|
|
QObject* obj = nullptr;
|
|
{
|
|
std::lock_guard<std::mutex> g(m_mu);
|
|
// Route token to the module matching req.moduleName if we host
|
|
// it; otherwise the first published module (matches today's behavior
|
|
// for the single-published-object provider pattern).
|
|
auto it = m_published.find(req.moduleName);
|
|
if (it != m_published.end()) obj = it->second.object;
|
|
else if (!m_published.empty()) obj = m_published.begin()->second.object;
|
|
}
|
|
if (!obj) return;
|
|
QString authToken = QString::fromStdString(req.authToken);
|
|
QString moduleName = QString::fromStdString(req.moduleName);
|
|
QString token = QString::fromStdString(req.token);
|
|
QMetaObject::invokeMethod(obj, "informModuleToken",
|
|
Qt::QueuedConnection,
|
|
Q_ARG(QString, authToken),
|
|
Q_ARG(QString, moduleName),
|
|
Q_ARG(QString, token));
|
|
}
|
|
|
|
} // namespace logos::plain
|