/*! * * Copyright (c) 2026 Diality Inc. - All Rights Reserved. * \copyright * THIS CODE MAY NOT BE COPIED OR REPRODUCED IN ANY FORM, IN PART OR IN * WHOLE, WITHOUT THE EXPLICIT PERMISSION OF THE COPYRIGHT OWNER. * * \file DCSSimController.cpp * \author (original) Stephen Quong * \date (original) 30-Jul-2026 * */ #include #include #include #include #include #include #include #include #include #include "DCSSimController.h" #include "LeahiMsgDefs.h" #include "LeahiMsgProtoUtils.h" /*! * \brief SslTcpServer::setTls * \details Installs the TLS material; connections accepted from here on are encrypted. * \param tls Certificate, key and optional client CA bundle. */ void SslTcpServer::setTls(const Tls &tls) { _tls = tls; } /*! * \brief SslTcpServer::incomingConnection * \details Adopts the accepted descriptor and, with TLS material set, starts the * server-side handshake before surfacing the connection. * \param socketDescriptor Native descriptor of the accepted connection. */ void SslTcpServer::incomingConnection(qintptr socketDescriptor) { if (_tls.cert.isNull()) { QTcpServer::incomingConnection(socketDescriptor); return; } auto *socket = new QSslSocket(this); if (!socket->setSocketDescriptor(socketDescriptor)) { qWarning().noquote() << "SslTcpServer: cannot adopt the accepted socket"; delete socket; return; } QSslConfiguration ssl = QSslConfiguration::defaultConfiguration(); ssl.setLocalCertificate(_tls.cert); ssl.setPrivateKey(_tls.key); if (!_tls.caCerts.isEmpty()) { ssl.setCaCertificates(_tls.caCerts); // mTLS: refuse clients that do not present a certificate the CA signs. ssl.setPeerVerifyMode(QSslSocket::VerifyPeer); } socket->setSslConfiguration(ssl); connect(socket, QOverload &>::of(&QSslSocket::sslErrors), this, [](const QList &errors) { for (const QSslError &error : errors) { qWarning().noquote() << "SslTcpServer: TLS error:" << error.errorString(); } }); socket->startServerEncryption(); addPendingConnection(socket); } /*! * \brief DCSSimController::DCSSimController * \details Constructor * \param port TCP port to listen on. * \param parent QObject parent. */ DCSSimController::DCSSimController(quint16 port, const TlsConfig &tls, QObject *parent) : QObject(parent), _port(port), _tlsConfig(tls) { connect(&_server, &QTcpServer::newConnection, this, &DCSSimController::onNewConnection); } /*! * \brief DCSSimController::listen * \details Starts the controller listening on all interfaces. * \return true on success, false if the port cannot be bound or TLS material * cannot be loaded. */ bool DCSSimController::listen() { if (!_tlsConfig.certPath.isEmpty() && !initTls()) { return false; } if (!_server.listen(QHostAddress::Any, _port)) { qCritical().noquote() << "DCSSimController: cannot listen on port" << _port << "—" << _server.errorString(); return false; } qInfo().noquote() << "DCSSimController: listening on port" << _port << (_tlsConfig.certPath.isEmpty() ? "(plain)" : _tlsConfig.caPath.isEmpty() ? "(TLS)" : "(mTLS)"); return true; } /*! * \brief DCSSimController::initTls * \details Loads the PEM material from TlsConfig into the server. * \return true if everything referenced by TlsConfig loaded. * \note The key is tried as RSA first, then EC. */ bool DCSSimController::initTls() { SslTcpServer::Tls tls; const QList certs = QSslCertificate::fromPath(_tlsConfig.certPath); if (certs.isEmpty()) { qCritical().noquote() << "DCSSimController: no server certificate in" << _tlsConfig.certPath; return false; } tls.cert = certs.first(); QFile keyFile(_tlsConfig.keyPath); if (!keyFile.open(QIODevice::ReadOnly)) { qCritical().noquote() << "DCSSimController: cannot read private key" << _tlsConfig.keyPath; return false; } const QByteArray pem = keyFile.readAll(); tls.key = QSslKey(pem, QSsl::Rsa, QSsl::Pem); if (tls.key.isNull()) { tls.key = QSslKey(pem, QSsl::Ec, QSsl::Pem); } if (tls.key.isNull()) { qCritical().noquote() << "DCSSimController: private key" << _tlsConfig.keyPath << "is not a PEM RSA or EC key"; return false; } if (!_tlsConfig.caPath.isEmpty()) { tls.caCerts = QSslCertificate::fromPath(_tlsConfig.caPath); if (tls.caCerts.isEmpty()) { qCritical().noquote() << "DCSSimController: no CA certificates in" << _tlsConfig.caPath; return false; } } _server.setTls(tls); return true; } /*! * \brief DCSSimController::onNewConnection * \details Accepts the pending connection, if another client is not already connect. */ void DCSSimController::onNewConnection() { if (_client != nullptr) { qWarning().noquote() << "DCSSimController: connection refused, client already connected"; _server.nextPendingConnection()->deleteLater(); return; } _client = _server.nextPendingConnection(); qInfo().noquote() << "DCSSimController: client connected from" << _client->peerAddress().toString(); connect(_client, &QTcpSocket::readyRead, this, &DCSSimController::onReadyRead); connect(_client, &QTcpSocket::disconnected, this, &DCSSimController::onDisconnected); } /*! * \brief DCSSimController::onDisconnected * \details Perform housekeeping on client disconnect. */ void DCSSimController::onDisconnected() { qInfo().noquote() << "DCSSimController: client disconnected after" << _publishCount << "messages"; _client->deleteLater(); _client = nullptr; _rxBuf.clear(); } /*! * \brief DCSSimController::onReadyRead * \details Handler for incoming data from the socket. */ void DCSSimController::onReadyRead() { _rxBuf.append(_client->readAll()); Mqtt::ReadState state = Mqtt::ReadState::Incomplete; do { Mqtt::Packet packet; state = Mqtt::readPacket(_rxBuf, packet); if (state == Mqtt::ReadState::Complete) { handlePacket(packet); } else if (state == Mqtt::ReadState::Error) { qWarning().noquote() << "DCSSimController: client read error, dropping the connection"; _rxBuf.clear(); _client->abort(); return; } } while (state == Mqtt::ReadState::Complete && !_rxBuf.isEmpty() && _client != nullptr); } /*! * \brief DCSSimController::handlePacketn * \details Incoming MQTT message handler. * \param packet The decoded MQTT message. */ void DCSSimController::handlePacket(const Mqtt::Packet &packet) { switch (packet.type) { case Mqtt::PacketType::Connect: handleConnectPacket(packet); break; case Mqtt::PacketType::Publish: handlePublishPacket(packet); break; case Mqtt::PacketType::PingReq: _client->write(Mqtt::buildPingResp()); break; case Mqtt::PacketType::Disconnect: qInfo().noquote() << "DCSSimController: DISCONNECT requested from client"; _client->disconnectFromHost(); break; default: qWarning().noquote() << "DCSSimController: received unexpected" << Mqtt::typeName(packet.type) << "from client"; break; } } /*! * \brief DCSSimController::handleConnectPacket * \details Handle received CONNECT message. * \param packet The decoded message. */ void DCSSimController::handleConnectPacket(const Mqtt::Packet &packet) { qInfo().noquote() << QString("DCSSimController: CONNECT requested: clientId=%1 keepAlive=%2s cleanSession=%3") .arg(packet.clientId) .arg(packet.keepAliveSecs) .arg(packet.cleanSession ? "true" : "false"); _client->write(Mqtt::buildConnAck(Mqtt::ConnAckCode::Accepted)); qInfo().noquote() << "DCSSimController: CONNACK sent"; } /*! * \brief DCSSimController::handlePublishPacket * \details Handle received PUBLISH message. * \param packet The decoded message. */ void DCSSimController::handlePublishPacket(const Mqtt::Packet &packet) { ++_publishCount; qInfo().noquote() << QString("DCSSimController: PUBLISH: topic=%1 packetId=%2 qos=%3 bytes=%4") .arg(packet.topic) .arg(packet.packetId) .arg(packet.qos) .arg(packet.payload.size()); if (packet.qos == 1) { _client->write(Mqtt::buildPubAck(packet.packetId)); qInfo().noquote() << QString("DCSSimController: PUBACK sent (packetId=%1)").arg(packet.packetId); } dumpPayload(packet.payload); } /*! * \brief DCSSimController::dumpPayload * \details Decodes a payload containing a serialized protobuf message and prints it as JSON. * \param payload Serialized protobuf bytes from the PUBLISH message. */ void DCSSimController::dumpPayload(const QByteArray &payload) { // The Envelope header is field 1 of every typed message, so parsing the // payload as an Envelope reads the header without knowing the real type yet. leahi::messages::Envelope envelope; if (!envelope.ParseFromArray(payload.constData(), static_cast(payload.size()))) { qWarning().noquote() << "DCSSimController: could not parse the Envelope header, payload dropped"; return; } const leahi::messages::Header &header = envelope.header(); const quint16 msgId = static_cast(header.msgid()); qInfo().noquote() << QString("DCSSimController: msgId=0x%1 (%2) serial=%3 seq=%4") .arg(msgId, 4, 16, QChar('0')) .arg(leahi::msgIdString(static_cast(msgId))) .arg(QString::fromStdString(header.deviceserialnum())) .arg(header.sequence()); const std::string &typeName = leahi::msgIdToProtoName(msgId); if (typeName.empty()) { qWarning().noquote() << QString("DCSSimController: unknown message with msgId=0x%1") .arg(msgId, 4, 16, QChar('0')); return; } const google::protobuf::Descriptor *descriptor = google::protobuf::DescriptorPool::generated_pool()->FindMessageTypeByName(typeName); if (descriptor == nullptr) { qWarning().noquote() << "DCSSimController: no descriptor for" << QString::fromStdString(typeName); return; } google::protobuf::DynamicMessageFactory factory; std::unique_ptr body(factory.GetPrototype(descriptor)->New()); if (!body->ParseFromArray(payload.constData(), static_cast(payload.size()))) { qWarning().noquote() << "DCSSimController: could not parse" << QString::fromStdString(typeName); return; } std::string json; google::protobuf::util::JsonPrintOptions options; options.add_whitespace = true; options.always_print_primitive_fields = true; google::protobuf::util::MessageToJsonString(*body, &json, options); qDebug().noquote() << QString::fromStdString(typeName) << ":" << Qt::endl << QString::fromStdString(json); }