feat(client): 迁移Hub更新客户端实现

This commit is contained in:
2026-09-08 17:08:58 +08:00
parent 0c500024e6
commit 8dfef45bc4
34 changed files with 2032 additions and 2903 deletions
+11 -11
View File
@@ -1,23 +1,23 @@
project(Common LANGUAGES C CXX)
set(SRC
HttpHelper.h
HttpHelper.cpp
FileHelper.h
FileHelper.cpp
HttpHelper.h
HttpHelper.cpp
FileHelper.h
FileHelper.cpp
ConfigHelper.h
ConfigHelper.cpp
PolicyHelper.h
PolicyHelper.cpp
LocalStateHelper.h
LocalStateHelper.cpp
TicketHelper.h
TicketHelper.cpp
IntegrityHelper.h
IntegrityHelper.cpp
DeviceIdentityHelper.h
DeviceIdentityHelper.cpp
)
TicketHelper.h
TicketHelper.cpp
UpdatePathPolicy.h
UpdatePathPolicy.cpp
IntegrityHelper.h
IntegrityHelper.cpp
)
add_library(Common STATIC ${SRC})
# Common编译自身需要OpenSSL头文件
+22 -17
View File
@@ -35,8 +35,8 @@ const QString kConfigKeyPrefix = QStringLiteral("--config-key-b64=");
const QString kConfigValuePrefix = QStringLiteral("--config-value-b64=");
const QString kFilePathPrefix = QStringLiteral("--file-path-b64=");
const QString kFileDataPrefix = QStringLiteral("--file-data-b64=");
const QString kRegistryOrganization = QStringLiteral("Marsco");
const QString kRegistryApplication = QStringLiteral("UpdateClientSDK");
const QString kRegistryOrganization = QStringLiteral("SimCAE");
const QString kRegistryApplication = QStringLiteral("HubUpdateClient");
const QString kRegistryInstallationsGroup = QStringLiteral("installations");
const QString kRegistryConfigGroup = QStringLiteral("config");
const QString kRegistryMetaGroup = QStringLiteral("_meta");
@@ -178,7 +178,8 @@ bool isRegistryManagedConfigKey(const QString& key)
{
// 服务端地址是编译期 qrc 配置,不进入注册表。
// 其他运行配置会在 Launcher 首次启动时导入注册表,之后以注册表为准。
return key != kApiBaseUrlKey;
Q_UNUSED(key);
return true;
}
bool isPathInsideDirectory(const QString& path, const QString& directory)
@@ -478,7 +479,6 @@ ConfigHelper::ConfigHelper()
QCryptographicHash::Sha256).toHex());
migrateLegacyIniIfNeeded();
syncRegistryFromConfigFileIfChanged();
removeRegistryValue(kApiBaseUrlKey);
qDebug() << "Loading app config path:" << m_configPath;
qDebug() << "File exists?" << QFile::exists(m_configPath);
qDebug() << "Registry installation id:" << m_registryInstallId;
@@ -508,7 +508,7 @@ QString ConfigHelper::dataRoot() const
if (base.isEmpty())
base = QDir::homePath();
return QDir::cleanPath(QDir(base).filePath(
QStringLiteral("Marsco/UpdateClientSDK/installations/%1").arg(m_registryInstallId)));
QStringLiteral("SimCAE/HubUpdateClient/installations/%1").arg(m_registryInstallId)));
}
QString ConfigHelper::dataConfigDir() const
@@ -788,16 +788,18 @@ QString ConfigHelper::readFileValue(const QString& key) const
QString ConfigHelper::getValue(const QString& section, const QString& key) const
{
Q_UNUSED(section);
const QString embeddedValue = readEmbeddedValue(key);
if (!embeddedValue.isEmpty())
return embeddedValue;
if (!isRegistryManagedConfigKey(key))
return QString();
QString value;
if (readRegistryValue(key, &value))
return value;
return readFileValue(key);
value = readFileValue(key).trimmed();
if (!value.isEmpty())
return value;
return readEmbeddedValue(key);
}
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
@@ -824,15 +826,15 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
config.insert(key, value);
};
copyText("App", "app_id");
copyText("App", "app_name", "Marsco Demo App");
copyText("App", "app_id");
copyText("App", "product_code", ini.value("App/app_id").toString());
copyText("App", "app_name", "SimCAE");
copyText("App", "channel", "stable");
copyText("App", "current_version", "1.0.0");
copyText("App", "client_protocol", "3");
copyText("App", "launch_token");
copyText("License", "license_key");
copyText("Server", "api_base_url");
copyText("Server", "client_token");
copyText("App", "client_protocol", "3");
copyText("Server", "client_token");
copyText("App", "launch_token");
copyText("Server", "api_base_url");
copyText("Update", "request_timeout_ms", "5000");
copyText("Update", "temp_folder", "update_temp");
copyText("Update", "device_id");
@@ -842,6 +844,9 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
copyText("Runtime", "updater_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Updater"));
copyText("Runtime", "bootstrap_executable", ConfigHelper::executableNameForCurrentPlatform(QString(), "Bootstrap"));
copyText("Runtime", "health_check_timeout_ms", "15000");
copyText("Security", "require_manifest_signature", "false");
copyText("Security", "verify_installed_on_start", "false");
copyText("Platform", "abi");
#ifdef Q_OS_WIN
config.insert("platform", "windows");
#elif defined(Q_OS_LINUX)
@@ -849,7 +854,7 @@ bool ConfigHelper::migrateLegacyIniIfNeeded()
#else
config.insert("platform", "unknown");
#endif
config.insert("arch", "x64");
config.insert("arch", "x86_64");
QDir().mkpath(QFileInfo(m_configPath).path());
QSaveFile output(m_configPath);
-323
View File
@@ -1,323 +0,0 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCoreApplication>
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonParseError>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSysInfo>
#include <QTimer>
#include <QUuid>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
namespace {
QString manifestPublicKeyPath(const QString &installDir)
{
return QDir(installDir).filePath(QStringLiteral("config/manifest_public_key.pem"));
}
} // namespace
DeviceIdentityHelper::DeviceIdentityHelper(const QString &installDir)
: m_installDir(installDir)
{
}
QString DeviceIdentityHelper::deviceId() const
{
return m_deviceId;
}
QString DeviceIdentityHelper::errorString() const
{
return m_error;
}
bool DeviceIdentityHelper::verifySignature(const QByteArray &payload, const QString &signatureBase64)
{
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"OpenSSL is unavailable, so device credential signature cannot be verified.");
return false;
#else
const QString keyPath = manifestPublicKeyPath(m_installDir);
QFile keyFile(keyPath);
if (!keyFile.open(QIODevice::ReadOnly)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is missing: %1").arg(keyPath);
return false;
}
const QByteArray keyData = keyFile.readAll();
BIO *bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
EVP_PKEY *publicKey = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr;
if (bio) {
BIO_free(bio);
}
if (!publicKey) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device public key is invalid: %1").arg(keyPath);
return false;
}
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
const QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
const bool ok = ctx
&& EVP_DigestVerifyInit(ctx, nullptr, EVP_sha256(), nullptr, publicKey) == 1
&& EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1
&& EVP_DigestVerifyFinal(
ctx,
reinterpret_cast<const unsigned char *>(signature.constData()),
signature.size()) == 1;
if (ctx) {
EVP_MD_CTX_free(ctx);
}
EVP_PKEY_free(publicKey);
if (!ok) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential signature is invalid. The local identity file may not match this server.");
}
return ok;
#endif
}
bool DeviceIdentityHelper::loadAndVerify(const QString &expectedAppId, const QString &expectedChannel)
{
// client_identity.dat 是服务端签发的本机设备凭证,不是用户可手写配置。
// 本地启动时先用公钥校验签名,再校验 app/channel/license/installation/device 和有效期。
QString credentialPath = ConfigHelper::instance().clientIdentityPath();
if (!QFile::exists(credentialPath)) {
credentialPath = QDir(m_installDir).filePath(QStringLiteral("config/client_identity.dat"));
}
QFile credentialFile(credentialPath);
if (!credentialFile.open(QIODevice::ReadOnly)) {
return false;
}
QJsonParseError parseError;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(credentialFile.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device credential file is not valid JSON: %1")
.arg(credentialPath);
return false;
}
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray identityText = wrapper.value(QStringLiteral("identity_text")).toString().toUtf8();
const QString signature = wrapper.value(QStringLiteral("signature")).toString();
if (identityText.isEmpty() || !verifySignature(identityText, signature)) {
return false;
}
const QJsonDocument identityDoc = QJsonDocument::fromJson(identityText);
const QJsonObject identity = identityDoc.object();
const QDateTime expiry = QDateTime::fromString(
identity.value(QStringLiteral("valid_until")).toString(),
Qt::ISODate);
const bool identityMatches = identity.value(QStringLiteral("app_id")).toString() == expectedAppId
&& identity.value(QStringLiteral("channel")).toString() == expectedChannel
&& !identity.value(QStringLiteral("license_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("installation_id")).toString().isEmpty()
&& !identity.value(QStringLiteral("device_id")).toString().isEmpty();
if (!identityMatches) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device credential does not match this application, channel, license, installation or device.");
return false;
}
if (!expiry.isValid() || expiry <= QDateTime::currentDateTimeUtc()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License has expired. Please ask the administrator to issue a new License.");
return false;
}
m_deviceId = identity.value(QStringLiteral("device_id")).toString();
return true;
}
bool DeviceIdentityHelper::verifyLocal(const QString &appId, const QString &channel)
{
m_error.clear();
return loadAndVerify(appId, channel);
}
bool DeviceIdentityHelper::ensureIssued(
const QString &apiBaseUrl,
const QString &clientToken,
const QString &appId,
const QString &channel,
const QString &licenseKey)
{
// 首次启动或本地凭证失效时,Launcher 会拿 License 向服务端登记设备。
// 服务端返回签名后的 identity_text,客户端保存为 client_identity.dat,并把真实 device_id 写入运行配置。
m_error.clear();
if (loadAndVerify(appId, channel)) {
ConfigHelper::instance().setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId);
return true;
}
const QString trimmedBaseUrl = apiBaseUrl.trimmed();
if (appId.trimmed().isEmpty()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "app_id is empty in app_config.json.");
return false;
}
if (channel.trimmed().isEmpty()) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "channel is empty in app_config.json.");
return false;
}
if (trimmedBaseUrl.isEmpty() || trimmedBaseUrl.contains(QStringLiteral("YOUR_SERVER_IP"), Qt::CaseInsensitive)) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Server address is not configured. Set config/server_config.json before building Launcher, for example: http://192.168.229.128:8000");
return false;
}
if (clientToken.trimmed().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"client_token is empty. Copy the client_token generated by the admin page into app_config.json.");
return false;
}
if (licenseKey.trimmed().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"License is empty. Create or select a License in the admin page, then copy the generated client configuration.");
return false;
}
ConfigHelper &config = ConfigHelper::instance();
QString installationId = config.getValue(QStringLiteral("Device"), QStringLiteral("installation_id"));
if (installationId.isEmpty()) {
installationId = QUuid::createUuid().toString(QUuid::WithoutBraces);
if (!config.setValue(QStringLiteral("Device"), QStringLiteral("installation_id"), installationId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save installation id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
}
const QByteArray machine = QSysInfo::machineUniqueId() + installationId.toUtf8();
const QString machineHash = QString::fromLatin1(
QCryptographicHash::hash(machine, QCryptographicHash::Sha256).toHex());
const QJsonObject body{
{QStringLiteral("app_id"), appId},
{QStringLiteral("channel"), channel},
{QStringLiteral("license_key"), licenseKey},
{QStringLiteral("installation_id"), installationId},
{QStringLiteral("machine_hash"), machineHash},
};
QNetworkAccessManager manager;
QNetworkRequest request{QUrl(trimmedBaseUrl + QStringLiteral("/api/v1/device/issue"))};
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
request.setRawHeader("X-Client-Token", clientToken.toUtf8());
QNetworkReply *reply = manager.post(request, QJsonDocument(body).toJson(QJsonDocument::Compact));
QEventLoop loop;
QTimer timer;
timer.setSingleShot(true);
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance()
.getValue(QStringLiteral("Update"), QStringLiteral("request_timeout_ms"))
.toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) {
timeoutMs = 5000;
}
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) {
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
timer.stop();
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QString networkError = reply->errorString();
const QByteArray raw = reply->readAll();
reply->deleteLater();
if (status != 200) {
QJsonParseError responseError;
const QJsonDocument errorDoc = QJsonDocument::fromJson(raw, &responseError);
QString serverMessage;
if (responseError.error == QJsonParseError::NoError && errorDoc.isObject()) {
const QJsonValue detail = errorDoc.object().value(QStringLiteral("detail"));
serverMessage = detail.isObject()
? detail.toObject().value(QStringLiteral("msg")).toString()
: detail.toString();
}
if (serverMessage.isEmpty())
serverMessage = QString::fromUtf8(raw).trimmed();
if (status == 0) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Cannot contact the update server to issue device identity.\nServer: %1\nApp: %2\nChannel: %3\nNetwork error: %4\nTimeout: %5 ms")
.arg(trimmedBaseUrl, appId, channel, networkError, QString::number(timeoutMs));
} else {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Device identity request was rejected by the update server.\nServer: %1\nHTTP status: %2\nApp: %3\nChannel: %4\nServer message: %5")
.arg(trimmedBaseUrl, QString::number(status), appId, channel,
serverMessage.isEmpty() ? QCoreApplication::translate("DeviceIdentityHelper", "<empty response>") : serverMessage);
}
return false;
}
const QJsonDocument responseDoc = QJsonDocument::fromJson(raw);
const QJsonObject response = responseDoc.object();
if (response.value(QStringLiteral("identity_text")).toString().isEmpty()
|| response.value(QStringLiteral("signature")).toString().isEmpty()) {
m_error = QCoreApplication::translate(
"DeviceIdentityHelper",
"Server returned an invalid device identity response.");
return false;
}
const QJsonObject wrapper{
{QStringLiteral("identity_text"), response.value(QStringLiteral("identity_text"))},
{QStringLiteral("signature"), response.value(QStringLiteral("signature"))},
};
const QByteArray credentialBytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact);
const QString credentialPath = config.clientIdentityPath();
QString writeError;
if (!ConfigHelper::writeFileWithElevationIfNeeded(credentialPath, credentialBytes, &writeError)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save device credential to %1: %2")
.arg(credentialPath, writeError);
return false;
}
if (!loadAndVerify(appId, channel)) {
return false;
}
if (!config.setValue(QStringLiteral("Update"), QStringLiteral("device_id"), m_deviceId)) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Cannot save server device id to %1: %2")
.arg(config.configPath(), config.lastError());
return false;
}
return true;
}
-28
View File
@@ -1,28 +0,0 @@
#pragma once
#include <QByteArray>
#include <QString>
class DeviceIdentityHelper {
public:
explicit DeviceIdentityHelper(const QString &installDir);
bool ensureIssued(
const QString &apiBaseUrl,
const QString &clientToken,
const QString &appId,
const QString &channel,
const QString &licenseKey);
bool verifyLocal(const QString &appId, const QString &channel);
QString deviceId() const;
QString errorString() const;
private:
bool loadAndVerify(const QString &expectedAppId, const QString &expectedChannel);
bool verifySignature(const QByteArray &payload, const QString &signatureBase64);
QString m_installDir;
QString m_deviceId;
QString m_error;
};
+128 -64
View File
@@ -3,68 +3,132 @@
#include "ConfigHelper.h"
#include <QFile>
#include <QDir>
#include <QApplication>
#include <QTimer>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8());
QString identityPath = ConfigHelper::instance().clientIdentityPath();
if (!QFile::exists(identityPath))
identityPath = QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat");
QFile identity(identityPath);
if (identity.open(QIODevice::ReadOnly))
req.setRawHeader("X-Device-Credential", identity.readAll().toBase64());
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
QEventLoop loop;
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000) timeoutMs = 5000;
QTimer timer;
timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) {
qDebug() << "Request timeout, abort:" << url;
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(timeoutMs);
loop.exec();
timer.stop();
int retCode = 0;
QJsonObject retObj;
retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData;
retObj = QJsonDocument::fromJson(respData).object();
}
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << retCode << "detail:" << reply->errorString();
}
callback(retCode, retObj);
reply->deleteLater();
manager->deleteLater();
#include <QApplication>
#include <QJsonParseError>
#include <QTimer>
#include <QUrl>
namespace {
int requestTimeoutMs()
{
bool timeoutOk = false;
int timeoutMs = ConfigHelper::instance().getValue("Update", "request_timeout_ms").toInt(&timeoutOk);
if (!timeoutOk || timeoutMs < 1000)
timeoutMs = 5000;
return timeoutMs;
}
void applyCommonHeaders(QNetworkRequest& req, const QString& bearerToken)
{
const QString clientToken = ConfigHelper::instance().getValue(QStringLiteral("Server"), QStringLiteral("client_token")).trimmed();
if (!clientToken.isEmpty())
req.setRawHeader("X-Client-Token", clientToken.toUtf8());
const QString token = bearerToken.trimmed();
if (!token.isEmpty())
req.setRawHeader("Authorization", QByteArray("Bearer ") + token.toUtf8());
}
void readJsonReply(QNetworkReply* reply, int* retCode, QJsonObject* retObj)
{
*retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray respData = reply->readAll();
if (!respData.isEmpty()) {
qDebug() << "Server raw response:" << respData;
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(respData, &parseError);
if (parseError.error == QJsonParseError::NoError && document.isObject())
*retObj = document.object();
}
if (reply->error() != QNetworkReply::NoError)
{
qDebug() << "Network error code:" << reply->error();
qDebug() << "HTTP status:" << *retCode << "detail:" << reply->errorString();
}
}
void waitForReply(const QString& url, QNetworkReply* reply)
{
QEventLoop loop;
QTimer timer;
timer.setSingleShot(true);
QObject::connect(&timer, &QTimer::timeout, [&]() {
if (reply && reply->isRunning()) {
qDebug() << "Request timeout, abort:" << url;
reply->abort();
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
timer.start(requestTimeoutMs());
loop.exec();
timer.stop();
}
} // namespace
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
postRequest(url, jsonBody, QString(), callback);
}
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req{QUrl(url)};
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
applyCommonHeaders(req, bearerToken);
QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact);
qDebug() << "=== POST Request ===";
qDebug() << "Url:" << url;
qDebug() << "Body:" << data;
QNetworkReply* reply = manager->post(req, data);
waitForReply(url, reply);
int retCode = 0;
QJsonObject retObj;
readJsonReply(reply, &retCode, &retObj);
callback(retCode, retObj);
reply->deleteLater();
manager->deleteLater();
}
void HttpHelper::getRequest(const QString& url,
std::function<void(int code, const QJsonObject& resp)> callback)
{
getRequest(url, QString(), callback);
}
void HttpHelper::getRequest(const QString& url, const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req{QUrl(url)};
applyCommonHeaders(req, bearerToken);
qDebug() << "=== GET Request ===";
qDebug() << "Url:" << url;
QNetworkReply* reply = manager->get(req);
waitForReply(url, reply);
int retCode = 0;
QJsonObject retObj;
readJsonReply(reply, &retCode, &retObj);
callback(retCode, retObj);
reply->deleteLater();
manager->deleteLater();
}
+18 -10
View File
@@ -3,15 +3,23 @@
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QJsonObject>
#include <QJsonDocument>
#include <QEventLoop>
#include <QDebug>
class HttpHelper
{
public:
#include <QJsonObject>
#include <QJsonDocument>
#include <QEventLoop>
#include <QDebug>
#include <functional>
class HttpHelper
{
public:
// Create an independent manager for each call instead of keeping it as a member.
static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback);
static void postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback);
static void postRequest(const QString& url, const QJsonObject& jsonBody,
const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback);
static void getRequest(const QString& url,
std::function<void(int code, const QJsonObject& resp)> callback);
static void getRequest(const QString& url, const QString& bearerToken,
std::function<void(int code, const QJsonObject& resp)> callback);
};
+115 -54
View File
@@ -1,52 +1,62 @@
#include "IntegrityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include "IntegrityHelper.h"
#include "ConfigHelper.h"
#include "UpdatePathPolicy.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QCoreApplication>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
IntegrityHelper::IntegrityHelper(const QString& installDir)
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
namespace {
QString configValue(const QString& key, const QString& fallback = QString())
{
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
return value.isEmpty() ? fallback : value;
}
bool configFlag(const QString& key)
{
const QString value = configValue(key).toLower();
return value == QStringLiteral("true")
|| value == QStringLiteral("1")
|| value == QStringLiteral("yes")
|| value == QStringLiteral("on");
}
bool manifestFileRequired(const QJsonObject& item)
{
if (!item.contains(QStringLiteral("required")))
return true;
return item.value(QStringLiteral("required")).toBool(true);
}
} // namespace
IntegrityHelper::IntegrityHelper(const QString& installDir)
: m_installDir(QDir::cleanPath(installDir)) {}
QString IntegrityHelper::errorString() const { return m_error; }
bool IntegrityHelper::safeRelativePath(const QString& path) const
{
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
&& !clean.startsWith("../") && !clean.contains(":");
}
bool IntegrityHelper::safeRelativePath(const QString& path) const
{
return UpdatePathPolicy::isSafeRelativePath(path);
}
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
{
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
"config/client_identity.dat", "config/version_policy.dat"
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(p);
}
return UpdatePathPolicy::isFullUpdateProtectedPath(
path, ConfigHelper::instance().runtimeRelativePath());
}
QString IntegrityHelper::sha256(const QString& filePath) const
{
@@ -127,16 +137,39 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
.arg(version, cachePath, wrapperError.errorString());
return false;
}
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
if (manifestText.isEmpty() || signature.isEmpty()) {
const QJsonObject wrapper = wrapperDoc.object();
QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8();
if (manifestText.isEmpty())
manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString manifestSha256 = wrapper.value("manifestSha256").toString(
wrapper.value("manifest_sha256").toString());
const QString signature = wrapper.value("signature").toString(
wrapper.value("manifest").toObject().value("signature").toString());
const bool signedManifest = wrapper.value("signed").toBool(!signature.isEmpty());
if (manifestText.isEmpty()) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
.arg(version, cachePath);
return false;
}
if (!verifySignature(manifestText, signature)) return false;
if (!manifestSha256.isEmpty()) {
const QString actualSha = QString::fromLatin1(
QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex());
if (actualSha.compare(manifestSha256, Qt::CaseInsensitive) != 0) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1.\nExpected SHA-256: %2\nActual SHA-256: %3")
.arg(version, manifestSha256, actualSha);
return false;
}
}
if (signedManifest && !signature.isEmpty()) {
if (!verifySignature(manifestText, signature)) return false;
} else if (configFlag(QStringLiteral("require_manifest_signature"))) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1.")
.arg(version);
return false;
}
QJsonParseError manifestError;
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
@@ -147,20 +180,23 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
return false;
}
const QJsonObject manifest = manifestDoc.object();
if (manifest.value("app_id").toString() != appId
const QString manifestProduct = manifest.value("productCode").toString(
manifest.value("app_id").toString());
if (manifestProduct != appId
|| manifest.value("channel").toString() != channel
|| manifest.value("version").toString() != version) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
.arg(appId, channel, version,
manifest.value("app_id").toString(),
manifestProduct,
manifest.value("channel").toString(),
manifest.value("version").toString());
return false;
}
QSet<QString> declaredExecutables;
for (const QJsonValue& value : manifest.value("files").toArray()) {
QSet<QString> declaredExecutables;
QSet<QString> optionalComponentDirs;
for (const QJsonValue& value : manifest.value("files").toArray()) {
const QJsonObject item = value.toObject();
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
if (!safeRelativePath(path)) {
@@ -169,6 +205,14 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
.arg(version, path);
return false;
}
if (UpdatePathPolicy::isExecutableOrLibrary(path))
declaredExecutables.insert(path.toCaseFolded());
if (!manifestFileRequired(item)) {
const QString dir = QDir::fromNativeSeparators(QFileInfo(path).path());
if (!dir.isEmpty() && dir != QStringLiteral("."))
optionalComponentDirs.insert((dir + QStringLiteral("/")).toCaseFolded());
continue;
}
if (runtimeProtectedPath(path)) continue;
const QString fullPath = QDir(m_installDir).filePath(path);
if (!QFile::exists(fullPath)) {
@@ -177,6 +221,17 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
.arg(version, path, fullPath);
return false;
}
const qint64 expectedSize = item.contains("sizeBytes")
? item.value("sizeBytes").toVariant().toLongLong()
: item.value("size").toVariant().toLongLong();
if ((item.contains("sizeBytes") || item.contains("size"))
&& QFileInfo(fullPath).size() != expectedSize) {
m_error = QCoreApplication::translate("IntegrityHelper",
"Installed file size does not match the local manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected size: %4 bytes\nActual size: %5 bytes")
.arg(version, path, fullPath,
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
return false;
}
const QString expected = item.value("sha256").toString();
const QString actual = sha256(fullPath);
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
@@ -186,9 +241,7 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
return false;
}
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
}
}
QDir root(m_installDir);
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
@@ -202,10 +255,18 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
const bool runtimeWorkDir = !runtimePrefix.isEmpty()
&& (folded.startsWith(runtimePrefix + "/update/")
|| folded.startsWith(runtimePrefix + "/update_temp/"));
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
bool optionalComponentFile = false;
for (const QString& prefix : optionalComponentDirs) {
if (folded.startsWith(prefix)) {
optionalComponentFile = true;
break;
}
}
if (optionalComponentFile)
continue;
if (UpdatePathPolicy::isExecutableOrLibrary(relative) && !declaredExecutables.contains(folded)) {
m_error = QCoreApplication::translate("IntegrityHelper",
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
.arg(version, relative);
+127
View File
@@ -0,0 +1,127 @@
#include "UpdatePathPolicy.h"
#include <QDir>
#include <QFileInfo>
#include <QSet>
#include <QStringList>
namespace {
bool exactOrRuntimeMatch(const QString& folded, const QString& runtimePrefix,
const QSet<QString>& exactPaths)
{
if (exactPaths.contains(folded))
return true;
if (runtimePrefix.isEmpty() || !folded.startsWith(runtimePrefix + QStringLiteral("/")))
return false;
return exactPaths.contains(folded.mid(runtimePrefix.size() + 1));
}
bool prefixOrRuntimePrefixMatch(const QString& folded, const QString& runtimePrefix,
const QString& prefix)
{
if (folded.startsWith(prefix))
return true;
if (runtimePrefix.isEmpty())
return false;
return folded.startsWith(runtimePrefix + QStringLiteral("/") + prefix);
}
} // namespace
namespace UpdatePathPolicy {
QString normalizeRelativePath(const QString& path)
{
QString normalized = QDir::cleanPath(QDir::fromNativeSeparators(path.trimmed()));
if (normalized == QStringLiteral("."))
return QString();
while (normalized.startsWith(QStringLiteral("./")))
normalized = normalized.mid(2);
return normalized;
}
bool isSafeRelativePath(const QString& path)
{
const QString clean = normalizeRelativePath(path);
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != QStringLiteral("..")
&& !clean.startsWith(QStringLiteral("../")) && !clean.contains(QLatin1Char(':'));
}
bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath)
{
const QString folded = normalizeRelativePath(path).toCaseFolded();
const QString runtimePrefix = normalizeRelativePath(runtimeRelativePath).toCaseFolded();
const QSet<QString> exactPaths{
QStringLiteral("bootstrap"),
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher"),
QStringLiteral("launcher.exe"),
QStringLiteral("updater"),
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
if (exactOrRuntimeMatch(folded, runtimePrefix, exactPaths))
return true;
return prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update/"))
|| prefixOrRuntimePrefixMatch(folded, runtimePrefix, QStringLiteral("update_temp/"));
}
bool isIFWInstallerManagedPath(const QString& path)
{
const QString folded = normalizeRelativePath(path).toCaseFolded();
if (folded.isEmpty())
return false;
const bool rootFile = !folded.contains(QLatin1Char('/'));
if (rootFile && (folded == QStringLiteral("maintenancetool")
|| folded == QStringLiteral("maintenancetool.exe")
|| folded.startsWith(QStringLiteral("maintenancetool.")))) {
return true;
}
const QSet<QString> exactPaths{
QStringLiteral("components.xml"),
QStringLiteral("components.xml.new"),
QStringLiteral("components.xml.old"),
QStringLiteral("installation.xml"),
QStringLiteral("installation.dat"),
QStringLiteral("installer.dat"),
QStringLiteral("installer.ini"),
QStringLiteral("network.xml"),
QStringLiteral("repositories.xml"),
QStringLiteral("repositories.cfg"),
QStringLiteral("repository.xml")
};
if (exactPaths.contains(folded))
return true;
const QStringList prefixes{
QStringLiteral("installerresources/"),
QStringLiteral("installationinformation/"),
QStringLiteral("licenses/")
};
for (const QString& prefix : prefixes) {
if (folded.startsWith(prefix))
return true;
}
return false;
}
bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath)
{
return isUpdaterRuntimeProtectedPath(path, runtimeRelativePath)
|| isIFWInstallerManagedPath(path);
}
bool isExecutableOrLibrary(const QString& path)
{
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
return suffix == QStringLiteral("exe") || suffix == QStringLiteral("dll");
}
} // namespace UpdatePathPolicy
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <QString>
namespace UpdatePathPolicy {
QString normalizeRelativePath(const QString& path);
bool isSafeRelativePath(const QString& path);
bool isUpdaterRuntimeProtectedPath(const QString& path, const QString& runtimeRelativePath);
bool isIFWInstallerManagedPath(const QString& path);
bool isFullUpdateProtectedPath(const QString& path, const QString& runtimeRelativePath);
bool isExecutableOrLibrary(const QString& path);
}