/** * @file BobinkClient.cpp * @brief BobinkClient implementation. */ #include "BobinkClient.h" #include "BobinkAuth.h" #include #include #include using namespace Qt::Literals::StringLiterals; BobinkClient *BobinkClient::s_instance = nullptr; static QString defaultPkiDir () { return QStandardPaths::writableLocation (QStandardPaths::AppDataLocation) + QStringLiteral ("/pki"); } /** @brief Create the standard OPC UA PKI directory tree. */ static void ensurePkiDirs (const QString &base) { for (const auto sub : { u"own/certs"_s, u"own/private"_s, u"trusted/certs"_s, u"trusted/crl"_s, u"issuers/certs"_s, u"issuers/crl"_s }) { QDir ().mkpath (base + QLatin1Char ('/') + sub); } } BobinkClient::BobinkClient (QObject *parent) : QObject (parent), m_provider (new QOpcUaProvider (this)), m_pkiDir (defaultPkiDir ()) { ensurePkiDirs (m_pkiDir); setupClient (); autoDetectPki (); applyPki (); connect (&m_discoveryTimer, &QTimer::timeout, this, &BobinkClient::doDiscovery); } BobinkClient::~BobinkClient () { if (s_instance == this) s_instance = nullptr; } BobinkClient * BobinkClient::instance () { return s_instance; } BobinkClient * BobinkClient::create (QQmlEngine *, QJSEngine *) { if (!s_instance) { s_instance = new BobinkClient; QJSEngine::setObjectOwnership (s_instance, QJSEngine::CppOwnership); } return s_instance; } void BobinkClient::setupClient () { m_client = m_provider->createClient (QStringLiteral ("open62541")); if (!m_client) { qWarning () << "BobinkClient: failed to create open62541 backend"; return; } connect (m_client, &QOpcUaClient::stateChanged, this, &BobinkClient::handleStateChanged); connect (m_client, &QOpcUaClient::endpointsRequestFinished, this, &BobinkClient::handleEndpointsReceived); connect (m_client, &QOpcUaClient::connectError, this, &BobinkClient::handleConnectError); connect (m_client, &QOpcUaClient::findServersFinished, this, &BobinkClient::handleFindServersFinished); connect (m_client, &QOpcUaClient::errorChanged, this, [this] (QOpcUaClient::ClientError error) { static const QLatin1StringView names[] = { "NoError"_L1, "InvalidUrl"_L1, "AccessDenied"_L1, "ConnectionError"_L1, "UnknownError"_L1, "UnsupportedAuthenticationInformation"_L1, "InvalidAuthenticationInformation"_L1, "InvalidEndpointDescription"_L1, "NoMatchingUserIdentityTokenFound"_L1, "UnsupportedSecurityPolicy"_L1, "InvalidPki"_L1, }; qint32 idx = static_cast (error); if (idx > 0 && idx <= 10) emit connectionError ( QStringLiteral ("Client error: %1").arg (names[idx])); }); } /* ====================================== * Connection properties * ====================================== */ bool BobinkClient::connected () const { return m_connected; } QString BobinkClient::serverUrl () const { return m_serverUrl; } void BobinkClient::setServerUrl (const QString &url) { if (m_serverUrl == url) return; m_serverUrl = url; emit serverUrlChanged (); } BobinkAuth * BobinkClient::auth () const { return m_auth; } void BobinkClient::setAuth (BobinkAuth *auth) { if (m_auth == auth) return; m_auth = auth; emit authChanged (); } QOpcUaClient * BobinkClient::opcuaClient () const { return m_client; } /* ====================================== * Connection methods * ====================================== */ void BobinkClient::connectToServer () { if (!m_client) { emit connectionError (QStringLiteral ("OPC UA backend not available")); return; } if (m_serverUrl.isEmpty ()) { emit connectionError (QStringLiteral ("No server URL set")); return; } if (m_client->state () != QOpcUaClient::Disconnected) { emit connectionError ( QStringLiteral ("Already connected or connecting")); return; } QUrl url (m_serverUrl); if (!url.isValid ()) { emit connectionError ( QStringLiteral ("Invalid server URL: %1").arg (m_serverUrl)); return; } m_client->requestEndpoints (url); } static QString securityPolicyUri (BobinkClient::SecurityPolicy policy) { switch (policy) { case BobinkClient::Basic256Sha256: return QStringLiteral ( "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256"); case BobinkClient::Aes128_Sha256_RsaOaep: return QStringLiteral ( "http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep"); case BobinkClient::Aes256_Sha256_RsaPss: return QStringLiteral ( "http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss"); } return {}; } void BobinkClient::connectDirect (SecurityPolicy policy, SecurityMode mode) { if (!m_client) { emit connectionError (QStringLiteral ("OPC UA backend not available")); return; } if (m_serverUrl.isEmpty ()) { emit connectionError (QStringLiteral ("No server URL set")); return; } if (m_client->state () != QOpcUaClient::Disconnected) { emit connectionError ( QStringLiteral ("Already connected or connecting")); return; } QOpcUaEndpointDescription endpoint; endpoint.setEndpointUrl (m_serverUrl); endpoint.setSecurityPolicy (securityPolicyUri (policy)); endpoint.setSecurityMode ( static_cast (mode)); QOpcUaUserTokenPolicy tokenPolicy; if (m_auth) { switch (m_auth->mode ()) { case BobinkAuth::Anonymous: tokenPolicy.setTokenType ( QOpcUaUserTokenPolicy::TokenType::Anonymous); break; case BobinkAuth::UserPass: tokenPolicy.setTokenType ( QOpcUaUserTokenPolicy::TokenType::Username); break; case BobinkAuth::Certificate: tokenPolicy.setTokenType ( QOpcUaUserTokenPolicy::TokenType::Certificate); break; } m_client->setAuthenticationInformation ( m_auth->toAuthenticationInformation ()); } else { tokenPolicy.setTokenType (QOpcUaUserTokenPolicy::TokenType::Anonymous); } endpoint.setUserIdentityTokens ({ tokenPolicy }); m_client->connectToEndpoint (endpoint); } void BobinkClient::disconnectFromServer () { if (m_client) m_client->disconnectFromEndpoint (); } void BobinkClient::acceptCertificate () { m_certAccepted = true; if (m_certLoop) m_certLoop->quit (); } void BobinkClient::rejectCertificate () { m_certAccepted = false; if (m_certLoop) m_certLoop->quit (); } /* ====================================== * Discovery properties * ====================================== */ QString BobinkClient::discoveryUrl () const { return m_discoveryUrl; } void BobinkClient::setDiscoveryUrl (const QString &url) { if (m_discoveryUrl == url) return; m_discoveryUrl = url; emit discoveryUrlChanged (); } bool BobinkClient::discovering () const { return m_discovering; } const QList & BobinkClient::discoveredServers () const { return m_discoveredServers; } QVariantList BobinkClient::servers () const { return m_serversCache; } /* ====================================== * PKI * ====================================== */ QString BobinkClient::pkiDir () const { return m_pkiDir; } void BobinkClient::setPkiDir (const QString &path) { if (m_pkiDir == path) return; m_pkiDir = path; ensurePkiDirs (m_pkiDir); emit pkiDirChanged (); } QString BobinkClient::certFile () const { return m_certFile; } void BobinkClient::setCertFile (const QString &path) { if (m_certFile == path) return; m_certFile = path; emit certFileChanged (); } QString BobinkClient::keyFile () const { return m_keyFile; } void BobinkClient::setKeyFile (const QString &path) { if (m_keyFile == path) return; m_keyFile = path; emit keyFileChanged (); } void BobinkClient::autoDetectPki () { if (m_pkiDir.isEmpty ()) return; QDir certDir (m_pkiDir + QStringLiteral ("/own/certs")); QStringList certs = certDir.entryList ({ QStringLiteral ("*.der") }, QDir::Files); if (!certs.isEmpty ()) setCertFile (certDir.filePath (certs.first ())); QDir keyDir (m_pkiDir + QStringLiteral ("/own/private")); QStringList keys = keyDir.entryList ( { QStringLiteral ("*.pem"), QStringLiteral ("*.crt") }, QDir::Files); if (!keys.isEmpty ()) setKeyFile (keyDir.filePath (keys.first ())); } void BobinkClient::applyPki () { if (!m_client || m_pkiDir.isEmpty ()) return; if (!m_certFile.isEmpty () && !QFile::exists (m_certFile)) { emit statusMessage ( QStringLiteral ("PKI error: certificate not found: %1") .arg (m_certFile)); return; } if (!m_keyFile.isEmpty () && !QFile::exists (m_keyFile)) { emit statusMessage ( QStringLiteral ("PKI error: private key not found: %1") .arg (m_keyFile)); return; } if (!m_certFile.isEmpty () && m_keyFile.isEmpty ()) { emit statusMessage ( QStringLiteral ("PKI error: certificate set but no private key")); return; } if (m_certFile.isEmpty () && !m_keyFile.isEmpty ()) { emit statusMessage ( QStringLiteral ("PKI error: private key set but no certificate")); return; } QOpcUaPkiConfiguration pki; if (!m_certFile.isEmpty ()) pki.setClientCertificateFile (m_certFile); if (!m_keyFile.isEmpty ()) pki.setPrivateKeyFile (m_keyFile); pki.setTrustListDirectory (m_pkiDir + QStringLiteral ("/trusted/certs")); pki.setRevocationListDirectory (m_pkiDir + QStringLiteral ("/trusted/crl")); pki.setIssuerListDirectory (m_pkiDir + QStringLiteral ("/issuers/certs")); pki.setIssuerRevocationListDirectory (m_pkiDir + QStringLiteral ("/issuers/crl")); m_client->setPkiConfiguration (pki); if (pki.isKeyAndCertificateFileSet ()) { auto identity = pki.applicationIdentity (); if (!identity.isValid ()) { emit statusMessage (QStringLiteral ( "PKI error: certificate could not be parsed (invalid DER?)")); return; } m_client->setApplicationIdentity (identity); emit statusMessage (QStringLiteral ("PKI applied: %1") .arg (m_certFile.split ('/').last ())); } else { emit statusMessage ( QStringLiteral ("PKI applied (no client certificate)")); } } /* ====================================== * Discovery methods * ====================================== */ void BobinkClient::startDiscovery () { if (m_discoveryUrl.isEmpty () || !m_client) return; doDiscovery (); m_discoveryTimer.start (m_discoveryInterval); if (!m_discovering) { m_discovering = true; emit discoveringChanged (); } } void BobinkClient::stopDiscovery () { m_discoveryTimer.stop (); if (m_discovering) { m_discovering = false; emit discoveringChanged (); } } void BobinkClient::doDiscovery () { if (!m_client || m_discoveryUrl.isEmpty ()) return; QUrl url (m_discoveryUrl); if (!url.isValid ()) return; m_client->findServers (url); } /* ====================================== * Private slots * ====================================== */ void BobinkClient::handleStateChanged (QOpcUaClient::ClientState state) { bool nowConnected = (state == QOpcUaClient::Connected); if (m_connected != nowConnected) { m_connected = nowConnected; emit connectedChanged (); } } void BobinkClient::handleEndpointsReceived ( const QList &endpoints, QOpcUa::UaStatusCode statusCode, const QUrl &) { if (statusCode != QOpcUa::Good || endpoints.isEmpty ()) { emit connectionError (QStringLiteral ("Failed to retrieve endpoints")); return; } QOpcUaEndpointDescription best = endpoints.first (); for (const auto &ep : endpoints) { if (ep.securityLevel () > best.securityLevel ()) best = ep; } if (m_auth) m_client->setAuthenticationInformation ( m_auth->toAuthenticationInformation ()); m_client->connectToEndpoint (best); } void BobinkClient::handleConnectError (QOpcUaErrorState *errorState) { if (errorState->connectionStep () == QOpcUaErrorState::ConnectionStep::CertificateValidation) { // connectError uses BlockingQueuedConnection — the backend thread is // blocked waiting for us to return. The errorState pointer is stack- // allocated in the backend, so it is only valid during this call. // Spin a local event loop so QML can show a dialog and call // acceptCertificate() / rejectCertificate() while we stay in scope. m_certAccepted = false; emit certificateTrustRequested ( QStringLiteral ("The server certificate is not trusted. Accept?")); QEventLoop loop; m_certLoop = &loop; QTimer::singleShot (30000, &loop, &QEventLoop::quit); loop.exec (); m_certLoop = nullptr; errorState->setIgnoreError (m_certAccepted); } else { emit connectionError ( QStringLiteral ("Connection error at step %1, code 0x%2") .arg (static_cast (errorState->connectionStep ())) .arg (static_cast (errorState->errorCode ()), 8, 16, QLatin1Char ('0'))); } } void BobinkClient::handleFindServersFinished ( const QList &servers, QOpcUa::UaStatusCode statusCode, const QUrl &) { if (statusCode != QOpcUa::Good) return; m_discoveredServers = servers; m_serversCache.clear (); for (const auto &s : m_discoveredServers) { QVariantMap entry; entry[QStringLiteral ("serverName")] = s.applicationName ().text (); entry[QStringLiteral ("applicationUri")] = s.applicationUri (); entry[QStringLiteral ("discoveryUrls")] = QVariant::fromValue (s.discoveryUrls ()); m_serversCache.append (entry); } emit serversChanged (); }