From a257611e35e9b2a050bcb46b33737aef97b68b01 Mon Sep 17 00:00:00 2001 From: Paolo Stivanin Date: Fri, 27 Mar 2026 09:48:00 +0100 Subject: [PATCH 1/2] Add per-account mTLS client certificate support via PKCS#12 import Allows users to configure a client certificate for mutual TLS authentication on a per-account basis. Certificates are imported from .p12/.pfx files and persisted in account settings. --- src/gui/CMakeLists.txt | 1 + src/gui/accountmanager.cpp | 36 ++++ src/gui/accountsettings.cpp | 8 +- src/gui/accountsettings.h | 1 + src/gui/accountstate.cpp | 1 + src/gui/clientcertificatedialog.cpp | 158 ++++++++++++++++++ src/gui/clientcertificatedialog.h | 46 +++++ src/gui/qml/FolderDelegate.qml | 4 + src/libsync/CMakeLists.txt | 1 + src/libsync/accessmanager.cpp | 11 ++ src/libsync/accessmanager.h | 10 ++ src/libsync/account.cpp | 34 ++++ src/libsync/account.h | 21 +++ src/libsync/clientcertificateutils.cpp | 66 ++++++++ src/libsync/clientcertificateutils.h | 43 +++++ .../networkjobs/checkserverjobfactory.cpp | 3 + 16 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 src/gui/clientcertificatedialog.cpp create mode 100644 src/gui/clientcertificatedialog.h create mode 100644 src/libsync/clientcertificateutils.cpp create mode 100644 src/libsync/clientcertificateutils.h diff --git a/src/gui/CMakeLists.txt b/src/gui/CMakeLists.txt index 6f53259813..5a953efcba 100644 --- a/src/gui/CMakeLists.txt +++ b/src/gui/CMakeLists.txt @@ -38,6 +38,7 @@ target_sources(OpenCloudGui PRIVATE application.cpp fetchserversettings.cpp commonstrings.cpp + clientcertificatedialog.cpp connectionvalidator.cpp folder.cpp folderdefinition.cpp diff --git a/src/gui/accountmanager.cpp b/src/gui/accountmanager.cpp index 7ccbea0352..61d1036965 100644 --- a/src/gui/accountmanager.cpp +++ b/src/gui/accountmanager.cpp @@ -55,6 +55,16 @@ auto caCertsKeyC() return QStringLiteral("CaCertificates"); } +auto clientCertKeyC() +{ + return QStringLiteral("ClientCertificate"); +} + +auto clientKeyKeyC() +{ + return QStringLiteral("ClientPrivateKey"); +} + auto accountsC() { return QStringLiteral("Accounts"); @@ -121,6 +131,23 @@ bool AccountManager::restore() qCInfo(lcAccountManager) << u"Restored: " << certs.count() << u" unknown certs."; acc->setApprovedCerts(certs); + // restore client certificate for mTLS + const auto clientCertData = settings.value(clientCertKeyC()).toByteArray(); + const auto clientKeyData = settings.value(clientKeyKeyC()).toByteArray(); + if (!clientCertData.isEmpty() && !clientKeyData.isEmpty()) { + const auto clientCerts = QSslCertificate::fromData(clientCertData, QSsl::Pem); + if (!clientCerts.isEmpty()) { + QSslKey clientKey(clientKeyData, QSsl::Rsa, QSsl::Pem); + if (clientKey.isNull()) { + clientKey = QSslKey(clientKeyData, QSsl::Ec, QSsl::Pem); + } + if (!clientKey.isNull()) { + acc->setClientCertificate(clientCerts.first(), clientKey); + qCInfo(lcAccountManager) << u"Restored client certificate for mTLS:" << clientCerts.first().subjectDisplayName(); + } + } + } + if (auto accState = AccountState::loadFromSettings(acc, settings)) { addAccountState(std::move(accState)); } @@ -162,6 +189,15 @@ void AccountManager::save() settings.setValue(caCertsKeyC(), certs); } + // save client certificate for mTLS + if (account->hasClientCertificate()) { + settings.setValue(clientCertKeyC(), account->clientCertificate().toPem()); + settings.setValue(clientKeyKeyC(), account->clientPrivateKey().toPem()); + } else { + settings.remove(clientCertKeyC()); + settings.remove(clientKeyKeyC()); + } + // save the account state accountState->writeToSettings(settings); } diff --git a/src/gui/accountsettings.cpp b/src/gui/accountsettings.cpp index 5ebc5b52f8..c94c11f75b 100644 --- a/src/gui/accountsettings.cpp +++ b/src/gui/accountsettings.cpp @@ -16,8 +16,8 @@ #include "accountsettings.h" #include "ui_accountsettings.h" - #include "account.h" +#include "clientcertificatedialog.h" #include "accountmanager.h" #include "accountstate.h" #include "application.h" @@ -489,6 +489,12 @@ const QSet &AccountSettings::notifications() const return _notifications; } +void AccountSettings::slotConfigureClientCertificate() +{ + auto *dialog = new ClientCertificateDialog(_accountState->account(), this); + addModalLegacyDialog(dialog, ModalWidgetSizePolicy::Minimum); +} + void AccountSettings::slotDeleteAccount() { // Deleting the account potentially deletes 'this', so diff --git a/src/gui/accountsettings.h b/src/gui/accountsettings.h index a03618ebe4..7c60ebe37b 100644 --- a/src/gui/accountsettings.h +++ b/src/gui/accountsettings.h @@ -92,6 +92,7 @@ class OPENCLOUD_GUI_EXPORT AccountSettings : public QWidget public Q_SLOTS: void slotAccountStateChanged(); void slotSpacesUpdated(); + Q_INVOKABLE void slotConfigureClientCertificate(); protected Q_SLOTS: void slotAddFolder(); diff --git a/src/gui/accountstate.cpp b/src/gui/accountstate.cpp index 02251e1f23..ea45efdbf8 100644 --- a/src/gui/accountstate.cpp +++ b/src/gui/accountstate.cpp @@ -75,6 +75,7 @@ AccountState::AccountState(AccountPtr account) connect(account.data(), &Account::credentialsAsked, this, &AccountState::slotCredentialsAsked); connect(account.data(), &Account::unknownConnectionState, this, [this] { checkConnectivity(true); }); + connect(account.data(), &Account::clientCertificateChanged, this, [this] { checkConnectivity(true); }); connect(account.data(), &Account::capabilitiesChanged, this, [this] { if (_account->capabilities().checkForUpdates() && isOcApp()) { diff --git a/src/gui/clientcertificatedialog.cpp b/src/gui/clientcertificatedialog.cpp new file mode 100644 index 0000000000..edc47e85bc --- /dev/null +++ b/src/gui/clientcertificatedialog.cpp @@ -0,0 +1,158 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "clientcertificatedialog.h" + +#include "libsync/clientcertificateutils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace OCC { + +Q_LOGGING_CATEGORY(lcClientCertDialog, "gui.clientcertdialog", QtInfoMsg) + +ClientCertificateDialog::ClientCertificateDialog(const AccountPtr &account, QWidget *parent) + : QWidget(parent) + , _account(account) +{ + setWindowTitle(tr("Client Certificate (mTLS)")); + setAttribute(Qt::WA_DeleteOnClose); + + auto *layout = new QVBoxLayout(this); + + auto *statusGroup = new QGroupBox(tr("Certificate Status"), this); + auto *statusLayout = new QVBoxLayout(statusGroup); + + _statusLabel = new QLabel(this); + _statusLabel->setWordWrap(true); + statusLayout->addWidget(_statusLabel); + + _detailsLabel = new QLabel(this); + _detailsLabel->setWordWrap(true); + _detailsLabel->setTextFormat(Qt::RichText); + statusLayout->addWidget(_detailsLabel); + + layout->addWidget(statusGroup); + + auto *buttonLayout = new QHBoxLayout; + _importButton = new QPushButton(tr("Import Certificate..."), this); + _removeButton = new QPushButton(tr("Remove Certificate"), this); + + auto *closeButton = new QPushButton(tr("Close"), this); + + buttonLayout->addWidget(_importButton); + buttonLayout->addWidget(_removeButton); + buttonLayout->addStretch(); + buttonLayout->addWidget(closeButton); + + layout->addLayout(buttonLayout); + layout->addStretch(); + + connect(_importButton, &QPushButton::clicked, this, &ClientCertificateDialog::slotImportCertificate); + connect(_removeButton, &QPushButton::clicked, this, &ClientCertificateDialog::slotRemoveCertificate); + connect(closeButton, &QPushButton::clicked, this, &ClientCertificateDialog::close); + + updateCertificateDisplay(); +} + +void ClientCertificateDialog::slotImportCertificate() +{ + const QString filePath = QFileDialog::getOpenFileName( + this, + tr("Select PKCS#12 Certificate"), + QString(), + tr("PKCS#12 Files (*.p12 *.pfx);;All Files (*)")); + + if (filePath.isEmpty()) { + return; + } + + bool ok = false; + const QString password = QInputDialog::getText( + this, + tr("Certificate Password"), + tr("Enter the password for the certificate file:"), + QLineEdit::Password, + QString(), + &ok); + + if (!ok) { + return; + } + + ClientCertificateUtils::Pkcs12Result result; + if (!ClientCertificateUtils::importPkcs12(filePath, password, &result)) { + QMessageBox::warning(this, tr("Import Failed"), + tr("Failed to import the certificate file.\n\n" + "Please check that the file is a valid PKCS#12 (.p12/.pfx) file " + "and that the password is correct.")); + return; + } + + _account->setClientCertificate(result.certificate, result.privateKey); + + if (!result.caCertificates.isEmpty()) { + _account->addApprovedCerts({result.caCertificates.begin(), result.caCertificates.end()}); + } + + qCInfo(lcClientCertDialog) << "Client certificate imported successfully:" << result.certificate.subjectDisplayName(); + + updateCertificateDisplay(); +} + +void ClientCertificateDialog::slotRemoveCertificate() +{ + _account->clearClientCertificate(); + qCInfo(lcClientCertDialog) << "Client certificate removed"; + updateCertificateDisplay(); +} + +void ClientCertificateDialog::updateCertificateDisplay() +{ + if (_account->hasClientCertificate()) { + const auto &cert = _account->clientCertificate(); + _statusLabel->setText(tr("A client certificate is configured for mTLS authentication.")); + + const QString details = QStringLiteral( + "" + "" + "" + "" + "" + "" + "
%1%2
%3%4
%5%6
%7%8
%9%10
") + .arg( + tr("Subject:"), cert.subjectDisplayName(), + tr("Issuer:"), cert.issuerDisplayName(), + tr("Valid from:"), QLocale().toString(cert.effectiveDate(), QLocale::LongFormat), + tr("Expires:"), QLocale().toString(cert.expiryDate(), QLocale::LongFormat), + tr("Fingerprint (SHA-256):"), QString::fromLatin1(cert.digest(QCryptographicHash::Sha256).toHex(':'))); + + _detailsLabel->setText(details); + _detailsLabel->setVisible(true); + _removeButton->setEnabled(true); + } else { + _statusLabel->setText(tr("No client certificate configured.")); + _detailsLabel->setVisible(false); + _removeButton->setEnabled(false); + } +} + +} diff --git a/src/gui/clientcertificatedialog.h b/src/gui/clientcertificatedialog.h new file mode 100644 index 0000000000..f413de5059 --- /dev/null +++ b/src/gui/clientcertificatedialog.h @@ -0,0 +1,46 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#pragma once + +#include "account.h" + +#include +#include +#include + +namespace OCC { + +class ClientCertificateDialog : public QWidget +{ + Q_OBJECT + +public: + explicit ClientCertificateDialog(const AccountPtr &account, QWidget *parent = nullptr); + +private Q_SLOTS: + void slotImportCertificate(); + void slotRemoveCertificate(); + +private: + void updateCertificateDisplay(); + + AccountPtr _account; + QLabel *_statusLabel; + QLabel *_detailsLabel; + QPushButton *_importButton; + QPushButton *_removeButton; +}; + +} diff --git a/src/gui/qml/FolderDelegate.qml b/src/gui/qml/FolderDelegate.qml index 85e56fc6c3..ebb678b7cb 100644 --- a/src/gui/qml/FolderDelegate.qml +++ b/src/gui/qml/FolderDelegate.qml @@ -87,6 +87,10 @@ Pane { text: CommonStrings.showInWebBrowser() onTriggered: Qt.openUrlExternally(accountSettings.accountState.account.url) } + MenuItem { + text: qsTr("Client Certificate (mTLS)...") + onTriggered: accountSettings.slotConfigureClientCertificate() + } MenuItem { text: qsTr("Remove") diff --git a/src/libsync/CMakeLists.txt b/src/libsync/CMakeLists.txt index 4c4dbe4022..592ba305ec 100644 --- a/src/libsync/CMakeLists.txt +++ b/src/libsync/CMakeLists.txt @@ -22,6 +22,7 @@ add_library(libsync SHARED jobqueue.cpp logger.cpp accessmanager.cpp + clientcertificateutils.cpp configfile.cpp globalconfig.cpp abstractnetworkjob.cpp diff --git a/src/libsync/accessmanager.cpp b/src/libsync/accessmanager.cpp index 06f408ca8c..ed2b63a5d1 100644 --- a/src/libsync/accessmanager.cpp +++ b/src/libsync/accessmanager.cpp @@ -99,6 +99,10 @@ QNetworkReply *AccessManager::createRequest(QNetworkAccessManager::Operation op, // this behavior does not match the documentation sslConfiguration.addCaCertificates({ _customTrustedCaCertificates.begin(), _customTrustedCaCertificates.end() }); } + if (!_clientCertificate.isNull()) { + sslConfiguration.setLocalCertificate(_clientCertificate); + sslConfiguration.setPrivateKey(_clientPrivateKey); + } newRequest.setSslConfiguration(sslConfiguration); const auto reply = QNetworkAccessManager::createRequest(op, newRequest, outgoingData); @@ -126,6 +130,13 @@ void AccessManager::addCustomTrustedCaCertificates(const QList clearConnectionCache(); } +void AccessManager::setClientCertificate(const QSslCertificate &cert, const QSslKey &key) +{ + _clientCertificate = cert; + _clientPrivateKey = key; + clearConnectionCache(); +} + CookieJar *AccessManager::openCloudCookieJar() const { auto jar = qobject_cast(cookieJar()); diff --git a/src/libsync/accessmanager.h b/src/libsync/accessmanager.h index 5a8180b2af..c08623826f 100644 --- a/src/libsync/accessmanager.h +++ b/src/libsync/accessmanager.h @@ -17,6 +17,8 @@ #include "opencloudsynclib.h" #include +#include +#include class QByteArray; class QUrl; @@ -48,6 +50,12 @@ class OPENCLOUD_SYNC_EXPORT AccessManager : public QNetworkAccessManager */ void addCustomTrustedCaCertificates(const QList &certificates); + /*** + * Set the client certificate and private key for mTLS authentication. + * Warning calling this will break running network jobs. + */ + void setClientCertificate(const QSslCertificate &cert, const QSslKey &key); + CookieJar *openCloudCookieJar() const; /*** @@ -60,6 +68,8 @@ class OPENCLOUD_SYNC_EXPORT AccessManager : public QNetworkAccessManager private: QSet _customTrustedCaCertificates; + QSslCertificate _clientCertificate; + QSslKey _clientPrivateKey; }; } // namespace OCC diff --git a/src/libsync/account.cpp b/src/libsync/account.cpp index 6c02305098..d1abff5253 100644 --- a/src/libsync/account.cpp +++ b/src/libsync/account.cpp @@ -195,6 +195,9 @@ void Account::setCredentials(AbstractCredentials *cred) if (jar) { _am->setCookieJar(jar); } + if (!_clientCertificate.isNull()) { + _am->setClientCertificate(_clientCertificate, _clientPrivateKey); + } connect(_credentials.data(), &AbstractCredentials::fetched, this, [this] { Q_EMIT credentialsFetched(); _queueGuard.unblock(); @@ -251,6 +254,37 @@ void Account::addApprovedCerts(const QSet &certs) Q_EMIT wantsAccountSaved(this); } +QSslCertificate Account::clientCertificate() const +{ + return _clientCertificate; +} + +QSslKey Account::clientPrivateKey() const +{ + return _clientPrivateKey; +} + +bool Account::hasClientCertificate() const +{ + return !_clientCertificate.isNull(); +} + +void Account::setClientCertificate(const QSslCertificate &cert, const QSslKey &key) +{ + _clientCertificate = cert; + _clientPrivateKey = key; + if (_am) { + _am->setClientCertificate(cert, key); + } + Q_EMIT clientCertificateChanged(); + Q_EMIT wantsAccountSaved(this); +} + +void Account::clearClientCertificate() +{ + setClientCertificate(QSslCertificate(), QSslKey()); +} + void Account::setUrl(const QUrl &url) { if (_url != url) { diff --git a/src/libsync/account.h b/src/libsync/account.h index f0957d3a67..9a7dc2feb5 100644 --- a/src/libsync/account.h +++ b/src/libsync/account.h @@ -35,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -148,6 +149,22 @@ class OPENCLOUD_SYNC_EXPORT Account : public QObject */ void addApprovedCerts(const QSet &certs); + /** Client certificate for mTLS authentication */ + QSslCertificate clientCertificate() const; + QSslKey clientPrivateKey() const; + bool hasClientCertificate() const; + + /*** + * Set the client certificate and private key for mTLS. + * Warning calling this will break running network jobs on the current access manager. + */ + void setClientCertificate(const QSslCertificate &cert, const QSslKey &key); + + /*** + * Remove the client certificate configuration. + */ + void clearClientCertificate(); + /** Access the server capabilities */ const Capabilities &capabilities() const; void setCapabilities(const Capabilities &caps); @@ -211,6 +228,8 @@ public Q_SLOTS: void unknownConnectionState(); + void clientCertificateChanged(); + void requestUrlUpdate(const QUrl &newUrl); // the signal exists on the Account object as the Approvider itself can change during runtime @@ -234,6 +253,8 @@ public Q_SLOTS: QString _cacheDirectory; QSet _approvedCerts; + QSslCertificate _clientCertificate; + QSslKey _clientPrivateKey; Capabilities _capabilities; QPointer _am; QPointer _networkCache = nullptr; diff --git a/src/libsync/clientcertificateutils.cpp b/src/libsync/clientcertificateutils.cpp new file mode 100644 index 0000000000..a1d681f101 --- /dev/null +++ b/src/libsync/clientcertificateutils.cpp @@ -0,0 +1,66 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#include "clientcertificateutils.h" + +#include +#include +#include + +namespace OCC::ClientCertificateUtils { + +Q_LOGGING_CATEGORY(lcClientCert, "sync.clientcert", QtInfoMsg) + +bool importPkcs12(const QString &filePath, const QString &password, Pkcs12Result *result) +{ + Q_ASSERT(result); + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) { + qCWarning(lcClientCert) << "Failed to open PKCS#12 file:" << filePath; + return false; + } + + QByteArray data = file.readAll(); + file.close(); + + QSslCertificate cert; + QSslKey key; + QList caCerts; + + QBuffer buffer(&data); + buffer.open(QIODevice::ReadOnly); + if (!QSslCertificate::importPkcs12(&buffer, &key, &cert, &caCerts, password.toUtf8())) { + qCWarning(lcClientCert) << "Failed to import PKCS#12 file (wrong password or invalid format):" << filePath; + return false; + } + + if (cert.isNull() || key.isNull()) { + qCWarning(lcClientCert) << "PKCS#12 file did not contain a valid certificate or key:" << filePath; + return false; + } + + qCInfo(lcClientCert) << "Successfully imported client certificate:" + << cert.subjectDisplayName() + << "issued by" << cert.issuerDisplayName() + << "expires" << cert.expiryDate().toString(Qt::ISODate); + + result->certificate = cert; + result->privateKey = key; + result->caCertificates = caCerts; + + return true; +} + +} diff --git a/src/libsync/clientcertificateutils.h b/src/libsync/clientcertificateutils.h new file mode 100644 index 0000000000..c5ee86ac01 --- /dev/null +++ b/src/libsync/clientcertificateutils.h @@ -0,0 +1,43 @@ +/* + * Copyright (C) by OpenCloud GmbH + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +#pragma once + +#include "opencloudsynclib.h" + +#include +#include +#include +#include + +namespace OCC::ClientCertificateUtils { + +struct Pkcs12Result +{ + QSslCertificate certificate; + QSslKey privateKey; + QList caCertificates; +}; + +/** + * Import a PKCS#12 (.p12/.pfx) file. + * + * @param filePath Path to the PKCS#12 file + * @param password Password to decrypt the file + * @param result Output struct containing the certificate, key, and CA certs + * @return true on success, false on failure + */ +OPENCLOUD_SYNC_EXPORT bool importPkcs12(const QString &filePath, const QString &password, Pkcs12Result *result); + +} diff --git a/src/libsync/networkjobs/checkserverjobfactory.cpp b/src/libsync/networkjobs/checkserverjobfactory.cpp index ee8b687096..a06f954db3 100644 --- a/src/libsync/networkjobs/checkserverjobfactory.cpp +++ b/src/libsync/networkjobs/checkserverjobfactory.cpp @@ -69,6 +69,9 @@ CheckServerJobFactory CheckServerJobFactory::createFromAccount(const AccountPtr // in order to receive all ssl erorrs we need a fresh QNam auto nam = account->credentials()->createAM(); nam->setCustomTrustedCaCertificates(account->approvedCerts()); + if (account->hasClientCertificate()) { + nam->setClientCertificate(account->clientCertificate(), account->clientPrivateKey()); + } nam->setParent(parent); // do we start with the old cookies or new if (!(clearCookies && Theme::instance()->connectionValidatorClearCookies())) { From b261cfdda58370daa00091b3ce646a62add20d63 Mon Sep 17 00:00:00 2001 From: Paolo Stivanin Date: Fri, 27 Mar 2026 17:11:34 +0100 Subject: [PATCH 2/2] Fix account stuck in "Connecting" state after network switch When switching networks (e.g. WiFi to mobile hotspot), the running ConnectionValidator's HTTP request could hang on the dead socket. The guard in checkConnectivity() would then block all subsequent reconnection attempts, leaving the account permanently stuck. Two fixes: - Abort any stale ConnectionValidator when reachability or captive portal state changes, so a fresh validation can start immediately. - Add a 60-second hard timeout to ConnectionValidator to abort hung requests as a safety net. --- src/gui/accountstate.cpp | 19 +++++++++++++++++++ src/gui/connectionvalidator.cpp | 14 ++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/gui/accountstate.cpp b/src/gui/accountstate.cpp index ea45efdbf8..3bb764b174 100644 --- a/src/gui/accountstate.cpp +++ b/src/gui/accountstate.cpp @@ -91,6 +91,18 @@ AccountState::AccountState(AccountPtr account) case NetworkInformation::Reachability::Site: [[fallthrough]]; case NetworkInformation::Reachability::Unknown: + // Abort any running validator — its results are stale since the network changed. + // Without this, the guard in checkConnectivity() would skip the new attempt + // and leave the account stuck in "Connecting" state indefinitely. + if (_connectionValidator) { + _connectionValidator->disconnect(this); + _connectionValidator->deleteLater(); + _connectionValidator.clear(); + } + // Drop stale TCP connections from the old network interface so the + // upcoming connectivity check (and all subsequent requests) open + // fresh sockets on the new interface. + _account->accessManager()->clearConnectionCache(); // the connection might not yet be established QTimer::singleShot(0, this, [this] { checkConnectivity(false); }); break; @@ -132,6 +144,13 @@ AccountState::AccountState(AccountPtr account) _queueGuard.unblock(); } + // Abort any running validator — captive portal state changed, so its results are stale. + if (_connectionValidator) { + _connectionValidator->disconnect(this); + _connectionValidator->deleteLater(); + _connectionValidator.clear(); + } + // A direct connect is not possible, because then the state parameter of `isBehindCaptivePortalChanged` // would become the `verifyServerState` argument to `checkConnectivity`. // The call is also made for when we "go behind" a captive portal. That ensures that not diff --git a/src/gui/connectionvalidator.cpp b/src/gui/connectionvalidator.cpp index a611f6f7b9..b3617ab6f4 100644 --- a/src/gui/connectionvalidator.cpp +++ b/src/gui/connectionvalidator.cpp @@ -47,11 +47,17 @@ ConnectionValidator::ConnectionValidator(AccountPtr account, QObject *parent) : QObject(parent) , _account(account) { - // TODO: 6.0 abort validator on 5min timeout + // Hard timeout: abort the validator if it hasn't completed within 60 seconds. + // This prevents the account from getting stuck in "Connecting" state when + // a network change leaves HTTP requests hanging on a dead socket. auto timer = new QTimer(this); - timer->setInterval(30s); - connect(timer, &QTimer::timeout, this, - [this] { qCInfo(lcConnectionValidator) << u"ConnectionValidator" << _account->displayNameWithHost() << u"still running after" << _duration; }); + timer->setSingleShot(true); + timer->setInterval(60s); + connect(timer, &QTimer::timeout, this, [this] { + qCWarning(lcConnectionValidator) << u"ConnectionValidator for" << _account->displayNameWithHost() << u"timed out after" << _duration; + _errors.append(tr("timeout")); + reportResult(Timeout); + }); timer->start(); }