feat: 优化客户端跨平台配置和运行态管理

This commit is contained in:
2026-07-16 08:14:51 +00:00
parent fb6b080ad4
commit fcd67e08aa
25 changed files with 994 additions and 416 deletions
+296 -43
View File
@@ -1,48 +1,301 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#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 <QUuid>
#include <QTimer>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
#else
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
#endif
}
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("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& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
const QString trimmedBase=base.trimmed();
if(appId.trimmed().isEmpty()){m_error="app_id is empty in config/app_config.json";return false;}
if(channel.trimmed().isEmpty()){m_error="channel is empty in config/app_config.json";return false;}
if(trimmedBase.isEmpty()||trimmedBase.contains("YOUR_SERVER_IP",Qt::CaseInsensitive)){m_error="api_base_url is not configured. Set config/server_config.json before building the client, for example http://192.168.229.128:8000";return false;}
if(token.trimmed().isEmpty()){m_error="client_token is empty in config/app_config.json";return false;}
if(licenseKey.trimmed().isEmpty()){m_error="license_key is empty. Create a License in the admin page and paste the generated key into config/app_config.json";return false;}
ConfigHelper& config=ConfigHelper::instance();
QString installation=config.getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!config.setValue("Device","installation_id",installation)){m_error=QString("cannot save installation id to %1: %2").arg(config.configPath(),config.lastError());return false;}}
QByteArray machine=QSysInfo::machineUniqueId()+installation.toUtf8();QString mh=QString::fromLatin1(QCryptographicHash::hash(machine,QCryptographicHash::Sha256).toHex());QJsonObject body{{"app_id",appId},{"channel",channel},{"license_key",licenseKey},{"installation_id",installation},{"machine_hash",mh}};
QNetworkAccessManager manager;QNetworkRequest req{QUrl(trimmedBase+"/api/v1/device/issue")};req.setHeader(QNetworkRequest::ContentTypeHeader,"application/json");req.setRawHeader("X-Client-Token",token.toUtf8());QNetworkReply* reply=manager.post(req,QJsonDocument(body).toJson(QJsonDocument::Compact));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())reply->abort();});QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);timer.start(timeoutMs);loop.exec();timer.stop();int status=reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();QByteArray raw=reply->readAll();reply->deleteLater();if(status!=200){m_error=QString("device issue failed (HTTP %1): %2").arg(status).arg(QString::fromUtf8(raw));return false;}auto response=QJsonDocument::fromJson(raw).object();QJsonObject wrapper{{"identity_text",response.value("identity_text")},{"signature",response.value("signature")}};QString path=QDir(m_installDir).filePath("config/client_identity.dat");QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);QString writeError;if(!ConfigHelper::writeFileWithElevationIfNeeded(path,bytes,&writeError)){m_error=QString("cannot save device credential to %1: %2").arg(path,writeError);return false;}if(!loadAndVerify(appId,channel))return false;if(!config.setValue("Update","device_id",m_deviceId)){m_error=QString("cannot save server device id to %1: %2").arg(config.configPath(),config.lastError());return false;}return true;
#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 QByteArray raw = reply->readAll();
reply->deleteLater();
if (status != 200) {
m_error = QCoreApplication::translate("DeviceIdentityHelper", "Device identity request failed (HTTP %1): %2")
.arg(status)
.arg(QString::fromUtf8(raw));
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;
}