diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ae67530 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +** +!server/Dockerfile +!server/requirements.txt +!server/main.py +!server/db.py +!server/minio_tool.py +!server/tables.sql +!client/admin.html diff --git a/.gitignore b/.gitignore index 6095619..a4d29c4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,23 +16,15 @@ Desktop.ini *.bak *.swp *.log +*~ -# Client: CMake / Visual Studio build outputs -client/out/ -client/build/ -client/build-*/ -client/cmake-build-*/ -client/.cmake/ -client/CMakeSettings.json -client/CMakeUserPresets.json -client/Testing/ - -# Client: local runtime state and credentials -client/config/app_config.json -client/config/client_identity.dat -client/config/*.local.json -client/update/ -client/update_temp/ +# Archive/package outputs +*.zip +*.tar +*.tar.gz +*.tgz +*.7z +*.rar # C/C++ generated artifacts outside build directories *.obj @@ -47,9 +39,32 @@ client/update_temp/ *.dll *.exe -# Keep source-controlled PEM public keys; never commit private keys -server/keys/*private*.pem -server/keys/*private*.key +# Client: CMake / Visual Studio build outputs +client/out/ +client/build/ +client/build-*/ +client/cmake-build-*/ +client/.cmake/ +client/CMakeFiles/ +client/CMakeCache.txt +client/CMakeSettings.json +client/CMakeUserPresets.json +client/Testing/ + +# Client: third-party/business binary drops and generated packages +client/App/ +client/dist/ + +# Client: local runtime state and credentials +client/config/app_config.json +client/config/client_identity.dat +client/config/local_state.json +client/config/version_policy.dat +client/config/*.local.json +client/config/*private*.pem +client/config/*private*.key +client/update/ +client/update_temp/ # Server: Python environments and caches server/venv/ @@ -66,17 +81,27 @@ server/htmlcov/ server/.env server/.env.* !server/.env.example +!server/.env.docker.example server/admin_token.sha256 +server/keys/*private*.pem +server/keys/*private*.key -# Server: runtime databases, uploads and object storage +# Server: runtime databases, uploads, object storage and generated packages server/*.db server/*.db-journal server/*.db-wal server/*.db-shm server/local_uploads/ +server/upload_spool/ +server/crash_storage/ server/minio_data/ +server/runtime/ +server/dist/ server/minio # Server: runtime files server/*.pid server/*.log + +# Docker/local generated files +.dockerignore.local diff --git a/client/Common/CMakeLists.txt b/client/Common/CMakeLists.txt index 3995e31..c1c5421 100644 --- a/client/Common/CMakeLists.txt +++ b/client/Common/CMakeLists.txt @@ -1,32 +1,38 @@ -project(Common LANGUAGES C CXX) -find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets) - -set(SRC - HttpHelper.h - HttpHelper.cpp - FileHelper.h - FileHelper.cpp - ConfigHelper.h - ConfigHelper.cpp - PolicyHelper.h - PolicyHelper.cpp - LocalStateHelper.h - LocalStateHelper.cpp -) -add_library(Common STATIC ${SRC}) - -# Common编译自身需要OpenSSL头文件 -target_include_directories(Common - PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} - PRIVATE ${OPENSSL_INC} -) - -# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe -target_link_directories(Common INTERFACE - $<$:${OPENSSL_LIB_DEBUG}> - $<$:${OPENSSL_LIB_RELEASE}> -) -target_link_libraries(Common - PRIVATE Qt5::Core Qt5::Network Qt5::Widgets - INTERFACE libssl.lib libcrypto.lib +project(Common LANGUAGES C CXX) +find_package(Qt5 REQUIRED COMPONENTS Core Network Widgets) + +set(SRC + 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 +) +add_library(Common STATIC ${SRC}) + +# Common编译自身需要OpenSSL头文件 +target_include_directories(Common + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} + PRIVATE ${OPENSSL_INC} +) + +# 链接库、库目录通过INTERFACE传递给所有依赖Common的exe +target_link_directories(Common INTERFACE + $<$:${OPENSSL_LIB_DEBUG}> + $<$:${OPENSSL_LIB_RELEASE}> +) +target_link_libraries(Common + PRIVATE Qt5::Core Qt5::Network Qt5::Widgets + INTERFACE libssl.lib libcrypto.lib ) \ No newline at end of file diff --git a/client/Common/ConfigHelper.cpp b/client/Common/ConfigHelper.cpp index 7c6f9ad..a4e8aae 100644 --- a/client/Common/ConfigHelper.cpp +++ b/client/Common/ConfigHelper.cpp @@ -95,12 +95,19 @@ bool ConfigHelper::migrateLegacyIniIfNeeded() copyText("App", "app_name", "Marsco Demo App"); 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("Update", "request_timeout_ms", "5000"); copyText("Update", "temp_folder", "update_temp"); copyText("Update", "device_id"); + copyText("Runtime", "main_executable", "MainApp.exe"); + copyText("Runtime", "launcher_executable", "Launcher.exe"); + copyText("Runtime", "updater_executable", "Updater.exe"); + copyText("Runtime", "bootstrap_executable", "Bootstrap.exe"); + copyText("Runtime", "health_check_timeout_ms", "15000"); config.insert("platform", "windows"); config.insert("arch", "x64"); diff --git a/client/Common/DeviceIdentityHelper.cpp b/client/Common/DeviceIdentityHelper.cpp new file mode 100644 index 0000000..6dc4b87 --- /dev/null +++ b/client/Common/DeviceIdentityHelper.cpp @@ -0,0 +1,41 @@ +#include "DeviceIdentityHelper.h" +#include "ConfigHelper.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef HAVE_OPENSSL +#include +#include +#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(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;} + QString installation=ConfigHelper::instance().getValue("Device","installation_id");if(installation.isEmpty()){installation=QUuid::createUuid().toString(QUuid::WithoutBraces);if(!ConfigHelper::instance().setValue("Device","installation_id",installation)){m_error="cannot save installation id";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(base+"/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;QObject::connect(reply,&QNetworkReply::finished,&loop,&QEventLoop::quit);loop.exec();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");QSaveFile out(path);QByteArray bytes=QJsonDocument(wrapper).toJson(QJsonDocument::Compact);if(!out.open(QIODevice::WriteOnly)||out.write(bytes)!=bytes.size()||!out.commit()){m_error="cannot save device credential";return false;}if(!loadAndVerify(appId,channel))return false;if(!ConfigHelper::instance().setValue("Update","device_id",m_deviceId)){m_error="cannot save server device id";return false;}return true; +} diff --git a/client/Common/DeviceIdentityHelper.h b/client/Common/DeviceIdentityHelper.h new file mode 100644 index 0000000..5b194f1 --- /dev/null +++ b/client/Common/DeviceIdentityHelper.h @@ -0,0 +1,15 @@ +#pragma once +#include +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, m_deviceId, m_error; +}; diff --git a/client/Common/HttpHelper.cpp b/client/Common/HttpHelper.cpp index 49fdcc3..c781113 100644 --- a/client/Common/HttpHelper.cpp +++ b/client/Common/HttpHelper.cpp @@ -1,49 +1,53 @@ -#include "HttpHelper.h" -#include -#include "ConfigHelper.h" - -void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, - std::function 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()); - - QByteArray data = QJsonDocument(jsonBody).toJson(QJsonDocument::Compact); - qDebug() << "=== POST Request ==="; - qDebug() << "Url:" << url; - qDebug() << "Body:" << data; - - QNetworkReply* reply = manager->post(req, data); - QEventLoop loop; - - QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); - loop.exec(); - - int retCode = 0; - QJsonObject retObj; - - if (reply->error() != QNetworkReply::NoError) - { - qDebug() << "Network error code:" << reply->error(); - qDebug() << "Error detail:" << reply->errorString(); - } - else - { - retCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - QByteArray respData = reply->readAll(); - qDebug() << "Server raw response:" << respData; - QJsonDocument doc = QJsonDocument::fromJson(respData); - retObj = doc.object(); - } - - callback(retCode, retObj); - - reply->deleteLater(); - manager->deleteLater(); +#include "HttpHelper.h" +#include +#include "ConfigHelper.h" +#include +#include +#include + +void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody, + std::function 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()); + QFile identity(QDir(QApplication::applicationDirPath()).filePath("config/client_identity.dat")); + 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; + + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + + 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(); } \ No newline at end of file diff --git a/client/Common/IntegrityHelper.cpp b/client/Common/IntegrityHelper.cpp new file mode 100644 index 0000000..44948c3 --- /dev/null +++ b/client/Common/IntegrityHelper.cpp @@ -0,0 +1,132 @@ +#include "IntegrityHelper.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef HAVE_OPENSSL +#include +#include +#endif + +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::runtimeProtectedPath(const QString& path) const +{ + const QString p = QDir::fromNativeSeparators(path).toCaseFolded(); + static const QSet protectedPaths{ + "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json", + "config/client_identity.dat", "config/version_policy.dat" + }; + return protectedPaths.contains(p); +} + +QString IntegrityHelper::sha256(const QString& filePath) const +{ + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly)) return {}; + QCryptographicHash hash(QCryptographicHash::Sha256); + while (!file.atEnd()) hash.addData(file.read(1024 * 1024)); + return QString::fromLatin1(hash.result().toHex()); +} + +bool IntegrityHelper::verifySignature(const QByteArray& payload, const QString& signatureBase64) +{ +#ifndef HAVE_OPENSSL + Q_UNUSED(payload); Q_UNUSED(signatureBase64); m_error = "OpenSSL unavailable"; return false; +#else + QFile keyFile(QDir(m_installDir).filePath("config/manifest_public_key.pem")); + if (!keyFile.open(QIODevice::ReadOnly)) { m_error = "manifest public key missing"; return false; } + const QByteArray keyData = keyFile.readAll(); + BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size()); + EVP_PKEY* key = bio ? PEM_read_bio_PUBKEY(bio, nullptr, nullptr, nullptr) : nullptr; + if (bio) BIO_free(bio); + if (!key) { m_error = "manifest public key invalid"; 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, key) == 1 + && EVP_DigestVerifyUpdate(ctx, payload.constData(), payload.size()) == 1 + && EVP_DigestVerifyFinal(ctx, reinterpret_cast(signature.constData()), signature.size()) == 1; + if (ctx) EVP_MD_CTX_free(ctx); + EVP_PKEY_free(key); + if (!ok) m_error = "manifest RSA signature invalid"; + return ok; +#endif +} + +bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString& channel, + const QString& version) +{ + m_error.clear(); + const QString cachePath = QDir(m_installDir).filePath( + "update/manifest_cache/manifest_" + version + ".json"); + QFile cache(cachePath); + if (!cache.open(QIODevice::ReadOnly)) { m_error = "signed manifest cache missing for " + version; return false; } + QJsonParseError wrapperError; + const QJsonDocument wrapperDoc = QJsonDocument::fromJson(cache.readAll(), &wrapperError); + if (wrapperError.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { + m_error = "manifest cache JSON invalid"; 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() || !verifySignature(manifestText, signature)) return false; + + QJsonParseError manifestError; + const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError); + if (manifestError.error != QJsonParseError::NoError || !manifestDoc.isObject()) { + m_error = "signed manifest payload invalid"; return false; + } + const QJsonObject manifest = manifestDoc.object(); + if (manifest.value("app_id").toString() != appId + || manifest.value("channel").toString() != channel + || manifest.value("version").toString() != version) { + m_error = "manifest identity does not match local application"; return false; + } + + QSet declaredExecutables; + 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)) { m_error = "unsafe manifest path: " + path; return false; } + if (runtimeProtectedPath(path)) continue; + const QString fullPath = QDir(m_installDir).filePath(path); + if (!QFile::exists(fullPath)) { m_error = "required file missing: " + path; return false; } + const QString expected = item.value("sha256").toString(); + const QString actual = sha256(fullPath); + if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) { + m_error = "file hash mismatch: " + path; 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); + while (it.hasNext()) { + const QString fullPath = it.next(); + const QString relative = QDir::fromNativeSeparators(root.relativeFilePath(fullPath)); + const QString folded = relative.toCaseFolded(); + if (folded.startsWith("update/") || folded.startsWith("update_temp/") + || runtimeProtectedPath(relative)) continue; + const QString suffix = QFileInfo(relative).suffix().toCaseFolded(); + if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) { + m_error = "undeclared executable or plugin: " + relative; return false; + } + } + return true; +} diff --git a/client/Common/IntegrityHelper.h b/client/Common/IntegrityHelper.h new file mode 100644 index 0000000..202bfc7 --- /dev/null +++ b/client/Common/IntegrityHelper.h @@ -0,0 +1,20 @@ +#pragma once +#include + +class IntegrityHelper +{ +public: + explicit IntegrityHelper(const QString& installDir); + bool verifyInstalledVersion(const QString& appId, const QString& channel, + const QString& version); + QString errorString() const; + +private: + bool verifySignature(const QByteArray& payload, const QString& signatureBase64); + bool safeRelativePath(const QString& path) const; + bool runtimeProtectedPath(const QString& path) const; + QString sha256(const QString& filePath) const; + + QString m_installDir; + QString m_error; +}; diff --git a/client/Common/PolicyHelper.cpp b/client/Common/PolicyHelper.cpp index febed97..cafdb6f 100644 --- a/client/Common/PolicyHelper.cpp +++ b/client/Common/PolicyHelper.cpp @@ -105,6 +105,7 @@ bool PolicyHelper::isVersionAllowed(const QString& version) const { } bool PolicyHelper::allowRun() const { return isValid() && m_policy.value("allow_run").toBool(); } bool PolicyHelper::forceUpdate() const { return isValid() && m_policy.value("force_update").toBool(); } +bool PolicyHelper::allowRollback() const { return isValid() && m_policy.value("allow_rollback").toBool(); } bool PolicyHelper::isOfflineAllowed() const { return isValid() && m_policy.value("offline_allowed").toBool(); } bool PolicyHelper::isExpired() const { if (!isValid()) return true; diff --git a/client/Common/PolicyHelper.h b/client/Common/PolicyHelper.h index 2a3fccb..fc28fd0 100644 --- a/client/Common/PolicyHelper.h +++ b/client/Common/PolicyHelper.h @@ -15,6 +15,7 @@ public: bool isVersionAllowed(const QString& currentVersion) const; bool allowRun() const; bool forceUpdate() const; + bool allowRollback() const; bool isOfflineAllowed() const; bool isExpired() const; qint64 policySeq() const; diff --git a/client/Common/TicketHelper.cpp b/client/Common/TicketHelper.cpp new file mode 100644 index 0000000..9cec298 --- /dev/null +++ b/client/Common/TicketHelper.cpp @@ -0,0 +1,91 @@ +#include "TicketHelper.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static QByteArray ticketMac(const QJsonObject& payload, const QString& secret) +{ + return QMessageAuthenticationCode::hash( + QJsonDocument(payload).toJson(QJsonDocument::Compact), secret.toUtf8(), QCryptographicHash::Sha256).toHex(); +} + +bool TicketHelper::createTicket(const QString& appId, const QString& deviceId, + const QString& version, const QString& secret, + QString* ticketPath, QString* errorMessage) +{ + if (appId.isEmpty() || version.isEmpty() || secret.isEmpty()) { + if (errorMessage) *errorMessage = "ticket identity or secret is empty"; + return false; + } + const QDateTime now = QDateTime::currentDateTimeUtc(); + QJsonObject payload{ + {"app_id", appId}, {"device_id", deviceId}, {"version", version}, + {"nonce", QUuid::createUuid().toString(QUuid::WithoutBraces)}, + {"issued_at", now.toString(Qt::ISODate)}, + {"expires_at", now.addSecs(60).toString(Qt::ISODate)}, + {"signature_alg", "HMAC-SHA256"} + }; + QJsonObject wrapper{{"payload", payload}, {"signature", QString::fromLatin1(ticketMac(payload, secret))}}; + const QString dirPath = QDir(QStandardPaths::writableLocation(QStandardPaths::TempLocation)).filePath("marsco_tickets"); + if (!QDir().mkpath(dirPath)) { if (errorMessage) *errorMessage = "cannot create ticket directory"; return false; } + const QString path = QDir(dirPath).filePath("ticket_" + QUuid::createUuid().toString(QUuid::WithoutBraces) + ".json"); + QSaveFile file(path); + const QByteArray bytes = QJsonDocument(wrapper).toJson(QJsonDocument::Compact); + if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit()) { + if (errorMessage) *errorMessage = "cannot save ticket"; + return false; + } + QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner); + if (ticketPath) *ticketPath = path; + return true; +} + +bool TicketHelper::consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, + const QString& expectedDeviceId, const QString& expectedVersion, + const QString& secret, QString* errorMessage) +{ + const QString consumingPath = ticketPath + ".consuming." + + QString::number(QCoreApplication::applicationPid()); + if (!QFile::rename(ticketPath, consumingPath)) { + if (errorMessage) *errorMessage = "ticket missing or already consumed"; + return false; + } + QFile file(consumingPath); + if (!file.open(QIODevice::ReadOnly)) { + QFile::remove(consumingPath); + if (errorMessage) *errorMessage = "cannot read claimed ticket"; + return false; + } + const QByteArray raw = file.readAll(); file.close(); + QFile::remove(consumingPath); // 一次性消费;无论成功失败都不能重放。 + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(raw, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) { + if (errorMessage) *errorMessage = "invalid ticket JSON"; return false; + } + const QJsonObject wrapper = doc.object(); + const QJsonObject payload = wrapper.value("payload").toObject(); + const QByteArray actual = wrapper.value("signature").toString().toLatin1(); + const QByteArray expected = ticketMac(payload, secret); + const QDateTime issued = QDateTime::fromString(payload.value("issued_at").toString(), Qt::ISODate); + const QDateTime expires = QDateTime::fromString(payload.value("expires_at").toString(), Qt::ISODate); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const bool identityOk = payload.value("app_id").toString() == expectedAppId + && payload.value("device_id").toString() == expectedDeviceId + && payload.value("version").toString() == expectedVersion; + const bool timeOk = issued.isValid() && expires.isValid() && issued <= now.addSecs(5) + && expires >= now && issued.secsTo(expires) <= 65; + if (actual.isEmpty() || actual != expected || !identityOk || !timeOk + || payload.value("nonce").toString().isEmpty()) { + if (errorMessage) *errorMessage = "ticket signature, identity, time or nonce invalid"; + return false; + } + return true; +} diff --git a/client/Common/TicketHelper.h b/client/Common/TicketHelper.h new file mode 100644 index 0000000..9531204 --- /dev/null +++ b/client/Common/TicketHelper.h @@ -0,0 +1,13 @@ +#pragma once +#include + +class TicketHelper +{ +public: + static bool createTicket(const QString& appId, const QString& deviceId, + const QString& version, const QString& secret, + QString* ticketPath, QString* errorMessage = nullptr); + static bool consumeAndVerify(const QString& ticketPath, const QString& expectedAppId, + const QString& expectedDeviceId, const QString& expectedVersion, + const QString& secret, QString* errorMessage = nullptr); +}; diff --git a/client/Launcher/CMakeLists.txt b/client/Launcher/CMakeLists.txt index 601eaf9..2063e02 100644 --- a/client/Launcher/CMakeLists.txt +++ b/client/Launcher/CMakeLists.txt @@ -40,7 +40,7 @@ if(WIN32) get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") add_custom_command(TARGET Launcher POST_BUILD - COMMAND ${WINDEPLOYQT} --debug $ + COMMAND ${WINDEPLOYQT} $,--debug,--release> $ COMMENT "自动部署Qt依赖dll" ) -endif() \ No newline at end of file +endif() diff --git a/client/Launcher/UpdateLogic.cpp b/client/Launcher/UpdateLogic.cpp index ef88bd9..a8f0024 100644 --- a/client/Launcher/UpdateLogic.cpp +++ b/client/Launcher/UpdateLogic.cpp @@ -33,6 +33,8 @@ void UpdateLogic::checkUpdate() body["app_id"] = m_appId; body["current_version"] = m_curVer; body["channel"] = m_channel; + const int configuredProtocol = ConfigHelper::instance().getValue("App", "client_protocol").toInt(); + body["client_protocol"] = qMax(3, configuredProtocol); m_http.postRequest(url, body, [this](int code, const QJsonObject& resp) { @@ -101,6 +103,7 @@ void UpdateLogic::reportUpdateResult(const QString& deviceId, const QString& fro { QString url = m_serverAddr + "/api/v1/update/report"; QJsonObject body; + body["app_id"] = m_appId; body["device_id"] = deviceId; body["from_version"] = fromVer; body["to_version"] = toVer; diff --git a/client/Launcher/main.cpp b/client/Launcher/main.cpp index 0048b2d..a2a1c25 100644 --- a/client/Launcher/main.cpp +++ b/client/Launcher/main.cpp @@ -9,6 +9,10 @@ #include "../Common/ConfigHelper.h" #include "../Common/PolicyHelper.h" #include "../Common/LocalStateHelper.h" +#include "../Common/TicketHelper.h" +#include "../Common/DeviceIdentityHelper.h" +#include +#include int main(int argc, char* argv[]) { @@ -26,6 +30,18 @@ int main(int argc, char* argv[]) progress.show(); QApplication::processEvents(); + ConfigHelper& config = ConfigHelper::instance(); + DeviceIdentityHelper identity(QApplication::applicationDirPath()); + if (!identity.ensureIssued(config.getValue("Server", "api_base_url"), + config.getValue("Server", "client_token"), + config.getValue("App", "app_id"), + config.getValue("App", "channel"), + config.getValue("License", "license_key"))) { + progress.close(); + QMessageBox::critical(nullptr, "设备身份验证失败", identity.errorString()); + return -1; + } + UpdateLogic logic; logic.checkUpdate(); @@ -37,13 +53,49 @@ int main(int argc, char* argv[]) const QString appId = logic.getAppId(); const QString channel = logic.getChannel(); - ConfigHelper& config = ConfigHelper::instance(); + if (logic.lastStatusCode() == 401 || logic.lastStatusCode() == 403) { + progress.close(); + const QJsonValue detail = response.value("detail"); + const QString message = detail.isObject() ? detail.toObject().value("msg").toString() : detail.toString(); + QMessageBox::critical(nullptr, "授权被拒绝", message.isEmpty() ? "设备或 License 授权无效。" : message); + return -1; + } + const QString launchToken = config.getValue("App", "launch_token"); const QString currentVersion = config.getValue("App", "current_version"); const QString appDir = QApplication::applicationDirPath(); - const QString mainAppPath = appDir + "/MainApp.exe"; - const QString updaterPath = appDir + "/Updater.exe"; - const QStringList mainArgs{QString("--launcher-token=%1").arg(launchToken)}; + const auto configuredName = [&](const QString& key, const QString& fallback) { + const QString value = config.getValue("Runtime", key).trimmed(); + return value.isEmpty() ? fallback : value; + }; + const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); + const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); + const QString mainAppPath = QDir(appDir).filePath(mainExecutable); + const QString updaterPath = QDir(appDir).filePath(updaterExecutable); + const auto importOfflinePackage = [&]() { + const QString package = QFileDialog::getOpenFileName(nullptr, "选择离线更新包", QString(), "Marsco 离线更新包 (*.upd)"); + return package.isEmpty() ? false : QProcess::startDetached(updaterPath, QStringList{QString("--offline-package=%1").arg(package)}); + }; + const QString deviceId = config.getValue("Update", "device_id"); + if (QCoreApplication::arguments().contains("--import-offline")) { + progress.close(); + if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包。"); + return 0; + } + + const auto startMainApp = [&]() { + QString ticketPath; + QString ticketError; + if (!TicketHelper::createTicket(logic.getAppId(), deviceId, currentVersion, + launchToken, &ticketPath, &ticketError)) { + qDebug() << "Cannot create launch ticket:" << ticketError; + return false; + } + const bool started = QProcess::startDetached(mainAppPath, + QStringList{QString("--ticket-file=%1").arg(ticketPath)}); + if (!started) QFile::remove(ticketPath); + return started; + }; progress.setLabelText("正在验证本地运行策略..."); QApplication::processEvents(); @@ -108,7 +160,7 @@ int main(int argc, char* argv[]) QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes; } if (!accepted) { - if (!QProcess::startDetached(mainAppPath, mainArgs)) { + if (!startMainApp()) { QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); return -1; } @@ -123,6 +175,16 @@ int main(int argc, char* argv[]) return 0; } + if (!networkOk) { + progress.close(); + if (QMessageBox::question(nullptr, "服务器不可用", "当前无法连接更新服务器。是否导入离线更新包?", + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::Yes) { + if (!importOfflinePackage()) QMessageBox::information(nullptr, "离线更新", "未选择离线更新包或无法启动更新器。"); + return 0; + } + progress.show(); + } + if (!networkOk && !policy.isOfflineAllowed()) { progress.close(); @@ -132,7 +194,7 @@ int main(int argc, char* argv[]) progress.setLabelText(networkOk ? "当前已是最新版本,正在启动..." : "当前处于离线模式,正在启动..."); QApplication::processEvents(); - if (!QProcess::startDetached(mainAppPath, mainArgs)) + if (!startMainApp()) { progress.close(); QMessageBox::critical(nullptr, "启动失败", QString("无法启动主程序:%1").arg(mainAppPath)); diff --git a/client/MainApp/CMakeLists.txt b/client/MainApp/CMakeLists.txt index 7874908..7182c39 100644 --- a/client/MainApp/CMakeLists.txt +++ b/client/MainApp/CMakeLists.txt @@ -16,6 +16,6 @@ if(WIN32) get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") add_custom_command(TARGET MainApp POST_BUILD - COMMAND ${WINDEPLOYQT} --debug $ + COMMAND ${WINDEPLOYQT} $,--debug,--release> $ ) -endif() \ No newline at end of file +endif() diff --git a/client/MainApp/main.cpp b/client/MainApp/main.cpp index ba2551a..35258d0 100644 --- a/client/MainApp/main.cpp +++ b/client/MainApp/main.cpp @@ -10,6 +10,9 @@ #include "../Common/ConfigHelper.h" #include "../Common/PolicyHelper.h" #include "../Common/LocalStateHelper.h" +#include "../Common/TicketHelper.h" +#include "../Common/IntegrityHelper.h" +#include "../Common/DeviceIdentityHelper.h" int main(int argc, char* argv[]) { @@ -20,35 +23,40 @@ int main(int argc, char* argv[]) qDebug() << Qt::endl << "entered main app" << Qt::endl; - const QString validToken = ConfigHelper::instance().getValue("App", "launch_token"); - bool pass = false; + ConfigHelper& config = ConfigHelper::instance(); + QString launcherExecutable = config.getValue("Runtime", "launcher_executable").trimmed(); + if (launcherExecutable.isEmpty()) launcherExecutable = "Launcher.exe"; + QString ticketFilePath; QString healthFilePath; - - for (int i = 0; i < argc; ++i) + for (int i = 1; i < argc; ++i) { - QString arg(argv[i]); - if (arg.startsWith("--launcher-token=")) - { - QString tk = arg.split("=").last(); - if (tk == validToken) - { - pass = true; - } - } + const QString arg(argv[i]); + if (arg.startsWith("--ticket-file=")) + ticketFilePath = arg.mid(QString("--ticket-file=").size()); else if (arg.startsWith("--health-file=")) - { healthFilePath = arg.mid(QString("--health-file=").size()); - } } - - if (!pass) + QString ticketError; + if (ticketFilePath.isEmpty() + || !TicketHelper::consumeAndVerify(ticketFilePath, + config.getValue("App", "app_id"), config.getValue("Update", "device_id"), + config.getValue("App", "current_version"), config.getValue("App", "launch_token"), + &ticketError)) { - QMessageBox::critical(nullptr, "Startup Restriction", "Direct double-clicking MainApp.exe is prohibited. Please use Launcher.exe to open the software!"); + QMessageBox::critical(nullptr, "Startup Restriction", + QString("Invalid or missing one-time launch ticket: %1\nPlease use %2.") + .arg(ticketError, launcherExecutable)); return -1; } QString appDir = QApplication::applicationDirPath(); + DeviceIdentityHelper identity(appDir); + if (!identity.verifyLocal(config.getValue("App", "app_id"), config.getValue("App", "channel"))) + { + QMessageBox::critical(nullptr, "License Error", QString("Local license invalid: %1").arg(identity.errorString())); + return -1; + } PolicyHelper policy(appDir); if (!policy.loadPolicy("config/version_policy.dat") || !policy.isValid()) { @@ -81,6 +89,17 @@ int main(int argc, char* argv[]) return -1; } + IntegrityHelper integrity(appDir); + if (!integrity.verifyInstalledVersion( + config.getValue("App", "app_id"), config.getValue("App", "channel"), + config.getValue("App", "current_version"))) + { + QMessageBox::critical(nullptr, "Integrity Check Failed", + QString("Application files failed signed Manifest verification:\n%1") + .arg(integrity.errorString())); + return -1; + } + MainWindow w; w.show(); @@ -107,4 +126,4 @@ int main(int argc, char* argv[]) qDebug() << "Main program MainApp is running normally"; return a.exec(); -} \ No newline at end of file +} diff --git a/client/READMe.txt b/client/READMe.txt index 8b03d6b..328294c 100644 --- a/client/READMe.txt +++ b/client/READMe.txt @@ -1,3 +1,27 @@ -client目录下的CMakeList.txt用来统一管理client下的Launcher工程和MainApp工程和Updater工程 - -cofig.ini要跟exe同级目录 \ No newline at end of file +客户端配置说明 +============== + +统一从程序目录下的 config/app_config.json 读取配置。 +首次运行时如果该文件不存在且发现旧 client.ini,会自动迁移。 + +接入新软件时通常需要修改: + +1. app_id、app_name、channel、current_version。 +2. api_base_url、client_token、license_key、launch_token。 +3. main_executable:团队业务主程序文件名。 +4. launcher_executable、updater_executable、bootstrap_executable。 +5. health_check_timeout_ms:升级后等待业务程序健康确认的毫秒数,最小 1000。 + +完整格式参考 config/app_config.example.json。 +运行时生成的 app_config.json、client_identity.dat、local_state.json 等文件不得打入通用 SDK 模板。 + +Windows 发布打包: + +1. 使用 Release 配置编译全部客户端程序。 +2. 先完成当前版本在线校验,确认 out/bin/update/manifest_cache 中存在对应的签名 Manifest。 +3. 准备一份实际 app_config.json,确认其中包含正确的 License Key、当前版本和业务程序名。 +4. 在 PowerShell 执行: + powershell -ExecutionPolicy Bypass -File .\package-client.ps1 -ConfigFile .\config\app_config.json +5. 输出位于 dist/UpdateClient 和 dist/UpdateClient.zip。 + +脚本会拒绝 Debug DLL、PDB、嵌套重复主程序和缺少签名 Manifest 的发布源目录。 diff --git a/client/SDK_README.md b/client/SDK_README.md new file mode 100644 index 0000000..cd1166b --- /dev/null +++ b/client/SDK_README.md @@ -0,0 +1,129 @@ +# UpdateClientSDK 接入说明 + +这个 SDK 是“独立更新器 SDK / 升级运行时 SDK”。它不是传统的 `include + lib` 形态,而是把自动升级能力作为一组独立程序交给业务软件使用。 + +SDK 核心程序: + +- `Launcher.exe`:用户入口。检查版本、验证策略,决定启动业务主程序或启动 Updater。 +- `Updater.exe`:下载、校验、备份、安装、健康确认、提交或回滚。 +- `Bootstrap.exe`:替换运行中可能被占用的 EXE/DLL。 +- `config/app_config.json`:接入方配置。 +- `config/manifest_public_key.pem`:验证服务端签名用的公钥。 + +## 接入方需要做什么 + +假设接入的软件叫 `YourApp.exe`。 + +1. 在服务端管理后台创建应用,例如 `app_id=your_app_id`。 +2. 创建或确认渠道,例如 `stable`。 +3. 创建 License,把生成的 `license_key` 填到客户端配置。 +4. 把业务程序和依赖 DLL 放到同一个发布目录。 +5. 把 SDK 的 `Launcher.exe`、`Updater.exe`、`Bootstrap.exe` 和运行时 DLL 放到该目录。 +6. 把 `config/app_config.example.json` 复制成 `config/app_config.json` 并修改字段。 +7. 用户入口改成 `Launcher.exe`,不要直接双击业务主程序。 +8. 在管理后台发布新版本时,选择包含业务主程序和 SDK 运行时的干净 Release 根目录。 + +## app_config.json 关键字段 + +```json +{ + "app_id": "simcae", + "app_name": "SimCAE", + "channel": "stable", + "current_version": "1.0.0", + "client_protocol": "3", + "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", + "license_key": "", + "api_base_url": "http://YOUR_SERVER_IP:8000", + "client_token": "SimCAEClientToken2026", + "request_timeout_ms": "5000", + "temp_folder": "update_temp", + "device_id": "", + "main_executable": "SimCAE.exe", + "launcher_executable": "Launcher.exe", + "updater_executable": "Updater.exe", + "bootstrap_executable": "Bootstrap.exe", + "health_check_timeout_ms": "15000", + "platform": "windows", + "arch": "x64" +} +``` + +字段说明: + +- `app_id`:服务端应用 ID,默认填 `simcae`;如果后台创建了别的 App ID,这里同步修改。 +- `channel`:发布渠道,例如 `stable`、`beta`、`dev`。 +- `current_version`:当前客户端初始版本。 +- `client_protocol`:客户端协议号,当前建议为 `3`。 +- `api_base_url`:服务端 API 地址,例如 `http://192.168.229.128:8000`;服务器 IP 无法提前知道,所以这里需要按现场地址修改。 +- `client_token`:服务端 `.env` 中的 `CLIENT_API_TOKEN`,默认交付包已填 `SimCAEClientToken2026`。 +- `license_key`:管理后台创建 License 后返回的密钥;模板里先留空,创建授权后再填。 +- `launch_token`:本机启动票据 HMAC 密钥,模板已给默认值,可试跑;正式交付建议改成你自己的 32 字符以上随机字符串。 +- `main_executable`:业务主程序文件名,默认填 `SimCAE.exe`;如果你的主程序不是这个名字,这里同步修改。 +- `health_check_timeout_ms`:升级后等待业务程序写健康标记的时间。 + +## 业务主程序需要配合什么 + +当前安全模式下,业务主程序需要配合两件事: + +1. 接收 `--ticket-file=` 参数,验证并消费一次性启动票据。 +2. 如果收到 `--health-file=` 参数,启动成功后向该路径写入 `ok\n`,让 Updater 确认新版本可用。 + +当前仓库里的 `client/MainApp/main.cpp` 是接入示例,已经实现了: + +- 启动票据校验。 +- 本地 License/设备身份校验。 +- 本地策略校验。 +- Manifest 完整性校验。 +- 健康标记写入。 + +如果第三方业务程序暂时不想改代码,可以先使用当前 `MainApp.exe` 作为 Demo 验证 SDK 包;真正接入时建议把这些启动检查逻辑移植到业务主程序。 + +## 如何生成 SDK 包 + +在 Windows PowerShell 中执行: + +```powershell +cd client +.\package-sdk.ps1 ` + -SourceDir .\out\bin ` + -OutputDir .\dist\UpdateClientSDK ` + -ZipFile .\dist\UpdateClientSDK.zip ` + -SdkVersion 0.1.0 +``` + +生成结果: + +```text +dist/UpdateClientSDK/ + README.md + bin/ + config/ + scripts/ + samples/ +``` + +把 `UpdateClientSDK.zip` 发给接入方即可。 + +## 如何生成某个产品的最终客户端包 + +SDK 是给开发者接入用的,最终给用户安装/分发时,可以使用: + +```powershell +cd client +.\package-client.ps1 ` + -SourceDir .\out\bin ` + -ConfigFile .\config\app_config.json ` + -OutputDir .\dist\UpdateClient ` + -ZipFile .\dist\UpdateClient.zip +``` + +`package-client.ps1` 会检查配置和必需文件,并生成具体产品的客户端包。 + +## 常见错误 + +1. 直接启动业务主程序提示 ticket 错误:应从 `Launcher.exe` 启动。 +2. 首次启动设备登记失败:检查 `api_base_url`、`client_token`、`license_key`、服务端 License 状态。 +3. 策略或 Manifest 验签失败:检查 `config/manifest_public_key.pem` 是否和服务端私钥匹配。 +4. 升级后回滚:检查业务程序是否在 `health_check_timeout_ms` 内写入健康标记。 +5. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。 diff --git a/client/SimCAE_Crash_Report后端接口规范.docx b/client/SimCAE_Crash_Report后端接口规范.docx new file mode 100644 index 0000000..92ab6f5 Binary files /dev/null and b/client/SimCAE_Crash_Report后端接口规范.docx differ diff --git a/client/SimCAE自动升级SDK接入说明_v0.1.docx b/client/SimCAE自动升级SDK接入说明_v0.1.docx new file mode 100644 index 0000000..b9664f4 Binary files /dev/null and b/client/SimCAE自动升级SDK接入说明_v0.1.docx differ diff --git a/client/Updater/CMakeLists.txt b/client/Updater/CMakeLists.txt index ba0221e..167f8b6 100644 --- a/client/Updater/CMakeLists.txt +++ b/client/Updater/CMakeLists.txt @@ -1,9 +1,9 @@ -# ǿб롢ʱɾʹx64ֹWin32 -set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "ǿx64ƽ̨Win32") +# 强制所有编译、设计时生成均使用x64,禁止Win32 +set(CMAKE_GENERATOR_PLATFORM x64 CACHE STRING "强制x64平台,禁用Win32") set(CMAKE_VS_PLATFORM_TOOLSET_HOST_ARCH x64) -# رVSԶWin32ʱԤ +# 关闭VS自动Win32设计时预生成 set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD OFF) -# 32λStrawberry Perl· +# 清除32位Strawberry Perl路径干扰 list(REMOVE_ITEM CMAKE_INCLUDE_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/include") list(REMOVE_ITEM CMAKE_LIBRARY_PATH "D:/softwaresInstallDir/strawberry-perl-5.22.1.3-32bit/c/lib") @@ -21,21 +21,21 @@ if(WIN32) set_target_properties(Updater PROPERTIES WIN32_EXECUTABLE TRUE) endif() -# ؼUpdaterOpenSSLͷļĿ¼#include openssl/*Ҳ +# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到 target_include_directories(Updater PRIVATE ${OPENSSL_INC}) -# CommonQtOpenSSLCommon INTERFACEԶ +# 仅链接Common和Qt,OpenSSL库由Common INTERFACE自动传递 target_link_libraries(Updater PRIVATE Common Qt5::Core Qt5::Network Qt5::Gui Qt5::Widgets ) -# Qtű +# Qt部署脚本 if(WIN32) get_target_property(QT_WINDEPLOYQT_EXE Qt5::qmake IMPORTED_LOCATION) get_filename_component(QT_BIN_PATH "${QT_WINDEPLOYQT_EXE}" DIRECTORY) set(WINDEPLOYQT "${QT_BIN_PATH}/windeployqt.exe") add_custom_command(TARGET Updater POST_BUILD - COMMAND ${WINDEPLOYQT} --debug $ + COMMAND ${WINDEPLOYQT} $,--debug,--release> $ ) -endif() \ No newline at end of file +endif() diff --git a/client/Updater/UpdaterLogic.cpp b/client/Updater/UpdaterLogic.cpp index b86bd67..0bf7bdc 100644 --- a/client/Updater/UpdaterLogic.cpp +++ b/client/Updater/UpdaterLogic.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include "ConfigHelper.h" @@ -325,6 +326,52 @@ bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& return true; } +bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& stagingDir) +{ + m_offlineError.clear(); + QFile package(packagePath); + if (!package.open(QIODevice::ReadOnly)) { m_offlineError = "无法打开离线更新包"; return false; } + if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = "离线包格式标识无效"; return false; } + const QByteArray lengthBytes = package.read(8); + if (lengthBytes.size() != 8) { m_offlineError = "离线包头不完整"; return false; } + quint64 headerSize = 0; + for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast(lengthBytes[i])) << (i * 8); + if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = "离线包头长度无效"; return false; } + QJsonParseError error; + const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error); + if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = "离线包头 JSON 无效"; return false; } + const QJsonObject wrapper = wrapperDoc.object(); + const QByteArray packageText = wrapper.value("package_text").toString().toUtf8(); + const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8(); + const QString publicKey = QApplication::applicationDirPath() + "/config/manifest_public_key.pem"; + if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = "离线包 RSA 签名无效"; return false; } + const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object(); + if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = "离线包签名元数据无效"; return false; } + if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = "Manifest 摘要与包签名不一致"; return false; } + QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object(); + if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = "离线 Manifest 无效"; return false; } + manifest.insert("signature", wrapper.value("manifest_signature").toString()); + m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear(); + if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = "包信息与 Manifest 身份不一致"; return false; } + if (!verifyManifestSignature()) { m_offlineError = "离线 Manifest RSA 签名无效"; return false; } + const qint64 payloadStart = 16 + qint64(headerSize); + const QJsonArray entries = packageMeta.value("files").toArray(); + for (const QJsonValue& value : entries) { + const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString()); + const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong(); + if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = "离线包包含不安全路径或越界数据: " + path; return false; } + FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi); + if (stagingDir.isEmpty()) continue; + const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = "无法准备离线文件: " + path; return false; } + QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = "无法创建暂存文件: " + path; return false; } + QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size; + while (remaining > 0) { const QByteArray block = package.read(qMin(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = "离线文件读取失败: " + path; return false; } hash.addData(block); remaining -= block.size(); } + if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = "离线文件 Hash 或写入失败: " + path; return false; } + } + if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = "离线包文件数量与 Manifest 不一致"; return false; } + return true; +} + void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId) { QString url = m_serverAddr + "/api/v1/update/download-url"; @@ -640,6 +687,18 @@ bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targe return true; } +void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel, + const QString& version, bool success) +{ + QJsonArray files; + for (const FileDownloadItem& item : m_fileItems) + files.append(QJsonObject{{"path", item.path}, {"size", item.size}}); + QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version}, + {"result", success ? "success" : "fail"}, {"files", files}}; + m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body, + [](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; }); +} + void UpdaterLogic::reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, @@ -648,6 +707,7 @@ void UpdaterLogic::reportResult(const QString& deviceId, QString url = m_serverAddr + "/api/v1/update/report"; QJsonObject body; + body["app_id"] = ConfigHelper::instance().getValue("App", "app_id"); body["device_id"] = deviceId; body["from_version"] = fromVer; body["to_version"] = toVer; diff --git a/client/Updater/UpdaterLogic.h b/client/Updater/UpdaterLogic.h index 6b2c0a3..2f6c525 100644 --- a/client/Updater/UpdaterLogic.h +++ b/client/Updater/UpdaterLogic.h @@ -29,9 +29,12 @@ public: bool validateLocalFiles(const QString& stagingDir, const QString& installedDir = QString()) const; bool saveManifestCache(const QString& cacheDir) const; bool loadManifestCache(const QString& cacheDir, const QString& version); + bool loadOfflinePackage(const QString& packagePath, const QString& stagingDir = QString()); + QString offlineError() const { return m_offlineError; } void getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId); void reportResult(const QString& deviceId, const QString& fromVer, const QString& toVer, bool success); + void reportDownloadResult(const QString& appId, const QString& channel, const QString& version, bool success); bool downloadAllFiles(const QString& tempDir, const QString& targetDir); qint64 estimateAdditionalDiskBytes(const QString& targetDir, const QStringList& obsoletePaths) const; QString calcLocalFileSha256(const QString& filePath) const; @@ -65,5 +68,6 @@ private: qint64 m_downloadCompletedBytes = 0; qint64 m_sessionDownloadedBytes = 0; QString m_currentDownloadPath; + QString m_offlineError; QElapsedTimer m_downloadTimer; }; \ No newline at end of file diff --git a/client/Updater/main.cpp b/client/Updater/main.cpp index f782472..2fda78b 100644 --- a/client/Updater/main.cpp +++ b/client/Updater/main.cpp @@ -17,6 +17,9 @@ #include "UpdateTransaction.h" #include "FileHelper.h" #include "ConfigHelper.h" +#include "TicketHelper.h" +#include "PolicyHelper.h" +#include int main(int argc, char* argv[]) { @@ -25,15 +28,30 @@ int main(int argc, char* argv[]) QApplication app(argc, argv); QApplication::setApplicationName("Marsco Updater"); - if (argc < 5) { - QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少应用、渠道或目标版本参数,请从 Launcher 启动。"); - return -1; + UpdaterLogic logic; + QString offlinePackagePath; + QString appId; + QString channel; + QString targetVersion; + int targetVersionId = 0; + if (argc >= 2 && QString(argv[1]).startsWith("--offline-package=")) { + offlinePackagePath = QString(argv[1]).mid(QString("--offline-package=").size()); + if (!logic.loadOfflinePackage(offlinePackagePath)) { + QMessageBox::critical(nullptr, "离线包无效", logic.offlineError()); + return -1; + } + const QJsonObject packageManifest = logic.getManifest(); + appId = packageManifest.value("app_id").toString(); + channel = packageManifest.value("channel").toString(); + targetVersion = packageManifest.value("version").toString(); + targetVersionId = packageManifest.value("manifest_seq").toInt(); + } else { + if (argc < 5) { + QMessageBox::critical(nullptr, "更新器参数错误", "更新器缺少在线更新参数或离线更新包。"); + return -1; + } + appId = argv[1]; channel = argv[2]; targetVersion = argv[3]; targetVersionId = QString(argv[4]).toInt(); } - - const QString appId = argv[1]; - const QString channel = argv[2]; - const QString targetVersion = argv[3]; - const int targetVersionId = QString(argv[4]).toInt(); QString bootstrapResult; for (int i = 5; i < argc; ++i) { const QString arg = argv[i]; @@ -44,7 +62,25 @@ int main(int argc, char* argv[]) const QString targetDir = QApplication::applicationDirPath(); ConfigHelper& config = ConfigHelper::instance(); + if (appId != config.getValue("App", "app_id") || channel != config.getValue("App", "channel")) { + QMessageBox::critical(nullptr, "离线包不适用", "更新包的应用或渠道与本机配置不一致。"); + return -1; + } QString fromVersion = config.getValue("App", "current_version"); + if (!offlinePackagePath.isEmpty()) { + PolicyHelper offlinePolicy(targetDir); + if (!offlinePolicy.loadPolicy() || !offlinePolicy.isValid() || offlinePolicy.isExpired() + || !offlinePolicy.isOfflineAllowed() || !offlinePolicy.isVersionAllowed(targetVersion)) { + QMessageBox::critical(nullptr, "离线更新被拒绝", "本地签名策略已过期、禁止离线更新或不允许目标版本。"); + return -1; + } + if (QVersionNumber::compare(QVersionNumber::fromString(targetVersion), + QVersionNumber::fromString(fromVersion)) < 0 + && !offlinePolicy.allowRollback()) { + QMessageBox::critical(nullptr, "禁止降级", "当前签名策略不允许安装较低版本的离线包。"); + return -1; + } + } QString deviceId = config.getValue("Update", "device_id"); if (deviceId.isEmpty()) deviceId = "unknown_device"; @@ -75,7 +111,6 @@ int main(int argc, char* argv[]) progress.show(); QApplication::processEvents(); - UpdaterLogic logic; UpdateTransaction transaction(targetDir, fromVersion, targetVersion, targetVersionId); const auto setProgress = [&](int value, const QString& message) { progress.setValue(value); @@ -103,22 +138,46 @@ int main(int argc, char* argv[]) formatBytes(static_cast(bytesPerSecond)))); QApplication::processEvents(); }); + const auto reportUpdateResult = [&](bool success) { + if (offlinePackagePath.isEmpty()) + logic.reportResult(deviceId, fromVersion, targetVersion, success); + }; const auto fail = [&](const QString& title, const QString& message, const QString& errorCode = QString("update_failed")) { transaction.markFailed(errorCode, message); - logic.reportResult(deviceId, fromVersion, targetVersion, false); + reportUpdateResult(false); progress.close(); QMessageBox::critical(nullptr, title, message); return -1; }; - const QString mainAppPath = QDir(targetDir).filePath("MainApp.exe"); - const QString updaterPath = QDir(targetDir).filePath("Updater.exe"); - const QString bootstrapPath = QDir(targetDir).filePath("Bootstrap.exe"); + const auto configuredName = [&](const QString& key, const QString& fallback) { + const QString value = config.getValue("Runtime", key).trimmed(); + return value.isEmpty() ? fallback : value; + }; + const QString mainExecutable = configuredName("main_executable", "MainApp.exe"); + const QString updaterExecutable = configuredName("updater_executable", "Updater.exe"); + const QString bootstrapExecutable = configuredName("bootstrap_executable", "Bootstrap.exe"); + bool timeoutOk = false; + int healthCheckTimeoutMs = config.getValue("Runtime", "health_check_timeout_ms").toInt(&timeoutOk); + if (!timeoutOk || healthCheckTimeoutMs < 1000) healthCheckTimeoutMs = 15000; + const QString mainAppPath = QDir(targetDir).filePath(mainExecutable); + const QString updaterPath = QDir(targetDir).filePath(updaterExecutable); + const QString bootstrapPath = QDir(targetDir).filePath(bootstrapExecutable); const QString launchToken = config.getValue("App", "launch_token"); const auto launchMainApp = [&](const QString& healthFile = QString()) { - QStringList args{QString("--launcher-token=%1").arg(launchToken)}; + QString ticketPath; + QString ticketError; + const QString launchVersion = config.getValue("App", "current_version"); + if (!TicketHelper::createTicket(appId, deviceId, launchVersion, launchToken, + &ticketPath, &ticketError)) { + qDebug() << "Cannot create launch ticket:" << ticketError; + return false; + } + QStringList args{QString("--ticket-file=%1").arg(ticketPath)}; if (!healthFile.isEmpty()) args.append(QString("--health-file=%1").arg(healthFile)); - return QProcess::startDetached(mainAppPath, args); + const bool started = QProcess::startDetached(mainAppPath, args); + if (!started) QFile::remove(ticketPath); + return started; }; const auto bootstrapPlanFile = [&]() { return QDir(targetDir).filePath("update/bootstrap_plan_" + transaction.transactionId() + ".txt"); @@ -132,7 +191,7 @@ int main(int argc, char* argv[]) return QFile::exists(bootstrapPath) && QProcess::startDetached(bootstrapPath, args); }; const auto delegateRollback = [&](const QString& title, const QString& reason) { - FileHelper::killProcess("MainApp.exe"); + FileHelper::killProcess(mainExecutable); transaction.markRollbackRequired(reason); progress.setLabelText("正在将回滚工作移交给 Bootstrap..."); QApplication::processEvents(); @@ -140,7 +199,7 @@ int main(int argc, char* argv[]) progress.close(); return 0; } - logic.reportResult(deviceId, fromVersion, targetVersion, false); + reportUpdateResult(false); progress.close(); QMessageBox::critical(nullptr, title, reason + "\n\n无法启动 Bootstrap 执行回滚。请不要继续运行软件,并联系管理员。"); @@ -150,17 +209,19 @@ int main(int argc, char* argv[]) if (resumingFromBootstrap) { QString resumeError; if (!transaction.resumeExisting(&resumeError)) { - logic.reportResult(deviceId, fromVersion, targetVersion, false); + reportUpdateResult(false); progress.close(); QMessageBox::critical(nullptr, "事务续办失败", QString("无法读取 Bootstrap 更新事务:%1").arg(resumeError)); return -1; } fromVersion = transaction.fromVersion(); + if (QFile::exists(QDir(transaction.backupDir()).filePath(".offline_mode"))) + offlinePackagePath = "__bootstrap_resumed_offline__"; if (bootstrapResult == "rolledback") { const bool stateOk = config.setValue("App", "current_version", fromVersion); transaction.markRolledBack(); - logic.reportResult(deviceId, fromVersion, targetVersion, false); + reportUpdateResult(false); if (stateOk) launchMainApp(); progress.close(); QMessageBox::warning(nullptr, "更新已回滚", @@ -169,7 +230,7 @@ int main(int argc, char* argv[]) return stateOk ? 0 : -1; } if (bootstrapResult != "success") { - logic.reportResult(deviceId, fromVersion, targetVersion, false); + reportUpdateResult(false); progress.close(); QMessageBox::critical(nullptr, "自动回滚失败", "Bootstrap 无法完整恢复旧版本。请不要继续运行软件,并联系管理员。"); @@ -177,16 +238,25 @@ int main(int argc, char* argv[]) } } else if (!transaction.initialize()) { return fail("更新准备失败", "无法创建更新事务目录或保存事务状态。", "transaction_init_failed"); + } else if (!offlinePackagePath.isEmpty()) { + QFile marker(QDir(transaction.backupDir()).filePath(".offline_mode")); + if (!marker.open(QIODevice::WriteOnly) || marker.write("offline\n") != 8) + return fail("更新准备失败", "无法保存离线事务标记。", "offline_marker_failed"); } - setProgress(resumingFromBootstrap ? 72 : 10, "正在获取并验证版本清单..."); - logic.getManifest(appId, channel, targetVersion, targetVersionId); + setProgress(resumingFromBootstrap ? 72 : 10, offlinePackagePath.isEmpty() ? "正在获取并验证版本清单..." : "正在验证离线更新包..."); + const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache"); + if (resumingFromBootstrap) { + if (!logic.loadManifestCache(manifestCacheDir, targetVersion)) + return delegateRollback("清单缓存失败", "Bootstrap 安装后无法读取签名 Manifest 缓存。"); + } else if (offlinePackagePath.isEmpty()) { + logic.getManifest(appId, channel, targetVersion, targetVersionId); + } if (!logic.verifyManifestSignature()) { if (resumingFromBootstrap) return delegateRollback("安全验证失败", "Bootstrap 安装后无法重新验证版本清单签名。"); return fail("安全验证失败", "版本清单签名无效,更新已停止。请联系管理员。", "manifest_signature_invalid"); } - const QString manifestCacheDir = QDir(targetDir).filePath("update/manifest_cache"); QStringList obsoletePaths; if (!resumingFromBootstrap && fromVersion != targetVersion) { UpdaterLogic oldManifestLogic; @@ -206,8 +276,9 @@ int main(int argc, char* argv[]) } if (!resumingFromBootstrap) { - setProgress(25, "正在获取安全下载地址..."); - logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId); + setProgress(25, offlinePackagePath.isEmpty() ? "正在获取安全下载地址..." : "正在准备离线包文件..."); + if (offlinePackagePath.isEmpty()) + logic.getDownloadUrl(appId, channel, targetVersion, targetVersionId); if (logic.getFileList().isEmpty()) return fail("没有可更新文件", "服务器没有返回任何版本文件,更新已停止。", "empty_file_list"); @@ -224,9 +295,17 @@ int main(int argc, char* argv[]) } const QString stagingDir = transaction.stagingDir(); - setProgress(30, QString("正在下载并校验 %1 个版本文件...").arg(logic.getFileList().size())); - if (!logic.downloadAllFiles(stagingDir, targetDir)) - return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed"); + setProgress(30, QString(offlinePackagePath.isEmpty() ? "正在下载并校验 %1 个版本文件..." : "正在提取并校验 %1 个离线文件...").arg(logic.getFileList().size())); + if (!offlinePackagePath.isEmpty()) { + if (!logic.loadOfflinePackage(offlinePackagePath, stagingDir)) + return fail("离线包提取失败", logic.offlineError(), "offline_package_invalid"); + } else { + if (!logic.downloadAllFiles(stagingDir, targetDir)) { + logic.reportDownloadResult(appId, channel, targetVersion, false); + return fail("下载失败", "部分文件下载失败或 SHA-256 校验未通过,请检查网络后重试。", "download_failed"); + } + logic.reportDownloadResult(appId, channel, targetVersion, true); + } setProgress(58, "正在校验完整版本文件..."); if (!logic.validateLocalFiles(stagingDir, targetDir)) @@ -238,15 +317,15 @@ int main(int argc, char* argv[]) while (stagingFiles.hasNext()) changedPaths.append(QDir::fromNativeSeparators(stagingRoot.relativeFilePath(stagingFiles.next()))); for (const QString& path : changedPaths) { - if (path.compare("Bootstrap.exe", Qt::CaseInsensitive) == 0) - return fail("Bootstrap 无法自更新", "本次版本包含新的 Bootstrap.exe。请使用安装包升级 Bootstrap,再重新发布业务版本。", "bootstrap_self_update_blocked"); + if (path.compare(bootstrapExecutable, Qt::CaseInsensitive) == 0) + return fail("Bootstrap 无法自更新", QString("本次版本包含新的 %1。请使用安装包升级该组件,再重新发布业务版本。").arg(bootstrapExecutable), "bootstrap_self_update_blocked"); } if (!transaction.recordVerifiedFiles(changedPaths, obsoletePaths)) return fail("事务记录失败", "无法保存已校验或待删除文件列表,更新已停止。", "transaction_record_failed"); setProgress(66, "正在关闭主程序..."); - if (!FileHelper::killProcess("MainApp.exe")) - return fail("无法关闭主程序", "MainApp.exe 仍在运行,请手动关闭后重试。", "mainapp_close_failed"); + if (!FileHelper::killProcess(mainExecutable)) + return fail("无法关闭主程序", QString("%1 仍在运行,请手动关闭后重试。").arg(mainExecutable), "mainapp_close_failed"); setProgress(72, QString("正在备份 %1 个待变更文件(其中删除 %2 个)...") .arg(changedPaths.size() + obsoletePaths.size()).arg(obsoletePaths.size())); @@ -300,16 +379,16 @@ int main(int argc, char* argv[]) QFile::remove(healthFile); setProgress(94, "正在启动新版本并等待健康确认..."); if (!launchMainApp(healthFile)) - return delegateRollback("启动失败", "MainApp.exe 无法启动。"); + return delegateRollback("启动失败", QString("%1 无法启动。").arg(mainExecutable)); QElapsedTimer healthTimer; healthTimer.start(); - while (healthTimer.elapsed() < 15000 && !QFile::exists(healthFile)) { + while (healthTimer.elapsed() < healthCheckTimeoutMs && !QFile::exists(healthFile)) { QApplication::processEvents(); QThread::msleep(100); } if (!QFile::exists(healthFile)) - return delegateRollback("启动确认失败", "新版本在 15 秒内没有完成启动健康确认。"); + return delegateRollback("启动确认失败", QString("新版本在 %1 毫秒内没有完成启动健康确认。").arg(healthCheckTimeoutMs)); setProgress(99, "正在提交更新事务..."); if (!transaction.commit()) @@ -317,7 +396,7 @@ int main(int argc, char* argv[]) QFile::remove(healthFile); QFile::remove(bootstrapPlanFile()); - logic.reportResult(deviceId, fromVersion, targetVersion, true); + reportUpdateResult(true); progress.setValue(100); progress.close(); QMessageBox::information(nullptr, "更新完成", QString("软件已成功更新到 %1,并通过启动健康检查。").arg(targetVersion)); diff --git a/client/admin.html b/client/admin.html index 6f9191e..34fd668 100644 --- a/client/admin.html +++ b/client/admin.html @@ -5,65 +5,339 @@ Marsco 发布控制台
@@ -77,9 +351,19 @@ -
令牌默认隐藏,仅保存在当前浏览器;服务端只保存令牌哈希。
+
令牌默认隐藏,仅保存在当前浏览器;服务端按 .env 中的 ADMIN_TOKEN 校验。
+
当前连接服务端:
@@ -99,6 +383,18 @@ +
+

渠道管理

+
+ + + + +
+
+
代码名称状态排序操作
+
+

发布新版本

@@ -107,7 +403,8 @@ - + +
@@ -131,6 +428,7 @@ ID 版本 渠道 + 协议 最新 创建时间 操作 @@ -143,7 +441,7 @@

版本运行策略

- + @@ -160,6 +458,25 @@
+
+

License 授权

+
+ + + + +
+
+
+
License ID客户渠道设备有效期状态操作
+
+ +
+

设备管理

+
显示当前应用登记的设备;禁用后客户端请求会立即被拒绝
+
设备 ID状态凭证序列最近访问IP操作
+
+

升级日志

@@ -179,14 +496,26 @@
+
+

文件下载日志

+
设备License版本/渠道文件大小结果IP时间
+
+
+

管理员审计日志

+
管理员指纹操作结果状态码IP时间
+
+

调试输出

准备就绪...
diff --git a/client/config/app_config.example.json b/client/config/app_config.example.json index b13e4b3..6b1a899 100644 --- a/client/config/app_config.example.json +++ b/client/config/app_config.example.json @@ -1,14 +1,21 @@ { - "app_id": "your_app_id", - "app_name": "Your Application", + "app_id": "simcae", + "app_name": "SimCAE", "channel": "stable", - "api_base_url": "http://127.0.0.1:8000", - "platform": "windows", - "arch": "x64", "current_version": "1.0.0", - "client_token": "replace_me", - "launch_token": "replace_me", - "request_timeout_ms": 5000, + "client_protocol": "3", + "launch_token": "SimCAE_Launch_Token_2026_ChangeMe_32Bytes", + "license_key": "", + "api_base_url": "http://YOUR_SERVER_IP:8000", + "client_token": "SimCAEClientToken2026", + "request_timeout_ms": "5000", "temp_folder": "update_temp", - "device_id": "" + "device_id": "", + "main_executable": "SimCAE.exe", + "launcher_executable": "Launcher.exe", + "updater_executable": "Updater.exe", + "bootstrap_executable": "Bootstrap.exe", + "health_check_timeout_ms": "15000", + "platform": "windows", + "arch": "x64" } diff --git a/client/config/local_state.json b/client/config/local_state.json deleted file mode 100644 index daa85ab..0000000 --- a/client/config/local_state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "max_policy_seq": 0, - "last_success_run_at": "", - "last_online_verified_at": "", - "last_success_version": "" -} diff --git a/client/config/version_policy.dat b/client/config/version_policy.dat deleted file mode 100644 index da9b067..0000000 --- a/client/config/version_policy.dat +++ /dev/null @@ -1 +0,0 @@ -{"disabled_versions":[],"force_update":false,"offline_allowed":true,"policy_seq":1,"valid_until":"2099-12-31T23:59:59Z","signature":"d6982cb02d9a0c6fb6b7176961a7736dbee0decfbd5e422bd189521c72664205"} \ No newline at end of file diff --git a/client/install-sdk.ps1 b/client/install-sdk.ps1 new file mode 100644 index 0000000..8c94d94 --- /dev/null +++ b/client/install-sdk.ps1 @@ -0,0 +1,41 @@ +param( + [Parameter(Mandatory=$true)] + [string]$SdkRoot, + + [string]$ReleaseDir = (Get-Location).Path, + + [switch]$OverwriteConfig +) + +$ErrorActionPreference = "Stop" + +$sdk = (Resolve-Path $SdkRoot).Path +$release = (Resolve-Path $ReleaseDir).Path + +$binDir = Join-Path $sdk "bin" +$configDir = Join-Path $sdk "config" +$appConfig = Join-Path $configDir "app_config.json" +$publicKey = Join-Path $configDir "manifest_public_key.pem" + +foreach ($path in @($binDir, $appConfig, $publicKey)) { + if (-not (Test-Path $path)) { + throw "SDK file is missing: $path" + } +} + +Copy-Item (Join-Path $binDir "*") $release -Recurse -Force + +$targetConfigDir = Join-Path $release "config" +New-Item $targetConfigDir -ItemType Directory -Force | Out-Null + +$targetAppConfig = Join-Path $targetConfigDir "app_config.json" +if ((-not (Test-Path $targetAppConfig)) -or $OverwriteConfig) { + Copy-Item $appConfig $targetAppConfig -Force +} else { + Write-Host "Keep existing config/app_config.json. Use -OverwriteConfig to replace it." +} + +Copy-Item $publicKey (Join-Path $targetConfigDir "manifest_public_key.pem") -Force + +Write-Host "SDK files installed to: $release" +Write-Host "Next: edit config/app_config.json, then start Launcher.exe." diff --git a/client/package-client.ps1 b/client/package-client.ps1 new file mode 100644 index 0000000..a274ebf --- /dev/null +++ b/client/package-client.ps1 @@ -0,0 +1,87 @@ +param( + [string]$SourceDir = "$PSScriptRoot/out/bin", + [Parameter(Mandatory = $true)] + [string]$ConfigFile, + [string]$OutputDir = "$PSScriptRoot/dist/UpdateClient", + [string]$ZipFile = "$PSScriptRoot/dist/UpdateClient.zip" +) + +$ErrorActionPreference = "Stop" + +$source = (Resolve-Path $SourceDir).Path +$config = (Resolve-Path $ConfigFile).Path +$settings = Get-Content $config -Raw -Encoding UTF8 | ConvertFrom-Json + +$requiredFields = @( + "app_id", "channel", "api_base_url", "current_version", + "client_token", "launch_token", "license_key", + "main_executable", "launcher_executable", "updater_executable", "bootstrap_executable" +) +foreach ($field in $requiredFields) { + if (-not $settings.$field) { + throw "Config file is missing required field: $field" + } +} + +$requiredFiles = @( + $settings.main_executable, + $settings.launcher_executable, + $settings.updater_executable, + $settings.bootstrap_executable +) +foreach ($name in $requiredFiles) { + if (-not (Test-Path (Join-Path $source $name))) { + throw "Source directory is missing required file: $name" + } +} + +$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { + $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or + $_.Extension -in @('.pdb', '.ilk') +} +if ($debugArtifacts) { + throw "Source directory contains Debug artifacts. Clean out/bin and rebuild Release first. Example: $($debugArtifacts[0].FullName)" +} + +$nestedMain = Get-ChildItem $source -Recurse -File -Filter $settings.main_executable | Where-Object { + $_.DirectoryName -ne $source +} | Select-Object -First 1 +if ($nestedMain) { + throw "Source directory contains a nested duplicate main executable. Use a clean Release root directory: $($nestedMain.FullName)" +} + +$manifestName = "manifest_$($settings.current_version).json" +$sourceManifest = Join-Path $source "update/manifest_cache/$manifestName" +if (-not (Test-Path $sourceManifest)) { + throw "Missing signed Manifest cache for current version: $sourceManifest. Complete online update/verification for this version before packaging." +} + +if (Test-Path $OutputDir) { + Remove-Item $OutputDir -Recurse -Force +} +New-Item $OutputDir -ItemType Directory -Force | Out-Null + +Get-ChildItem $source -Force | Where-Object { + $_.Name -notin @("update", "update_temp") +} | Copy-Item -Destination $OutputDir -Recurse -Force + +$outputConfigDir = Join-Path $OutputDir "config" +New-Item $outputConfigDir -ItemType Directory -Force | Out-Null +Copy-Item $config (Join-Path $outputConfigDir "app_config.json") -Force + +@("client_identity.dat", "local_state.json", "version_policy.dat") | ForEach-Object { + $runtimeFile = Join-Path $outputConfigDir $_ + if (Test-Path $runtimeFile) { Remove-Item $runtimeFile -Force } +} + +$manifestDir = Join-Path $OutputDir "update/manifest_cache" +New-Item $manifestDir -ItemType Directory -Force | Out-Null +Copy-Item $sourceManifest (Join-Path $manifestDir $manifestName) -Force + +$zipParent = Split-Path $ZipFile -Parent +New-Item $zipParent -ItemType Directory -Force | Out-Null +if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } +Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal + +Write-Host "Package directory: $OutputDir" +Write-Host "ZIP file: $ZipFile" diff --git a/client/package-sdk.ps1 b/client/package-sdk.ps1 new file mode 100644 index 0000000..e289708 --- /dev/null +++ b/client/package-sdk.ps1 @@ -0,0 +1,86 @@ +param( + [string]$SourceDir = "$PSScriptRoot/out/bin", + [string]$OutputDir = "$PSScriptRoot/dist/UpdateClientSDK", + [string]$ZipFile = "$PSScriptRoot/dist/UpdateClientSDK.zip", + [string]$SdkVersion = "0.1.0", + [string]$ExampleConfig = "$PSScriptRoot/config/app_config.example.json", + [switch]$IncludeDemoMainApp +) + +$ErrorActionPreference = "Stop" + +$source = (Resolve-Path $SourceDir).Path +$exampleConfigPath = (Resolve-Path $ExampleConfig).Path + +$requiredFiles = @("Launcher.exe", "Updater.exe", "Bootstrap.exe") +foreach ($name in $requiredFiles) { + $path = Join-Path $source $name + if (-not (Test-Path $path)) { + throw "SDK source directory is missing required file: $path" + } +} + +$publicKeyCandidates = @( + (Join-Path $source "config/manifest_public_key.pem"), + (Join-Path $source "manifest_public_key.pem"), + (Join-Path $PSScriptRoot "config/manifest_public_key.pem") +) +$publicKey = $publicKeyCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $publicKey) { + throw "manifest_public_key.pem is missing. Prepare the public key that matches the server signing private key." +} + +$debugArtifacts = Get-ChildItem $source -Recurse -File | Where-Object { + $_.Name -match '^(Qt5.*d|qwindowsd|libEGLd|libGLESv2d|msvcp.*d|vcruntime.*d)\.dll$' -or + $_.Extension -in @('.pdb', '.ilk') +} +if ($debugArtifacts) { + throw "SDK source directory contains Debug artifacts. Use a clean Release output directory. Example: $($debugArtifacts[0].FullName)" +} + +if (Test-Path $OutputDir) { Remove-Item $OutputDir -Recurse -Force } +New-Item $OutputDir -ItemType Directory -Force | Out-Null + +$binDir = Join-Path $OutputDir "bin" +$configDir = Join-Path $OutputDir "config" +$scriptsDir = Join-Path $OutputDir "scripts" +New-Item $binDir,$configDir,$scriptsDir -ItemType Directory -Force | Out-Null + +$excludedTopLevel = @("config", "update", "update_temp") +if (-not $IncludeDemoMainApp) { $excludedTopLevel += "MainApp.exe" } + +Get-ChildItem $source -Force | Where-Object { + $_.Name -notin $excludedTopLevel +} | Copy-Item -Destination $binDir -Recurse -Force + +Copy-Item $exampleConfigPath (Join-Path $configDir "app_config.json") -Force +Copy-Item $publicKey (Join-Path $configDir "manifest_public_key.pem") -Force + +$wordGuideSource = Get-ChildItem $PSScriptRoot -File -Filter "*.docx" | Where-Object { + $_.Name -like "*SDK*.docx" -and $_.Name -notlike "~$*" +} | Sort-Object Name | Select-Object -First 1 +if (-not $wordGuideSource) { + throw "SDK integration Word guide is missing. Expected a *SDK*.docx file in the client directory." +} +Copy-Item $wordGuideSource.FullName (Join-Path $OutputDir $wordGuideSource.Name) -Force + +Copy-Item (Join-Path $PSScriptRoot "package-client.ps1") (Join-Path $scriptsDir "package-client.ps1") -Force +Copy-Item (Join-Path $PSScriptRoot "package-sdk.ps1") (Join-Path $scriptsDir "package-sdk.ps1") -Force +Copy-Item (Join-Path $PSScriptRoot "install-sdk.ps1") (Join-Path $scriptsDir "install-sdk.ps1") -Force + +@{ + sdk_version = $SdkVersion + generated_at = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ") + sdk_type = "external-updater-runtime" + required_entry = "Launcher.exe" + contains_demo_main_app = [bool]$IncludeDemoMainApp +} | ConvertTo-Json -Depth 3 | Set-Content (Join-Path $OutputDir "sdk_manifest.json") -Encoding UTF8 + +$zipParent = Split-Path $ZipFile -Parent +New-Item $zipParent -ItemType Directory -Force | Out-Null +if (Test-Path $ZipFile) { Remove-Item $ZipFile -Force } +Compress-Archive -Path (Join-Path $OutputDir "*") -DestinationPath $ZipFile -CompressionLevel Optimal + +Write-Host "SDK directory: $OutputDir" +Write-Host "SDK zip: $ZipFile" +Write-Host "SDK version: $SdkVersion" diff --git a/client/软件自动升级系统接入说明.docx b/client/软件自动升级系统接入说明.docx new file mode 100644 index 0000000..7661a2a Binary files /dev/null and b/client/软件自动升级系统接入说明.docx differ diff --git a/client/项目进度.txt b/client/项目进度.txt deleted file mode 100644 index 61e1da3..0000000 --- a/client/项目进度.txt +++ /dev/null @@ -1,538 +0,0 @@ -软件自动升级与版本控制系统——项目进度 -更新时间:2026-07-01 -依据:《软件自动升级与版本控制系统开发设计文档 v0.1》及当前 server/client 源码 - -============================================================ -一、项目进度概述 -============================================================ - -目前项目已经完成一套可实际运行的 Windows 在线自动更新主链路。 - -客户端已经具备 Launcher、Updater、MainApp、Bootstrap 四个程序;服务端已经具备 FastAPI、SQLite、MinIO、版本发布、Manifest、下载授权、升级结果上报、版本策略管理和管理网页。客户端可以检查版本、验证 RSA 签名、差异下载、断点续传、备份旧文件、通过 Bootstrap 替换被占用文件、删除废弃文件、启动新版本并等待健康确认;失败时可以进入自动回滚流程。 - -目前 1.1.3 的正常升级流程已经实际运行成功,说明在线更新的成功路径已经基本打通。 - -按需求文档的 Demo 里程碑判断: - -1. 第一阶段“基础框架”:基本完成。 -2. 第二阶段“服务端基础能力”:基本完成。 -3. 第三阶段“在线更新”:基本完成,正常升级已实测。 -4. 第四阶段“版本策略”:大部分完成。 -5. 第五阶段“离线能力”:完成离线运行基础,离线更新包尚未实现。 -6. 第六阶段“可靠性与安全”:完成事务、回滚、策略防回退和时间检测;插件白名单、限流、完整日志等尚未实现。 - -如果只计算“在线更新 Demo”,当前完成度约为 80%。 -如果按照整份设计文档计算,当前完成度约为 60%~65%。 - -当前最主要的剩余工作不是普通在线更新,而是进一步补齐安全准入和管理能力,包括:一次性启动票据、MainApp 启动时核心文件完整性检查、设备身份、插件白名单、下载与审计日志、动态渠道、限流、离线更新包等。 - -============================================================ -二、已经实现的功能 -============================================================ - -2.1 客户端基础框架 - -已实现: - -1. Launcher.exe。 -2. Updater.exe。 -3. MainApp.exe。 -4. 独立原生 Bootstrap.exe。 -5. Qt 图形提示和更新进度界面。 -6. MainApp 显示当前版本。 -7. MainApp 显示 Demo DLL 的简化 Hash 标识。 -8. Launcher 启动 MainApp。 -9. 直接双击 MainApp 时拒绝运行。 -10. Windows x64 CMake 工程。 -11. Qt 5.15、MSVC、OpenSSL 构建配置。 - -说明:MainApp 当前读取 Demo DLL 内容并显示 Hash 标识,但还没有通过 QLibrary 真正加载并调用插件接口,因此“插件实际加载”只算部分完成。 - -2.2 本地 JSON 配置 - -已实现: - -1. 使用 config/app_config.json 保存客户端配置。 -2. 支持旧 client.ini 自动迁移。 -3. 保存 API 地址、App ID、当前版本、渠道、设备 ID、客户端 Token、启动 Token、平台和架构。 -4. 更新成功后写入 current_version。 -5. 使用 QSaveFile 原子写入主要配置。 -6. 运行时配置不进入发布包。 -7. 已配置项目 .gitignore。 - -2.3 服务端基础能力 - -已实现: - -1. FastAPI HTTP 服务。 -2. SQLite 数据库。 -3. MinIO 对象存储。 -4. 应用创建与查询。 -5. 版本发布、查询、设置最新和删除。 -6. stable、preview、dev 三个固定渠道。 -7. 删除版本时同步删除 MinIO 文件。 -8. 发布失败时清理 MinIO 和本地回退目录中的半成品。 -9. 大文件上传前检查磁盘空间。 -10. multipart 临时文件存放到 /dev/shm,避免与 MinIO 双重占用根分区。 -11. MinIO 不可用时支持本地存储回退。 -12. 管理员 Token 鉴权和令牌修改。 -13. 管理页面和跨域配置。 - -当前主要接口: - -1. /api/v1/update/check -2. /api/v1/update/manifest -3. /api/v1/update/download-url -4. /api/v1/update/report -5. /admin/publish -6. /admin/version/list -7. /admin/version/set-latest -8. /admin/version/delete -9. /admin/policy -10. /admin/policy/save -11. /admin/report/list - -2.4 软件根目录和多层目录发布 - -已实现: - -1. 浏览器一次选择软件根目录。 -2. 保留所有文件的相对路径。 -3. 支持多层 DLL、插件和资源目录。 -4. 服务端阻止绝对路径、..、盘符路径和重复路径。 -5. Manifest、MinIO 和客户端安装过程都保留相对路径。 -6. 检查 MainApp.exe 是否位于发布根级。 -7. 排除 Bootstrap、运行时配置、状态文件、策略缓存、PDB、ILK 和更新临时目录。 - -2.5 Manifest - -已实现: - -1. 服务端动态生成全量 Manifest。 -2. 包含 App ID、版本、渠道、平台、架构、Manifest 序列和创建时间。 -3. 包含文件相对路径、大小、SHA-256 和 executable 标记。 -4. 使用稳定 JSON 序列化。 -5. 使用 RSA-2048/SHA-256 签名。 -6. 客户端使用内置公钥验签。 -7. 签名失败时拒绝安装。 -8. Manifest 本地缓存。 -9. 新旧 Manifest 对比。 -10. 根据新旧 Manifest 差集删除废弃文件。 - -2.6 在线升级完整流程 - -已实现流程: - -1. Launcher 请求更新检查。 -2. 服务端返回目标版本和签名策略。 -3. Launcher 验证版本策略 RSA 签名。 -4. Launcher 根据策略决定升级、降级、继续运行或禁止运行。 -5. 启动 Updater。 -6. Updater 获取并验证 Manifest。 -7. 获取 MinIO 预签名下载地址。 -8. 比较本地文件 SHA-256。 -9. 只下载新增或变化的文件。 -10. 校验文件大小和 SHA-256。 -11. 备份旧文件。 -12. Updater 退出并移交 Bootstrap。 -13. Bootstrap 替换文件或删除废弃文件。 -14. Bootstrap 重新启动 Updater 续办事务。 -15. Updater 完整校验安装结果。 -16. 保存新版本号。 -17. 启动 MainApp 并等待健康确认。 -18. 健康确认成功后提交事务。 -19. 上报升级成功结果。 - -2.7 差异下载、断点续传和下载体验 - -已实现: - -1. SHA 相同的文件跳过下载。 -2. HTTP Range 断点续传。 -3. 使用 update/download_cache/.part 保存片段。 -4. Updater 重启后仍可继续未完成文件。 -5. 单文件最多自动重试四次。 -6. 使用递增等待时间重试。 -7. 服务端不支持 Range 时安全地完整重下。 -8. 下载后验证大小和 SHA-256。 -9. 显示当前文件、已下载量、总下载量、速度和总进度。 -10. 清理不属于当前 Manifest 的旧片段。 - -2.8 客户端磁盘空间预检 - -下载前会计算: - -1. 尚未下载的字节数。 -2. 已存在的断点片段大小。 -3. 被覆盖文件需要的备份空间。 -4. 废弃文件需要的备份空间。 -5. 至少 128MB 的安全余量。 - -空间不足时会在开始下载前阻止更新,并显示所需空间和当前可用空间。 - -2.9 升级事务状态机 - -已实现 update/upgrade_state.json,包含: - -1. transaction_id。 -2. from_version。 -3. to_version。 -4. status。 -5. manifest_id。 -6. staging_dir。 -7. backup_dir。 -8. changed_paths。 -9. obsolete_paths。 -10. error_code 和 message。 - -已经实现的主要状态: - -prepared、verified、waiting_mainapp_exit、backed_up、awaiting_bootstrap、replacing、replaced、post_verify、rollback_required、rolling_back、rolled_back、committed、failed。 - -Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。 - -2.10 Bootstrap 自更新机制 - -已实现: - -1. Bootstrap 不依赖 Qt。 -2. Updater 退出后由 Bootstrap 接管安装。 -3. 可以替换 Updater.exe、Launcher.exe、MainApp.exe、Qt DLL、OpenSSL DLL、Qt 插件和业务文件。 -4. 完成后重新启动 Updater 续办事务。 -5. 回滚也由 Bootstrap 执行,避免运行中的 Updater 锁住自己。 -6. Bootstrap 自身属于不可由普通更新事务替换的根组件。 -7. 旧 Manifest 即使包含 Bootstrap,也会由客户端作为受保护文件忽略。 - -2.11 自动回滚和健康检查 - -已实现: - -1. 安装前备份旧文件。 -2. 替换失败时回滚。 -3. 安装后 Hash 校验失败时回滚。 -4. MainApp 无法启动时回滚。 -5. MainApp 15 秒内未写入健康标记时回滚。 -6. 版本状态保存失败时回滚。 -7. 回滚后恢复旧版本号。 -8. 回滚后重新启动旧 MainApp。 -9. 被删除的废弃文件也会在回滚时恢复。 -10. Updater 自身发生变化时由 Bootstrap 执行回滚。 - -2.12 版本运行策略 - -已实现: - -1. version_policies 数据表。 -2. 每个应用和渠道分别保存策略。 -3. 每次修改自动递增 policy_seq。 -4. RSA 签名策略在线下发。 -5. Launcher 验证策略签名。 -6. 签名失败时拒绝使用。 -7. 策略原子缓存到本地。 -8. policy_seq 防回滚。 -9. 强制升级。 -10. 禁用指定版本。 -11. 最低支持版本。 -12. 允许或禁止降级。 -13. 允许或禁止离线启动。 -14. 策略有效期。 -15. 自定义客户端提示。 -16. 管理页面策略编辑区。 -17. 上次在线验证时间和系统时间回拨检测。 - -2.13 受控降级和用户选择 - -已实现: - -1. 管理员可以将历史版本设置为渠道最新。 -2. 允许降级时服务端返回 rollback_allowed。 -3. 禁止降级时返回 rollback_denied。 -4. 用户可以选择是否执行降级。 -5. 用户拒绝降级后继续运行当前版本。 -6. 普通可选升级也允许用户选择稍后更新。 -7. 强制升级不能跳过。 -8. 降级复用完整事务、Bootstrap、校验、健康确认和失败回滚机制。 - -2.14 离线运行基础 - -已实现: - -1. 在线策略本地缓存。 -2. 本地策略 RSA 验签。 -3. offline_allowed。 -4. valid_until。 -5. 策略过期时拒绝启动。 -6. policy_seq 防回滚。 -7. 记录上次成功启动时间。 -8. 记录上次在线验证时间。 -9. 检测系统时间是否回拨。 - -说明:目前实现的是“离线运行”,不是“离线升级”。 - -2.15 管理页面 - -已实现: - -1. 管理员令牌输入、隐藏、显示和保存。 -2. 修改管理员令牌。 -3. 创建和选择应用。 -4. 选择软件根目录发布。 -5. stable、preview、dev 渠道选择。 -6. 版本列表。 -7. 设置最新版本。 -8. 删除版本和云端文件。 -9. 版本策略读取和保存。 -10. 升级日志。 -11. 调试输出。 -12. 发布文件预览、大小和状态提示。 -13. 页面美化和响应式布局。 - -============================================================ -三、部分实现的功能 -============================================================ - -3.1 启动票据 - -当前实现:Launcher 或 Updater 通过 --launcher-token 参数启动 MainApp,直接双击 MainApp 会被拒绝。 - -与需求差距: - -1. 不是临时 ticket 文件。 -2. 没有 app_id、client_id、device_id、version、nonce、issued_at、expires_at。 -3. 没有一次性使用后删除。 -4. 没有防重放。 -5. 使用的是配置中的长期固定 Token。 - -结论:只能防止普通用户直接双击,不能算完整安全启动票据。 - -3.2 MainApp 启动完整性检查 - -当前 MainApp 会检查启动 Token、版本策略签名、策略有效期、policy_seq 和时间回拨。 - -尚缺少: - -1. 启动时重新验证当前 Manifest。 -2. 核心 EXE/DLL 全量 Hash。 -3. 检测受控目录中未被 Manifest 声明的额外 EXE/DLL。 -4. 插件白名单。 -5. MainApp 自身 Hash 验证。 - -因此需求中的“手动篡改 DLL 后启动失败”目前不能保证通过。 - -3.3 回滚完整性 - -文件恢复和版本号恢复已经实现,但仍缺少: - -1. 回滚完成后加载旧 Manifest 并进行完整 Hash 校验。 -2. 更详细的逐文件回滚错误。 -3. 回滚失败后的修复安装入口。 -4. 完整断电、杀进程、文件占用测试。 - -3.4 Manifest 安全字段 - -RSA 签名已经实现,但仍缺少: - -1. signature_alg 字段。 -2. key_id 字段。 -3. 当前和上一公钥同时内置。 -4. 公钥轮换流程。 -5. 密钥吊销机制。 - -3.5 升级日志 - -当前只记录 device_id、旧版本、新版本、success/fail 和时间。 - -尚缺少: - -1. app_id 和 channel。 -2. transaction_id。 -3. error_code。 -4. 失败阶段和失败文件。 -5. 下载字节数和耗时。 -6. 回滚结果。 -7. 操作系统、架构和客户端 IP。 - -3.6 REST API 契约 - -当前更新检查主要接收 app_id、current_version 和 channel。 - -需求文档中的以下字段尚未进入完整闭环: - -1. client_id。 -2. device_id。 -3. license_id。 -4. platform。 -5. arch。 -6. operation。 -7. target_version。 - -3.7 管理员登录 - -当前采用单管理员 Token,服务端只保存 Token Hash。 - -尚缺少: - -1. admin_users 表。 -2. 正式登录接口。 -3. Token 过期时间。 -4. 多管理员和角色权限。 -5. 登录失败审计。 -6. 管理员操作审计。 - -3.8 数据库结构 - -已有 apps、versions、version_files、version_policies、upgrade_logs、update_report。 - -部分字段仍未达到文档设计,例如: - -1. versions 缺少 status、allow_rollback、rollback_targets、描述和发布时间等字段。 -2. version_files 缺少 storage_key、file_type、is_required。 -3. apps 字段较少。 -4. 缺少较完整的外键、索引和约束。 - -3.9 精确降级控制 - -已有允许/禁止降级,但仍缺少: - -1. rollback_targets 目标白名单。 -2. 数据格式兼容性规则。 -3. 主动输入目标版本。 -4. 不同版本间的允许降级关系。 -5. “允许用户降级”和“管理员强制回退”的独立策略。 - -============================================================ -四、尚未实现的核心功能 -============================================================ - -4.1 一次性短期启动票据 - -需要实现临时 ticket 文件、有效期、nonce、签名、版本绑定、设备绑定、一次使用后删除和防重放。 - -4.2 MainApp 核心文件完整性准入 - -需要在每次启动时验证当前已安装 Manifest、MainApp、核心 DLL、插件目录和额外可执行文件。 - -4.3 设备身份和激活 - -尚未实现: - -1. client_identity.dat 正式签发。 -2. 设备身份 RSA 签名。 -3. /api/v1/device/issue。 -4. 首次设备激活和刷新。 -5. 设备禁用。 -6. devices 表。 -7. 每次请求验证设备身份。 - -当前 device_id 只是普通配置字符串,客户端请求使用共享 client_token。 - -4.4 License 授权系统 - -尚未实现 licenses 表、授权有效期、设备绑定、最大设备数、授权禁用、许可证签名和授权渠道控制。 - -4.5 动态渠道管理 - -目前 stable、preview、dev 写死在客户端和后台。 - -尚未实现 channels 表、渠道新增、启用、禁用、渠道名称和按应用配置渠道。 - -4.6 离线更新包 - -尚未实现: - -1. .upd 离线包。 -2. 离线包生成。 -3. 离线包 Manifest 和 RSA 签名。 -4. 本地导入和校验。 -5. 解包到 staging。 -6. 离线事务安装。 - -4.7 插件白名单 - -尚未实现插件目录扫描、未知 DLL 检测、插件签名/Hash 准入和插件接口版本检查。 - -4.8 服务端限流 - -尚未实现更新检查限流、下载链接限流、完整包下载次数限制、每日字节额度、管理 API 限流以及 HTTP 429/RATE_LIMITED。 - -4.9 下载日志 - -尚未实现 download_logs 表,以及设备、版本、文件、下载字节、结果、IP 和时间等业务下载记录。 - -4.10 管理员审计日志 - -尚未实现 admin_audit_logs,包括发布、删除、设置最新、修改策略、修改令牌、登录失败等操作记录。 - -4.11 代码签名 - -尚未实现 Windows Authenticode、发布者验证和 EXE/DLL 代码签名检查。 - -4.12 灰度发布 - -尚未实现按设备、客户、地区、百分比或批次灰度,以及失败率自动停止。 - -4.13 多平台 - -目前只支持 Windows x64,尚未支持 Windows ARM64、Linux、macOS 和多平台 Manifest 分流。 - -============================================================ -五、需求文档 Demo 里程碑状态 -============================================================ - -第一阶段:基础框架——基本完成。 -缺口:Demo 插件尚未真正通过接口加载。 - -第二阶段:服务端基础能力——基本完成。 -缺口:动态 channels 表和设备身份未完成。 - -第三阶段:在线更新——基本完成。 -正常升级 1.1.3 已实际测试成功。 - -第四阶段:版本策略——大部分完成。 -缺口:动态渠道、rollback_targets 和强制回退独立策略。 - -第五阶段:离线能力——部分完成。 -已经支持离线运行和策略有效期;尚未实现离线更新包。 - -第六阶段:可靠性与安全——部分完成。 -已经支持事务回滚、policy_seq 和时间回拨检测;尚未实现插件白名单、限流、完整下载日志和启动完整性准入。 - -============================================================ -六、需求文档验收用例状态 -============================================================ - -1. 通过 Launcher 正常启动 MainApp:已实现。 -2. 直接启动 MainApp 被拒绝:已实现,但启动票据较弱。 -3. MainApp 显示主程序和 DLL 版本:已实现简化版。 -4. 后台发布版本:已实现。 -5. 客户端在线升级:已实现并实测。 -6. 升级后 DLL 变化:支持。 -7. 升级后 MainApp 自动重启:已实现。 -8. 手动篡改 DLL 后启动失败:未完整实现。 -9. 手动篡改 version_policy 后启动失败:已实现 RSA 验签。 -10. 强制升级:已实现,待最终客户端测试。 -11. 禁用版本:已实现,待最终客户端测试。 -12. preview 客户端获取 preview 更新:基础支持,待测试。 -13. 禁止降级:已实现并进行过 API 测试。 -14. 离线凭证未过期时启动:已实现,待测试。 -15. 离线凭证过期后拒绝启动:已实现,待测试。 -16. 模拟升级失败后成功回滚:代码已实现,待完整故障测试。 - -============================================================ -七、推荐后续开发顺序 -============================================================ - -建议依次实现: - -1. 一次性、短期、防重放的启动票据。 -2. MainApp 启动时核心文件完整性检查和插件白名单。 -3. 设备身份签发与服务端验证。 -4. 升级日志错误码、失败阶段、回滚结果和下载日志。 -5. 管理员审计日志。 -6. 动态渠道管理。 -7. rollback_targets 精确降级目标。 -8. 服务端限流。 -9. 离线更新包。 -10. 最后统一进行成功升级、强制升级、版本禁用、离线、断点续传、文件占用、进程中断、回滚和策略篡改测试。 - -当前下一项最值得实现的是:一次性启动票据,以及 MainApp 启动时的核心文件完整性检查。这两项是目前客户端启动准入中最大的安全缺口。 diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..1c52f22 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,8 @@ +** +!Dockerfile +!requirements.txt +!main.py +!db.py +!minio_tool.py +!tables.sql +!admin.html diff --git a/server/.env.docker.example b/server/.env.docker.example new file mode 100644 index 0000000..afedf2e --- /dev/null +++ b/server/.env.docker.example @@ -0,0 +1,69 @@ +# Docker 镜像名称。通常不用改;重新打镜像并改版本号时才需要同步修改。 +SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0 + +# 服务端端口。浏览器访问 http://服务器IP:8000/。 +SERVER_PORT=8000 + +# 容器内运行用户。通常不用改;如果服务器文件权限特殊,再改成对应用户的 uid/gid。 +APP_UID=1000 +APP_GID=1000 + +# 管理后台标题。可不填或不改。 +SERVICE_TITLE=SimCAE Update Service + +# 跨域来源。内网部署一般保持 * 即可;生产环境可改成指定域名。 +CORS_ALLOW_ORIGINS=* + +# 发布包目标平台。当前客户端是 Windows x64,通常不用改。 +TARGET_PLATFORM=windows +TARGET_ARCH=x64 + +# 业务主程序文件名。必须和你发布目录根级的主 exe 名称一致。 +RELEASE_MAIN_EXECUTABLE=SimCAE.exe + +# Manifest 签名密钥编号。通常不用改,换签名密钥体系时再改。 +SIGNING_KEY_ID=manifest-key-v1 + +# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。 +CLIENT_API_TOKEN=SimCAEClientToken2026 + +# 管理后台令牌。已给默认值,可直接试跑;网页登录时在 X-Admin-Token 输入这个值,正式部署建议修改。 +ADMIN_TOKEN=SimCAEAdminToken2026 + +# 崩溃报告接口。已给默认值,可直接试跑;正式部署建议修改。 +CRASH_SERVICE_VERSION=1.0.0 +CRASH_REPORT_TOKEN=SimCAECrashReportToken2026 +CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026 + +# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。 +CRASH_ADMIN_TOKEN= + +# 崩溃报告大小限制。通常不用改。 +CRASH_METADATA_MAX_KB=256 +CRASH_MINIDUMP_MAX_MB=128 +CRASH_ATTACHMENTS_MAX_MB=64 +CRASH_REQUEST_MAX_MB=200 +CRASH_SYMBOLS_MAX_MB=512 + +# MinIO 端口。通常不用改;如果端口被占用再改。 +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 + +# MinIO 用户名和密码。Docker 启动 MinIO 时会用这里的值初始化账号;已给默认值,可直接试跑,正式部署建议修改。 +MINIO_ACCESS_KEY=simcae_minio_admin +MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe + +# MinIO 存储桶名称。通常不用改。 +MINIO_BUCKET=updates + +# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。 +MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000 + +# MinIO 预签名下载链接有效期,单位分钟。通常不用改。 +SIGN_EXPIRE_MIN=60 + +# MinIO 连接超时和重试参数。通常不用改。 +MINIO_CONNECT_TIMEOUT_SEC=2 +MINIO_READ_TIMEOUT_SEC=5 +MINIO_RETRY_TOTAL=1 +MINIO_HEALTH_TIMEOUT_SEC=2 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..23b0e6b --- /dev/null +++ b/server/.env.example @@ -0,0 +1,75 @@ +# 服务监听地址和端口。本机开发一般保持 0.0.0.0:8000。 +SERVER_HOST=0.0.0.0 +SERVER_PORT=8000 + +# 本地开发时一般不用改;Docker 部署主要看 .env.docker.example。 +APP_UID=1000 +APP_GID=1000 +SERVICE_TITLE=Software Update Service +ADMIN_HTML_PATH=../client/admin.html +CORS_ALLOW_ORIGINS=* +TARGET_PLATFORM=windows +TARGET_ARCH=x64 +RELEASE_MAIN_EXECUTABLE=MainApp.exe +SIGNING_KEY_ID=manifest-key-v1 + +# 客户端访问服务端 API 的令牌。已给默认值,可直接试跑;正式部署建议修改,并填到客户端 app_config.json 的 client_token。 +CLIENT_API_TOKEN=SimCAEClientToken2026 + +# 管理后台令牌。已给默认值,可直接试跑;网页登录时在 X-Admin-Token 输入这个值,正式部署建议修改。 +ADMIN_TOKEN=SimCAEAdminToken2026 + +# 数据库和 Manifest 私钥路径。本地开发一般不用改。 +DB_FILE=mini.db +SQL_FILE=tables.sql +MANIFEST_PRIVATE_KEY_PATH=keys/manifest_private_key.pem + +# 上传暂存和本地兜底存储目录。通常不用改。 +UPLOAD_SPOOL_DIR=upload_spool +UPLOAD_SPACE_RESERVE_MB=256 +LOCAL_UPLOAD_ROOT=local_uploads +LOCAL_FILE_URL_BASE=http://127.0.0.1:8000/static + +# SimCAE 崩溃报告接口。接入崩溃上报时必须改;不接入崩溃上报也建议改成随机字符串。 +CRASH_SERVICE_VERSION=1.0.0 +CRASH_REPORT_TOKEN=SimCAECrashReportToken2026 +CRASH_SYMBOL_TOKEN=SimCAESymbolToken2026 + +# 崩溃报告管理令牌。可不填;不填时查询/下载崩溃报告使用 ADMIN_TOKEN。 +CRASH_ADMIN_TOKEN= +CRASH_STORAGE_ROOT=crash_storage + +# 崩溃报告大小限制。通常不用改。 +CRASH_METADATA_MAX_KB=256 +CRASH_MINIDUMP_MAX_MB=128 +CRASH_ATTACHMENTS_MAX_MB=64 +CRASH_REQUEST_MAX_MB=200 +CRASH_SYMBOLS_MAX_MB=512 + +# MinIO 地址。本地开发如果 MinIO 在本机 9000 端口,保持不变。 +MINIO_ENDPOINT=127.0.0.1:9000 + +# 客户端下载升级文件时访问的 MinIO 地址。必须改成 Windows 客户端能访问到的服务器地址。 +MINIO_PUBLIC_ENDPOINT=http://服务器IP:9000 + +# MinIO 用户名、密码和桶。必须和你的 MinIO 配置一致。 +MINIO_ACCESS_KEY=simcae_minio_admin +MINIO_SECRET_KEY=SimCAE_MinIO_2026_ChangeMe +MINIO_BUCKET=updates +MINIO_SECURE=false + +# MinIO 预签名下载链接和连接参数。通常不用改。 +SIGN_EXPIRE_MIN=60 +MINIO_CONNECT_TIMEOUT_SEC=2 +MINIO_READ_TIMEOUT_SEC=5 +MINIO_RETRY_TOTAL=1 +MINIO_HEALTH_TIMEOUT_SEC=2 + +# 本地演示便利项。Docker 部署时会强制关闭自动启动 MinIO。 +MINIO_AUTO_START=true +MINIO_AUTO_DOWNLOAD=false +MINIO_BIN_PATH=minio +MINIO_DATA_DIR=minio_data +MINIO_LISTEN_ADDRESS=:9000 +MINIO_START_WAIT_SEC=5 +MINIO_DOWNLOAD_URL=https://dl.min.io/server/minio/release/linux-amd64/minio diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..e0dad4d --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN addgroup --system updateapp && adduser --system --ingroup updateapp updateapp + +COPY server/requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY server/main.py server/db.py server/minio_tool.py server/tables.sql ./ +COPY --chown=updateapp:updateapp client/admin.html ./admin.html + +RUN mkdir -p /data/uploads /data/upload_spool /run/secrets/update-keys \ + && chown -R updateapp:updateapp /app /data \ + && chmod 644 /app/admin.html + +USER updateapp + +EXPOSE 8000 + +HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)" + +CMD ["python", "main.py"] diff --git a/server/db.py b/server/db.py index fa004f3..b408345 100644 --- a/server/db.py +++ b/server/db.py @@ -5,8 +5,16 @@ from dotenv import load_dotenv load_dotenv() -DB_FILE = os.getenv("DB_FILE") -SQL_FILE = os.getenv("SQL_FILE") +BASE_DIR = Path(__file__).resolve().parent + + +def configured_path(name: str, default: str) -> Path: + path = Path(os.getenv(name, default)).expanduser() + return path if path.is_absolute() else BASE_DIR / path + + +DB_FILE = configured_path("DB_FILE", "mini.db") +SQL_FILE = configured_path("SQL_FILE", "tables.sql") # 初始化数据库 def init_db(): @@ -27,4 +35,4 @@ def get_conn(): return conn if __name__ == "__main__": - init_db() \ No newline at end of file + init_db() diff --git a/server/docker-compose.image.yml b/server/docker-compose.image.yml new file mode 100644 index 0000000..45542c0 --- /dev/null +++ b/server/docker-compose.image.yml @@ -0,0 +1,72 @@ +services: + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + command: server /data --console-address ":9001" + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - ./minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + depends_on: + minio: + condition: service_healthy + restart: "no" + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" && + mc mb --ignore-existing "update-store/$${MINIO_BUCKET}" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required} + + api: + image: ${SIMCAE_UPDATE_SERVER_IMAGE:-simcae-update-server:0.1.0} + restart: unless-stopped + user: "${APP_UID:-1000}:${APP_GID:-1000}" + env_file: + - .env + environment: + SERVER_HOST: 0.0.0.0 + SERVER_PORT: 8000 + DB_FILE: /data/mini.db + SQL_FILE: /app/tables.sql + ADMIN_HTML_PATH: /app/admin.html + LOCAL_UPLOAD_ROOT: /data/uploads + UPLOAD_SPOOL_DIR: /data/upload_spool + MINIO_DATA_DIR: /data + CRASH_STORAGE_ROOT: /data/crash_storage + MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem + MINIO_ENDPOINT: minio:9000 + MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required} + MINIO_AUTO_START: "false" + MINIO_SECURE: "false" + ports: + - "${SERVER_PORT:-8000}:8000" + volumes: + - ./runtime:/data + - ./keys:/run/secrets/update-keys:ro + depends_on: + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] + interval: 15s + timeout: 3s + start_period: 10s + retries: 3 diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..d37e158 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,74 @@ +services: + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + command: server /data --console-address ":9001" + restart: unless-stopped + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + ports: + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" + volumes: + - ./minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + + minio-init: + image: minio/mc:RELEASE.2025-04-16T18-13-26Z + depends_on: + minio: + condition: service_healthy + restart: "no" + entrypoint: ["/bin/sh", "-c"] + command: + - >- + mc alias set update-store http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}" && + mc mb --ignore-existing "update-store/$${MINIO_BUCKET}" + environment: + MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required} + MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required} + MINIO_BUCKET: ${MINIO_BUCKET:?MINIO_BUCKET is required} + + api: + build: + context: .. + dockerfile: server/Dockerfile + restart: unless-stopped + user: "${APP_UID:-1000}:${APP_GID:-1000}" + env_file: + - .env + environment: + SERVER_HOST: 0.0.0.0 + SERVER_PORT: 8000 + DB_FILE: /data/mini.db + SQL_FILE: /app/tables.sql + ADMIN_HTML_PATH: /app/admin.html + LOCAL_UPLOAD_ROOT: /data/uploads + UPLOAD_SPOOL_DIR: /data/upload_spool + MINIO_DATA_DIR: /data + CRASH_STORAGE_ROOT: /data/crash_storage + MANIFEST_PRIVATE_KEY_PATH: /run/secrets/update-keys/manifest_private_key.pem + MINIO_ENDPOINT: minio:9000 + MINIO_PUBLIC_ENDPOINT: ${MINIO_PUBLIC_ENDPOINT:?MINIO_PUBLIC_ENDPOINT is required} + MINIO_AUTO_START: "false" + MINIO_SECURE: "false" + ports: + - "${SERVER_PORT:-8000}:8000" + volumes: + - ./runtime:/data + - ./keys:/run/secrets/update-keys:ro + depends_on: + minio: + condition: service_healthy + minio-init: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"] + interval: 15s + timeout: 3s + start_period: 10s + retries: 3 diff --git a/server/main.py b/server/main.py index 28ec09f..f2b10ef 100644 --- a/server/main.py +++ b/server/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI, Body, Request, HTTPException, UploadFile, File, Form, Header, Depends from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from pydantic import BaseModel import os import sqlite3 @@ -12,6 +13,7 @@ import hashlib import secrets import base64 import json +import uuid from datetime import datetime, timezone, timedelta from fastapi.middleware.cors import CORSMiddleware import socket @@ -26,41 +28,61 @@ from cryptography.hazmat.primitives import hashes # 加载环境变量 load_dotenv() -SERVER_HOST = os.getenv("SERVER_HOST") -SERVER_PORT = int(os.getenv("SERVER_PORT")) +BASE_DIR = Path(__file__).resolve().parent + + +def env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"} + + +def configured_path(name: str, default: str) -> Path: + path = Path(os.getenv(name, default)).expanduser() + return path if path.is_absolute() else BASE_DIR / path + + +SERVER_HOST = os.getenv("SERVER_HOST", "0.0.0.0") +SERVER_PORT = int(os.getenv("SERVER_PORT", "8000")) VALID_CLIENT_TOKEN = os.getenv("CLIENT_API_TOKEN") +TARGET_PLATFORM = os.getenv("TARGET_PLATFORM", "windows") +TARGET_ARCH = os.getenv("TARGET_ARCH", "x64") +RELEASE_MAIN_EXECUTABLE = os.getenv("RELEASE_MAIN_EXECUTABLE", "MainApp.exe").strip() +SIGNING_KEY_ID = os.getenv("SIGNING_KEY_ID", "manifest-key-v1") # 大型 multipart 上传使用内存文件系统暂存,避免与 MinIO 对象双重占用根分区。 -UPLOAD_SPOOL_DIR = Path(os.getenv("UPLOAD_SPOOL_DIR", "/dev/shm/marsco-upload")) +UPLOAD_SPOOL_DIR = configured_path("UPLOAD_SPOOL_DIR", "upload_spool") UPLOAD_SPOOL_DIR.mkdir(parents=True, exist_ok=True) tempfile.tempdir = str(UPLOAD_SPOOL_DIR) -MINIO_DATA_DIR = Path(os.getenv("MINIO_DATA_DIR", "minio_data")) -UPLOAD_SPACE_RESERVE = 256 * 1024 * 1024 +MINIO_DATA_DIR = configured_path("MINIO_DATA_DIR", "minio_data") +UPLOAD_SPACE_RESERVE = int(os.getenv("UPLOAD_SPACE_RESERVE_MB", "256")) * 1024 * 1024 -# 后台管理令牌:仅持久化 SHA-256 哈希,不保存明文 -ADMIN_TOKEN_HASH_PATH = Path(os.getenv("ADMIN_TOKEN_HASH_PATH", "admin_token.sha256")) -DEFAULT_ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "AdminSecret2026") +# SimCAE 崩溃报告私有存储与上传限制 +CRASH_STORAGE_ROOT = configured_path("CRASH_STORAGE_ROOT", "crash_storage") +CRASH_METADATA_MAX_BYTES = int(os.getenv("CRASH_METADATA_MAX_KB", "256")) * 1024 +CRASH_MINIDUMP_MAX_BYTES = int(os.getenv("CRASH_MINIDUMP_MAX_MB", "128")) * 1024 * 1024 +CRASH_ATTACHMENTS_MAX_BYTES = int(os.getenv("CRASH_ATTACHMENTS_MAX_MB", "64")) * 1024 * 1024 +CRASH_REQUEST_MAX_BYTES = int(os.getenv("CRASH_REQUEST_MAX_MB", "200")) * 1024 * 1024 +CRASH_SYMBOLS_MAX_BYTES = int(os.getenv("CRASH_SYMBOLS_MAX_MB", "512")) * 1024 * 1024 +CRASH_SERVICE_VERSION = os.getenv("CRASH_SERVICE_VERSION", "1.0.0") + +# 后台管理令牌:当前版本直接使用 .env 中的 ADMIN_TOKEN 明文配置。 +ADMIN_TOKEN = os.getenv("ADMIN_TOKEN", "").strip() +if not ADMIN_TOKEN: + raise RuntimeError("请在环境变量或 .env 中配置 ADMIN_TOKEN") def token_digest(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() -def load_admin_token_hash() -> str: - if ADMIN_TOKEN_HASH_PATH.exists(): - stored = ADMIN_TOKEN_HASH_PATH.read_text(encoding="utf-8").strip() - if len(stored) == 64: - return stored - return token_digest(DEFAULT_ADMIN_TOKEN) - -ADMIN_TOKEN_HASH = load_admin_token_hash() - -print("读取到的CLIENT_API_TOKEN:", repr(VALID_CLIENT_TOKEN)) +CRASH_REPORT_TOKEN = os.getenv("CRASH_REPORT_TOKEN") or os.getenv("SIMCAE_CRASH_CLIENT_TOKEN") or VALID_CLIENT_TOKEN +CRASH_SYMBOL_TOKEN = os.getenv("CRASH_SYMBOL_TOKEN") or os.getenv("SIMCAE_SYMBOL_TOKEN") or ADMIN_TOKEN +CRASH_ADMIN_TOKEN = os.getenv("CRASH_ADMIN_TOKEN") # ========== 生命周期初始化 ========== @asynccontextmanager async def lifespan(app: FastAPI): print("===== 进程启动,开始初始化数据库 =====") - db_file = "mini.db" - sql_file_path = Path("./tables.sql") + db_file = db.DB_FILE + sql_file_path = db.SQL_FILE conn = sqlite3.connect(db_file, check_same_thread=False) conn.row_factory = sqlite3.Row cur = conn.cursor() @@ -69,6 +91,16 @@ async def lifespan(app: FastAPI): with open(sql_file_path, "r", encoding="utf-8") as f: full_sql = f.read() cur.executescript(full_sql) + version_columns = {row[1] for row in cur.execute("PRAGMA table_info(versions)").fetchall()} + if "client_protocol" not in version_columns: + cur.execute("ALTER TABLE versions ADD COLUMN client_protocol INTEGER NOT NULL DEFAULT 1") + device_columns = {row[1] for row in cur.execute("PRAGMA table_info(devices)").fetchall()} + if "license_id" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN license_id TEXT NOT NULL DEFAULT ''") + if "channel" not in device_columns: cur.execute("ALTER TABLE devices ADD COLUMN channel TEXT NOT NULL DEFAULT 'stable'") + for app_row in cur.execute("SELECT app_id FROM apps").fetchall(): + if not cur.execute("SELECT 1 FROM channels WHERE app_id=? LIMIT 1", (app_row[0],)).fetchone(): + cur.executemany("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)", + [(app_row[0], "stable", "正式版", 1, 10), (app_row[0], "preview", "预览版", 1, 20), (app_row[0], "dev", "开发版", 1, 30)]) conn.commit() print(f"✅ 成功执行tables.sql,创建数据表与测试数据") else: @@ -76,6 +108,10 @@ async def lifespan(app: FastAPI): conn.close() print("===== 数据库初始化完成 =====") + CRASH_STORAGE_ROOT.mkdir(parents=True, exist_ok=True) + (CRASH_STORAGE_ROOT / "reports").mkdir(parents=True, exist_ok=True) + (CRASH_STORAGE_ROOT / "symbols").mkdir(parents=True, exist_ok=True) + start_minio_if_needed() yield @@ -86,16 +122,17 @@ def is_minio_running(endpoint: str) -> bool: if endpoint.startswith("http://") or endpoint.startswith("https://"): base_url = endpoint.rstrip("/") else: - base_url = f"http://{endpoint.rstrip('/')}" + scheme = "https" if env_bool("MINIO_SECURE") else "http" + base_url = f"{scheme}://{endpoint.rstrip('/')}" health_url = base_url + "/minio/health/live" - with urllib.request.urlopen(health_url, timeout=2) as resp: + with urllib.request.urlopen(health_url, timeout=float(os.getenv("MINIO_HEALTH_TIMEOUT_SEC", "2"))) as resp: return resp.status == 200 except Exception: return False def download_minio_binary(target_path: Path) -> str | None: - url = "https://dl.min.io/server/minio/release/linux-amd64/minio" + url = os.getenv("MINIO_DOWNLOAD_URL", "https://dl.min.io/server/minio/release/linux-amd64/minio") try: print(f"MinIO 二进制未找到,尝试下载到 {target_path}") target_path.parent.mkdir(parents=True, exist_ok=True) @@ -111,6 +148,8 @@ def download_minio_binary(target_path: Path) -> str | None: def start_minio_if_needed(): + if not env_bool("MINIO_AUTO_START", True): + return endpoint = os.getenv('MINIO_ENDPOINT') if not endpoint: return @@ -122,17 +161,20 @@ def start_minio_if_needed(): minio_cmd = shutil.which('minio') if not minio_cmd: minio_bin = Path(os.getenv('MINIO_BIN_PATH', 'minio')) - minio_cmd = download_minio_binary(minio_bin) + minio_cmd = str(minio_bin.resolve()) if minio_bin.exists() else None + if not minio_cmd and env_bool("MINIO_AUTO_DOWNLOAD", False): + minio_cmd = download_minio_binary(minio_bin) if not minio_cmd: - print("MinIO 二进制未找到且自动下载失败,无法启动 MinIO") + print("MinIO 二进制未找到,且未启用或未完成自动下载") return - data_dir = Path(os.getenv('MINIO_DATA_DIR', 'minio_data')) + data_dir = MINIO_DATA_DIR data_dir.mkdir(parents=True, exist_ok=True) - print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address ':9000'") + minio_address = os.getenv("MINIO_LISTEN_ADDRESS", ":9000") + print(f"尝试自动启动 MinIO:{minio_cmd} server {data_dir} --address {minio_address}") try: - subprocess.Popen([minio_cmd, 'server', str(data_dir), '--address', ':9000'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - time.sleep(5) + subprocess.Popen([minio_cmd, 'server', str(data_dir), '--address', minio_address], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + time.sleep(float(os.getenv("MINIO_START_WAIT_SEC", "5"))) if is_minio_running(endpoint): print("MinIO 已启动成功") else: @@ -142,37 +184,82 @@ def start_minio_if_needed(): # Manifest signing key path -MANIFEST_PRIVATE_KEY_PATH = os.getenv("MANIFEST_PRIVATE_KEY_PATH", str(Path(__file__).resolve().parent / "keys" / "manifest_private_key.pem")) +MANIFEST_PRIVATE_KEY_PATH = str(configured_path("MANIFEST_PRIVATE_KEY_PATH", "keys/manifest_private_key.pem")) # 创建APP绑定生命周期 -app = FastAPI(title="最小自动更新服务", lifespan=lifespan) +app = FastAPI(title=os.getenv("SERVICE_TITLE", "软件自动升级服务"), lifespan=lifespan) + +ADMIN_HTML_PATH = configured_path("ADMIN_HTML_PATH", "../client/admin.html") + + +@app.get("/", include_in_schema=False) +@app.get("/admin.html", include_in_schema=False) +def admin_page(): + if not ADMIN_HTML_PATH.is_file(): + raise HTTPException(status_code=404, detail="管理页面文件不存在") + return FileResponse(ADMIN_HTML_PATH, headers={"Cache-Control": "no-store"}) + + +@app.get("/health", include_in_schema=False) +def health_check(): + return {"status": "ok"} # 本地上传备份文件目录(MinIO 不通时会保存到这里) -app.mount("/static", StaticFiles(directory="local_uploads"), name="static") +app.mount("/static", StaticFiles(directory=str(minio_tool.LOCAL_UPLOAD_ROOT)), name="static") # 跨域配置(给admin前端页面用) +cors_origins = [item.strip() for item in os.getenv("CORS_ALLOW_ORIGINS", "*").split(",") if item.strip()] app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=cors_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) +# 管理后台写操作统一审计;不保存令牌明文和请求正文。 +@app.middleware("http") +async def admin_audit_middleware(request: Request, call_next): + should_audit = request.url.path.startswith("/admin/") and request.method not in ("GET", "HEAD", "OPTIONS") + status_code = 500 + try: + response = await call_next(request); status_code = response.status_code; return response + finally: + if should_audit: + try: + token = request.headers.get("X-Admin-Token", "") + conn = db.get_conn(); conn.execute("""INSERT INTO admin_audit_logs + (actor_hash,action,method,path,target,result,status_code,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?)""", + (token_digest(token)[:16] if token else "anonymous", request.url.path.removeprefix("/admin/"), + request.method, request.url.path, request.url.query[:500], + "success" if 200 <= status_code < 400 else "fail", status_code, + request.client.host if request.client else "", request.headers.get("user-agent", "")[:300])); conn.commit(); conn.close() + except Exception as audit_error: + print("管理员审计日志写入失败:", audit_error) + # ===================== 全局客户端鉴权中间件 ===================== @app.middleware("http") async def auth_middleware(request: Request, call_next): path = request.url.path # 后台接口、文档接口、静态回退链接全部跳过客户端token校验 - skip_paths = ("/admin", "/docs", "/openapi.json", "/redoc", "/static") - if path.startswith(skip_paths): + skip_paths = ("/admin", "/docs", "/openapi.json", "/redoc", "/static", "/health", "/api/v1/health", "/api/v1/crash-reports", "/api/v1/symbols") + if path == "/" or path.startswith(skip_paths): response = await call_next(request) return response client_token = request.headers.get("X-Client-Token") - print("本次请求携带token:", repr(client_token)) if client_token != VALID_CLIENT_TOKEN: - raise HTTPException(status_code=401, detail="非法客户端,令牌校验失败") + return JSONResponse(status_code=401, content={"detail": "非法客户端,令牌校验失败"}) + if path != "/api/v1/device/issue": + credential = request.headers.get("X-Device-Credential", "") + if not credential: + return JSONResponse(status_code=401, content={"detail": { + "error": "device_credential_required", "msg": "缺少设备身份凭证" + }}) + try: + request.state.device_identity = verify_device_credential(credential) + except HTTPException as exc: + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) response = await call_next(request) return response @@ -181,6 +268,7 @@ class CheckUpdateReq(BaseModel): app_id: str current_version: str channel: str + client_protocol: int = 1 class DownloadUrlReq(BaseModel): app_id: str @@ -196,6 +284,7 @@ class ManifestReq(BaseModel): version_id: int class ReportReq(BaseModel): + app_id: str device_id: str from_version: str to_version: str @@ -204,12 +293,30 @@ class ReportReq(BaseModel): class ChangeAdminTokenReq(BaseModel): new_token: str +class DownloadReportReq(BaseModel): + app_id: str + channel: str + version: str + result: str + files: list[dict] + +class DeviceIssueReq(BaseModel): + app_id: str + channel: str + license_key: str + installation_id: str + machine_hash: str = "" + # 统一后台鉴权:全部接口从Header读取token,不再区分表单/头 def admin_auth(X_Admin_Token: str = Header("")): - if not secrets.compare_digest(token_digest(X_Admin_Token), ADMIN_TOKEN_HASH): + if not secrets.compare_digest(X_Admin_Token, ADMIN_TOKEN): raise HTTPException(status_code=403, detail="后台密钥错误,禁止访问") return True +@app.get("/admin/auth/check") +def admin_auth_check(auth=Depends(admin_auth)): + return {"code": 0, "msg": "管理员令牌有效"} + # Manifest signing helpers def load_manifest_private_key(): @@ -247,6 +354,41 @@ def sign_policy(policy_obj: dict) -> tuple[str, str]: return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii") +def sign_device_identity(identity: dict) -> tuple[str, str]: + payload = canonical_signed_bytes(identity) + signature = load_manifest_private_key().sign(payload, padding.PKCS1v15(), hashes.SHA256()) + return payload.decode("utf-8"), base64.b64encode(signature).decode("ascii") + +def verify_device_credential(encoded: str) -> dict: + try: + wrapper = json.loads(base64.b64decode(encoded, validate=True).decode("utf-8")) + identity_text = wrapper["identity_text"] + signature = base64.b64decode(wrapper["signature"], validate=True) + load_manifest_private_key().public_key().verify(signature, identity_text.encode(), padding.PKCS1v15(), hashes.SHA256()) + identity = json.loads(identity_text) + if any(not identity.get(k) for k in ("device_id", "license_id", "app_id", "channel", "installation_id", "credential_seq", "valid_until")): + raise ValueError("missing field") + except Exception: + raise HTTPException(status_code=401, detail={"error":"invalid_device_credential","msg":"设备凭证格式或签名无效"}) + conn = db.get_conn(); row = conn.execute("SELECT * FROM devices WHERE device_id=?", (identity["device_id"],)).fetchone() + if not row: + conn.close(); raise HTTPException(status_code=401, detail={"error":"device_not_registered","msg":"设备未登记"}) + if row["disabled"]: + reason=row["disabled_reason"] or "设备已被管理员禁用"; conn.close() + raise HTTPException(status_code=403, detail={"error":"device_disabled","msg":reason}) + if row["app_id"] != identity["app_id"] or row["installation_id"] != identity["installation_id"] or int(row["credential_seq"]) != int(identity["credential_seq"]): + conn.close(); raise HTTPException(status_code=401, detail={"error":"device_credential_revoked","msg":"设备凭证已失效"}) + license_row = conn.execute("SELECT * FROM licenses WHERE license_id=?", (identity.get("license_id", ""),)).fetchone() + now = datetime.now(timezone.utc) + try: license_expiry = datetime.fromisoformat((license_row["valid_until"] if license_row else "").replace("Z", "+00:00")) + except ValueError: license_expiry = now - timedelta(seconds=1) + if not license_row or license_row["status"] != "active" or license_expiry <= now: + conn.close(); raise HTTPException(status_code=403, detail={"error":"license_invalid","msg":"授权不存在、已禁用或已过期"}) + if license_row["app_id"] != identity["app_id"] or license_row["channel_code"] != identity.get("channel"): + conn.close(); raise HTTPException(status_code=403, detail={"error":"license_scope_mismatch","msg":"授权应用或渠道不匹配"}) + conn.execute("UPDATE devices SET last_seen_at=datetime('now') WHERE device_id=?", (identity["device_id"],)); conn.commit(); conn.close() + return identity + def policy_row_to_dict(row) -> dict: if not row: return { @@ -266,6 +408,15 @@ def policy_row_to_dict(row) -> dict: } +def validate_channel_code(code: str) -> bool: + return bool(code) and len(code) <= 32 and code[0].isalnum() and all(c.isalnum() or c in "_-" for c in code) + +def require_channel(conn, app_id: str, channel: str, require_enabled: bool = True): + row = conn.execute("SELECT * FROM channels WHERE app_id=? AND channel_code=?", (app_id, channel)).fetchone() + if not row: raise HTTPException(status_code=400, detail={"error":"channel_not_found","msg":f"渠道 {channel} 不存在"}) + if require_enabled and not row["enabled"]: raise HTTPException(status_code=403, detail={"error":"channel_disabled","msg":f"渠道 {channel} 已停用"}) + return row + def is_executable_path(path: str) -> bool: return path.lower().endswith((".exe", ".dll")) @@ -292,19 +443,623 @@ def normalize_relative_path(raw_path: str) -> str: raise HTTPException(status_code=400, detail=f"文件路径不能包含盘符: {raw_path}") return path.as_posix() + +def validate_release_path(relative_path: str): + folded = relative_path.casefold() + parts = PurePosixPath(folded).parts + blocked_directories = {".git", ".vs", "cmakefiles", "debug", "update", "update_temp"} + blocked_suffixes = ("_autogen",) + for part in parts[:-1]: + if (part in blocked_directories or part.startswith("build") + or part.endswith(blocked_suffixes)): + raise HTTPException( + status_code=400, + detail=f"发布目录包含构建或运行时目录,必须选择干净的 Release 输出目录: {relative_path}" + ) + # 文件sha256工具 def calc_sha256(data: bytes): sha = hashlib.sha256() sha.update(data) return sha.hexdigest() + +class CrashApiException(Exception): + def __init__(self, status_code: int, code: str, message: str, retryable: bool = False): + self.status_code = status_code + self.code = code + self.message = message + self.retryable = retryable + + +@app.exception_handler(CrashApiException) +async def crash_api_exception_handler(request: Request, exc: CrashApiException): + return JSONResponse( + status_code=exc.status_code, + content={"error": {"code": exc.code, "message": exc.message, "retryable": exc.retryable}}, + ) + + +def utc_now_text() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def crash_api_error(status_code: int, code: str, message: str, retryable: bool = False): + raise CrashApiException(status_code, code, message, retryable) + + +def bearer_token_from_request(request: Request) -> str: + value = request.headers.get("authorization", "") + if not value.lower().startswith("bearer "): + crash_api_error(401, "unauthorized", "missing bearer token", False) + token = value[7:].strip() + if not token: + crash_api_error(401, "unauthorized", "missing bearer token", False) + return token + + +def require_crash_bearer(request: Request, allowed_roles: set[str]) -> str: + token = bearer_token_from_request(request) + roles: set[str] = set() + if CRASH_REPORT_TOKEN and secrets.compare_digest(token, CRASH_REPORT_TOKEN): + roles.add("crash_report") + if CRASH_SYMBOL_TOKEN and secrets.compare_digest(token, CRASH_SYMBOL_TOKEN): + roles.add("symbols") + if CRASH_ADMIN_TOKEN: + if secrets.compare_digest(token, CRASH_ADMIN_TOKEN): + roles.add("admin") + elif secrets.compare_digest(token, ADMIN_TOKEN): + roles.add("admin") + if not roles: + crash_api_error(401, "unauthorized", "invalid bearer token", False) + if roles.isdisjoint(allowed_roles): + crash_api_error(403, "forbidden", "token is not allowed to access this endpoint", False) + return token + + +def ensure_multipart_request(request: Request, max_bytes: int = CRASH_REQUEST_MAX_BYTES): + content_type = request.headers.get("content-type", "") + if "multipart/form-data" not in content_type.lower(): + crash_api_error(415, "unsupported_media_type", "Content-Type must be multipart/form-data", False) + raw_length = request.headers.get("content-length") + if raw_length: + try: + content_length = int(raw_length) + except ValueError: + crash_api_error(400, "metadata_invalid", "Content-Length is invalid", False) + if content_length > max_bytes: + crash_api_error(413, "payload_too_large", "request body exceeds limit", False) + + +def is_upload_file(value) -> bool: + return hasattr(value, "filename") and hasattr(value, "read") + + +async def read_metadata_field(value, max_bytes: int) -> bytes: + if value is None: + crash_api_error(400, "metadata_invalid", "metadata is required", False) + if is_upload_file(value): + data = bytearray() + while True: + chunk = await value.read(64 * 1024) + if not chunk: + break + data.extend(chunk) + if len(data) > max_bytes: + crash_api_error(413, "payload_too_large", "metadata exceeds size limit", False) + return bytes(data) + data = str(value).encode("utf-8") + if len(data) > max_bytes: + crash_api_error(413, "payload_too_large", "metadata exceeds size limit", False) + return data + + +async def save_upload_limited(upload, target: Path, max_bytes: int, field_name: str) -> dict: + if not is_upload_file(upload): + crash_api_error(400, "metadata_invalid", f"{field_name} file is required", False) + target.parent.mkdir(parents=True, exist_ok=True) + total = 0 + digest = hashlib.sha256() + try: + with open(target, "wb") as out_file: + while True: + chunk = await upload.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > max_bytes: + crash_api_error(413, "payload_too_large", f"{field_name} exceeds size limit", False) + digest.update(chunk) + out_file.write(chunk) + except Exception: + if target.exists(): + target.unlink() + raise + if total <= 0: + crash_api_error(400, "metadata_invalid", f"{field_name} file is empty", False) + return {"sha256": digest.hexdigest(), "size": total, "filename": upload.filename or field_name} + + +def load_json_metadata(raw: bytes, error_code: str = "metadata_invalid") -> dict: + try: + obj = json.loads(raw.decode("utf-8")) + except Exception: + crash_api_error(400, error_code, "metadata must be valid UTF-8 JSON", False) + if not isinstance(obj, dict): + crash_api_error(400, error_code, "metadata must be a JSON object", False) + return obj + + +def require_metadata_fields(metadata: dict, fields: list[str], error_code: str = "metadata_invalid"): + for field in fields: + if metadata.get(field) in (None, ""): + crash_api_error(400, error_code, f"metadata.{field} is required", False) + if metadata.get("schemaVersion") != 1: + crash_api_error(400, error_code, "metadata.schemaVersion must be 1", False) + + +def normalize_declared_sha256(value, field_path: str) -> str: + if value in (None, ""): + return "" + text = str(value).strip().lower() + if len(text) != 64 or any(ch not in "0123456789abcdef" for ch in text): + crash_api_error(400, "metadata_invalid", f"{field_path} must be a SHA-256 hex string", False) + return text + + +def declared_file_sha256(metadata: dict, kind: str, filename: str) -> str: + files = metadata.get("files") + if not isinstance(files, list): + crash_api_error(400, "metadata_invalid", "metadata.files must be an array", False) + for index, item in enumerate(files): + if not isinstance(item, dict): + crash_api_error(400, "metadata_invalid", f"metadata.files[{index}] must be an object", False) + if item.get("kind") == kind or item.get("name") == filename: + return normalize_declared_sha256(item.get("sha256"), f"metadata.files[{index}].sha256") + return "" + + +def validate_client_report_id(value: str): + try: + uuid.UUID(value) + except Exception: + crash_api_error(400, "metadata_invalid", "metadata.clientReportId must be a UUID", False) + + +def safe_storage_component(value, fallback: str = "unknown") -> str: + text = str(value or fallback).strip() + cleaned = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in text) + return (cleaned[:96] or fallback) + + +def crash_content_hash(metadata_sha256: str, minidump_sha256: str, attachments_sha256: str) -> str: + payload = json.dumps( + {"metadata": metadata_sha256, "minidump": minidump_sha256, "attachments": attachments_sha256 or ""}, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def generate_prefixed_id(conn, table: str, column: str, prefix: str) -> str: + day = datetime.now(timezone.utc).strftime("%Y%m%d") + for _ in range(20): + value = f"{prefix}_{day}_{secrets.token_hex(4)}" + if not conn.execute(f"SELECT 1 FROM {table} WHERE {column}=?", (value,)).fetchone(): + return value + crash_api_error(503, "service_unavailable", "could not allocate report id", True) + + +def cleanup_path(path: Path | None): + if not path: + return + try: + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + except Exception as err: + print(f"清理临时文件失败: {path}: {err}") + + +def crash_report_response(row, duplicate: bool, status_code: int): + return JSONResponse( + status_code=status_code, + content={ + "reportId": row["report_id"], + "duplicate": duplicate, + "status": row["status"], + "acceptedAtUtc": row["received_at_utc"], + }, + ) + + +def symbol_upload_response(row, duplicate: bool, status_code: int): + return JSONResponse( + status_code=status_code, + content={ + "symbolUploadId": row["symbol_upload_id"], + "status": row["status"], + "duplicate": duplicate, + "acceptedAtUtc": row["received_at_utc"], + }, + ) + + +@app.get("/api/v1/health") +def crash_health(): + return { + "status": "ok", + "service": "simcae-crash-server", + "version": CRASH_SERVICE_VERSION, + "timeUtc": utc_now_text(), + } + + +@app.post("/api/v1/crash-reports") +async def create_crash_report(request: Request): + tmp_dir: Path | None = None + final_dir: Path | None = None + stored = False + conn = None + try: + require_crash_bearer(request, {"crash_report"}) + ensure_multipart_request(request) + try: + form = await request.form() + except Exception: + crash_api_error(400, "metadata_invalid", "multipart form is invalid", False) + + metadata_raw = await read_metadata_field(form.get("metadata"), CRASH_METADATA_MAX_BYTES) + metadata_sha = hashlib.sha256(metadata_raw).hexdigest() + metadata = load_json_metadata(metadata_raw) + require_metadata_fields( + metadata, + ["schemaVersion", "clientReportId", "crashTimeUtc", "product", "appVersion", "gitCommit", "buildType", "channel", "platform", "crash", "userConsent", "files"], + ) + if metadata.get("product") != "SIMCAE": + crash_api_error(400, "metadata_invalid", "metadata.product must be SIMCAE", False) + if not isinstance(metadata.get("platform"), dict): + crash_api_error(400, "metadata_invalid", "metadata.platform must be an object", False) + if not isinstance(metadata.get("crash"), dict): + crash_api_error(400, "metadata_invalid", "metadata.crash must be an object", False) + if not isinstance(metadata.get("userConsent"), dict): + crash_api_error(400, "metadata_invalid", "metadata.userConsent must be an object", False) + + client_report_id = str(metadata.get("clientReportId") or "").strip() + validate_client_report_id(client_report_id) + idempotency_key = request.headers.get("idempotency-key", "").strip() + if not idempotency_key: + crash_api_error(400, "metadata_invalid", "Idempotency-Key header is required", False) + if idempotency_key != client_report_id: + crash_api_error(400, "metadata_invalid", "Idempotency-Key must equal metadata.clientReportId", False) + + tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f"crash_{secrets.token_hex(12)}" + tmp_dir.mkdir(parents=True, exist_ok=False) + (tmp_dir / "metadata.json").write_bytes(metadata_raw) + minidump_info = await save_upload_limited(form.get("minidump"), tmp_dir / "crash.dmp", CRASH_MINIDUMP_MAX_BYTES, "minidump") + declared_dump_sha = declared_file_sha256(metadata, "minidump", "crash.dmp") + if declared_dump_sha and declared_dump_sha != minidump_info["sha256"]: + crash_api_error(400, "file_hash_mismatch", "minidump SHA-256 does not match metadata.files", False) + + attachments_info = None + if form.get("attachments") is not None: + attachments_info = await save_upload_limited(form.get("attachments"), tmp_dir / "attachments.zip", CRASH_ATTACHMENTS_MAX_BYTES, "attachments") + + package_hash = crash_content_hash(metadata_sha, minidump_info["sha256"], attachments_info["sha256"] if attachments_info else "") + received_at = utc_now_text() + content_length = int(request.headers.get("content-length") or 0) + remote_addr = request.client.host if request.client else "" + server_info = { + "storageVersion": 1, + "clientReportId": client_report_id, + "receivedAtUtc": received_at, + "remoteAddress": remote_addr, + "contentLength": content_length, + } + + conn = db.get_conn() + existing = conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone() + if existing: + if existing["content_hash"] != package_hash: + crash_api_error(409, "idempotency_conflict", "same clientReportId was uploaded with different content", False) + return crash_report_response(existing, True, 200) + + report_id = generate_prefixed_id(conn, "crash_reports", "report_id", "srv") + server_info["reportId"] = report_id + (tmp_dir / "server.json").write_text(json.dumps(server_info, ensure_ascii=False, indent=2), encoding="utf-8") + final_dir = CRASH_STORAGE_ROOT / "reports" / report_id + final_dir.parent.mkdir(parents=True, exist_ok=True) + tmp_dir.rename(final_dir) + tmp_dir = None + + crash_obj = metadata.get("crash") or {} + conn.execute( + """ + INSERT INTO crash_reports( + report_id,client_report_id,content_hash,status,symbolication_status,product,app_version, + git_commit,build_type,channel,exception_code,crash_time_utc,received_at_utc,remote_address, + content_length,metadata_sha256,metadata_size,minidump_sha256,minidump_size,attachments_sha256, + attachments_size,storage_path,metadata_json,server_json + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + report_id, + client_report_id, + package_hash, + "stored", + "not_started", + metadata.get("product"), + metadata.get("appVersion"), + metadata.get("gitCommit"), + metadata.get("buildType"), + metadata.get("channel"), + str(crash_obj.get("exceptionCode") or ""), + metadata.get("crashTimeUtc"), + received_at, + remote_addr, + content_length, + metadata_sha, + len(metadata_raw), + minidump_info["sha256"], + minidump_info["size"], + attachments_info["sha256"] if attachments_info else "", + attachments_info["size"] if attachments_info else 0, + str(final_dir), + metadata_raw.decode("utf-8", errors="replace"), + json.dumps(server_info, ensure_ascii=False, separators=(",", ":")), + ), + ) + conn.commit() + stored = True + row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone() + return crash_report_response(row, False, 201) + except sqlite3.IntegrityError: + if conn: + conn.rollback() + client_report_id = "" + try: + metadata = load_json_metadata(metadata_raw) + client_report_id = str(metadata.get("clientReportId") or "").strip() + except Exception: + pass + if client_report_id: + existing = conn.execute("SELECT * FROM crash_reports WHERE client_report_id=?", (client_report_id,)).fetchone() + if existing and existing["content_hash"] == package_hash: + return crash_report_response(existing, True, 200) + crash_api_error(409, "idempotency_conflict", "same clientReportId was uploaded with different content", False) + except CrashApiException: + if conn: + conn.rollback() + raise + except Exception as err: + if conn: + conn.rollback() + print("崩溃报告上传失败:", err) + crash_api_error(500, "server_error", "server failed to store crash report", True) + finally: + if conn: + conn.close() + cleanup_path(tmp_dir) + if final_dir and not stored: + cleanup_path(final_dir) + + +@app.get("/api/v1/crash-reports/{report_id}") +def get_crash_report(report_id: str, request: Request): + require_crash_bearer(request, {"admin"}) + conn = db.get_conn() + row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone() + conn.close() + if not row: + crash_api_error(404, "metadata_invalid", "crash report not found", False) + return { + "reportId": row["report_id"], + "clientReportId": row["client_report_id"], + "status": row["status"], + "receivedAtUtc": row["received_at_utc"], + "symbolicationStatus": row["symbolication_status"], + "appVersion": row["app_version"], + "gitCommit": row["git_commit"], + "product": row["product"], + "buildType": row["build_type"], + "channel": row["channel"], + "exceptionCode": row["exception_code"], + "files": { + "metadata": {"name": "metadata.json", "sha256": row["metadata_sha256"], "size": row["metadata_size"]}, + "minidump": {"name": "crash.dmp", "sha256": row["minidump_sha256"], "size": row["minidump_size"]}, + "attachments": {"name": "attachments.zip", "sha256": row["attachments_sha256"], "size": row["attachments_size"]} if row["attachments_size"] else None, + "server": {"name": "server.json"}, + }, + } + + +@app.get("/api/v1/crash-reports/{report_id}/files/{file_name}") +def download_crash_report_file(report_id: str, file_name: str, request: Request): + token = require_crash_bearer(request, {"admin"}) + allowed = { + "metadata": "metadata.json", + "metadata.json": "metadata.json", + "minidump": "crash.dmp", + "crash.dmp": "crash.dmp", + "attachments": "attachments.zip", + "attachments.zip": "attachments.zip", + "server": "server.json", + "server.json": "server.json", + } + stored_name = allowed.get(file_name) + if not stored_name: + crash_api_error(404, "metadata_invalid", "file not found", False) + conn = db.get_conn() + row = conn.execute("SELECT * FROM crash_reports WHERE report_id=?", (report_id,)).fetchone() + if not row: + conn.close() + crash_api_error(404, "metadata_invalid", "crash report not found", False) + target = Path(row["storage_path"]) / stored_name + try: + conn.execute( + "INSERT INTO crash_file_access_logs(report_id,file_name,actor_hash,ip,user_agent) VALUES(?,?,?,?,?)", + (report_id, stored_name, token_digest(token)[:16], request.client.host if request.client else "", request.headers.get("user-agent", "")[:300]), + ) + conn.commit() + finally: + conn.close() + if not target.is_file(): + crash_api_error(404, "metadata_invalid", "file not found", False) + return FileResponse(target, media_type="application/octet-stream", filename=stored_name) + + +@app.post("/api/v1/symbols") +async def upload_symbols(request: Request): + tmp_dir: Path | None = None + final_dir: Path | None = None + stored = False + conn = None + try: + require_crash_bearer(request, {"symbols"}) + ensure_multipart_request(request, CRASH_SYMBOLS_MAX_BYTES + CRASH_METADATA_MAX_BYTES) + try: + form = await request.form() + except Exception: + crash_api_error(400, "metadata_invalid", "multipart form is invalid", False) + metadata_raw = await read_metadata_field(form.get("metadata"), CRASH_METADATA_MAX_BYTES) + metadata_sha = hashlib.sha256(metadata_raw).hexdigest() + metadata = load_json_metadata(metadata_raw) + require_metadata_fields( + metadata, + ["schemaVersion", "product", "appVersion", "gitCommit", "buildType", "platform", "toolchain", "createdAtUtc", "files"], + ) + if metadata.get("product") != "SIMCAE": + crash_api_error(400, "metadata_invalid", "metadata.product must be SIMCAE", False) + if not isinstance(metadata.get("files"), list): + crash_api_error(400, "metadata_invalid", "metadata.files must be an array", False) + + tmp_dir = CRASH_STORAGE_ROOT / "_incoming" / f"symbols_{secrets.token_hex(12)}" + tmp_dir.mkdir(parents=True, exist_ok=False) + (tmp_dir / "metadata.json").write_bytes(metadata_raw) + symbols_info = await save_upload_limited(form.get("symbols"), tmp_dir / "symbols.zip", CRASH_SYMBOLS_MAX_BYTES, "symbols") + identity_payload = json.dumps( + { + "product": metadata.get("product"), + "appVersion": metadata.get("appVersion"), + "gitCommit": metadata.get("gitCommit"), + "buildType": metadata.get("buildType"), + "platform": metadata.get("platform"), + }, + sort_keys=True, + separators=(",", ":"), + ) + identity_hash = hashlib.sha256(identity_payload.encode("utf-8")).hexdigest() + received_at = utc_now_text() + conn = db.get_conn() + existing = conn.execute("SELECT * FROM crash_symbol_uploads WHERE identity_hash=?", (identity_hash,)).fetchone() + if existing: + if existing["symbols_sha256"] != symbols_info["sha256"]: + crash_api_error(409, "idempotency_conflict", "same build identity already has different symbols", False) + return symbol_upload_response(existing, True, 200) + + upload_id = generate_prefixed_id(conn, "crash_symbol_uploads", "symbol_upload_id", "sym") + final_dir = CRASH_STORAGE_ROOT / "symbols" / safe_storage_component(metadata.get("product")) / safe_storage_component(metadata.get("appVersion")) / safe_storage_component(metadata.get("gitCommit")) / safe_storage_component(metadata.get("buildType")) / safe_storage_component(metadata.get("platform")) + final_dir.parent.mkdir(parents=True, exist_ok=True) + if final_dir.exists(): + shutil.rmtree(final_dir) + server_info = { + "storageVersion": 1, + "symbolUploadId": upload_id, + "receivedAtUtc": received_at, + "remoteAddress": request.client.host if request.client else "", + "contentLength": int(request.headers.get("content-length") or 0), + } + (tmp_dir / "server.json").write_text(json.dumps(server_info, ensure_ascii=False, indent=2), encoding="utf-8") + tmp_dir.rename(final_dir) + tmp_dir = None + conn.execute( + """ + INSERT INTO crash_symbol_uploads( + symbol_upload_id,identity_hash,status,product,app_version,git_commit,build_type,platform,toolchain, + created_at_utc,received_at_utc,metadata_sha256,metadata_size,symbols_sha256,symbols_size,storage_path,metadata_json + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + """, + ( + upload_id, + identity_hash, + "stored", + metadata.get("product"), + metadata.get("appVersion"), + metadata.get("gitCommit"), + metadata.get("buildType"), + metadata.get("platform"), + metadata.get("toolchain"), + metadata.get("createdAtUtc"), + received_at, + metadata_sha, + len(metadata_raw), + symbols_info["sha256"], + symbols_info["size"], + str(final_dir), + metadata_raw.decode("utf-8", errors="replace"), + ), + ) + conn.commit() + stored = True + row = conn.execute("SELECT * FROM crash_symbol_uploads WHERE symbol_upload_id=?", (upload_id,)).fetchone() + return symbol_upload_response(row, False, 201) + except sqlite3.IntegrityError: + if conn: + conn.rollback() + crash_api_error(409, "idempotency_conflict", "same build identity already exists", False) + except CrashApiException: + if conn: + conn.rollback() + raise + except Exception as err: + if conn: + conn.rollback() + print("符号包上传失败:", err) + crash_api_error(500, "server_error", "server failed to store symbols", True) + finally: + if conn: + conn.close() + cleanup_path(tmp_dir) + if final_dir and not stored: + cleanup_path(final_dir) + # ==================== 原有客户端接口 完整保留 ==================== +# 0. 首次设备登记;仅此客户端接口允许没有设备凭证 +@app.post("/api/v1/device/issue") +async def issue_device(request: Request, body: DeviceIssueReq = Body(...)): + app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip() + if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128): raise HTTPException(status_code=400, detail="设备登记参数无效") + key_hash=token_digest(body.license_key.strip()); now=datetime.now(timezone.utc) + conn=db.get_conn(); conn.execute("BEGIN IMMEDIATE") + require_channel(conn, app_id, channel) + lic=conn.execute("SELECT * FROM licenses WHERE license_key_hash=?",(key_hash,)).fetchone() + try: expiry=datetime.fromisoformat((lic["valid_until"] if lic else "").replace("Z","+00:00")) + except ValueError: expiry=now-timedelta(seconds=1) + if not lic or lic["status"]!="active" or expiry<=now or lic["app_id"]!=app_id or lic["channel_code"]!=channel: + conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"license_invalid","msg":"授权密钥无效、已过期或不适用于当前应用/渠道"}) + row=conn.execute("SELECT * FROM devices WHERE app_id=? AND installation_id=?",(app_id,installation_id)).fetchone() + if row and row["disabled"]: reason=row["disabled_reason"] or "设备已被禁用";conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"device_disabled","msg":reason}) + if row and row["license_id"] not in ("",lic["license_id"]): conn.rollback();conn.close();raise HTTPException(status_code=409,detail="该安装实例已绑定其他授权") + device_id=row["device_id"] if row else "dev_"+secrets.token_hex(16); seq=int(row["credential_seq"]) if row else 1 + bound=conn.execute("SELECT 1 FROM license_devices WHERE license_id=? AND device_id=?",(lic["license_id"],device_id)).fetchone() + used=conn.execute("SELECT COUNT(*) FROM license_devices WHERE license_id=?",(lic["license_id"],)).fetchone()[0] + if not bound and used>=int(lic["max_devices"]): conn.rollback();conn.close();raise HTTPException(status_code=403,detail={"error":"license_device_limit","msg":"授权设备数量已达到上限"}) + ip=request.client.host if request.client else "" + if row: conn.execute("UPDATE devices SET machine_hash=?,license_id=?,channel=?,last_seen_at=datetime('now'),last_ip=? WHERE device_id=?",(body.machine_hash.strip().lower(),lic["license_id"],channel,ip,device_id)) + else: conn.execute("INSERT INTO devices(device_id,app_id,installation_id,machine_hash,credential_seq,last_ip,license_id,channel) VALUES(?,?,?,?,?,?,?,?)",(device_id,app_id,installation_id,body.machine_hash.strip().lower(),seq,ip,lic["license_id"],channel)) + conn.execute("INSERT OR IGNORE INTO license_devices(license_id,device_id) VALUES(?,?)",(lic["license_id"],device_id));conn.commit();conn.close() + identity={"device_id":device_id,"license_id":lic["license_id"],"app_id":app_id,"channel":channel,"installation_id":installation_id,"credential_seq":seq,"issued_at":now.replace(microsecond=0).isoformat().replace("+00:00","Z"),"valid_until":lic["valid_until"],"signature_alg":"RSA-2048-SHA256","key_id":SIGNING_KEY_ID} + identity_text,signature=sign_device_identity(identity);return {"identity":identity,"identity_text":identity_text,"signature":signature} + # 1. 查询更新接口 @app.post("/api/v1/update/check") -async def check_update(body: CheckUpdateReq = Body(...)): +async def check_update(request: Request, body: CheckUpdateReq = Body(...)): + if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel: raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") print("收到客户端版本检测请求,参数:", body.model_dump()) app_id, cur_ver, channel = body.app_id, body.current_version, body.channel conn = db.get_conn() + require_channel(conn, app_id, channel) ver = conn.execute( "SELECT * FROM versions WHERE app_id=? AND channel=? AND latest=1", (app_id, channel) ).fetchone() @@ -316,9 +1071,11 @@ async def check_update(body: CheckUpdateReq = Body(...)): settings = policy_row_to_dict(policy_row) latest_ver = ver["version"] if ver else "" version_id = int(ver["id"]) if ver else 0 + target_protocol = int(ver["client_protocol"] or 1) if ver else 0 + protocol_compatible = not ver or target_protocol >= body.client_protocol upgrade_available = bool(latest_ver) and version_key(latest_ver) > version_key(cur_ver) rollback_candidate = bool(latest_ver) and version_key(latest_ver) < version_key(cur_ver) - rollback_allowed = rollback_candidate and bool(settings["allow_rollback"]) + rollback_allowed = rollback_candidate and bool(settings["allow_rollback"]) and protocol_compatible need_update = upgrade_available or rollback_allowed disabled = cur_ver in settings["disabled_versions"] below_min = bool(settings["min_supported_version"]) and version_key(cur_ver) < version_key(settings["min_supported_version"]) @@ -332,6 +1089,8 @@ async def check_update(body: CheckUpdateReq = Body(...)): action, message = "force_update", settings["message"] or "必须升级到最新版本后才能继续使用" elif rollback_allowed: action, message = "rollback_allowed", settings["message"] or f"管理员要求回退到版本 {latest_ver}" + elif rollback_candidate and not protocol_compatible: + action, message = "rollback_denied", f"目标版本 {latest_ver} 的客户端协议为 {target_protocol},低于当前协议 {body.client_protocol},禁止降级" elif rollback_candidate: action, message = "rollback_denied", settings["message"] or f"渠道目标版本 {latest_ver} 低于当前版本,策略禁止降级" elif upgrade_available: @@ -348,7 +1107,7 @@ async def check_update(body: CheckUpdateReq = Body(...)): "issued_at": issued_at, "valid_until": settings["valid_until"], "latest_version": latest_ver, "min_supported_version": settings["min_supported_version"], "disabled_versions": settings["disabled_versions"], "message": message, - "signature_alg": "RSA-2048-SHA256", "key_id": "manifest-key-v1" + "signature_alg": "RSA-2048-SHA256", "key_id": SIGNING_KEY_ID } policy_text, policy_signature = sign_policy(policy) policy["signature"] = policy_signature @@ -357,21 +1116,29 @@ async def check_update(body: CheckUpdateReq = Body(...)): "need_update": need_update, "release_available": bool(ver), "current_version": cur_ver, "latest_version": latest_ver, "version_id": version_id, "force_update": force_update, "allow_rollback": settings["allow_rollback"], - "rollback": rollback_allowed, + "rollback": rollback_allowed, "client_protocol": body.client_protocol, + "target_client_protocol": target_protocol, "protocol_compatible": protocol_compatible, "policy_seq": settings["policy_seq"], "policy_valid_until": settings["valid_until"], "policy": policy, "policy_text": policy_text, "policy_signature": policy_signature } # 2. 获取文件下载链接 @app.post("/api/v1/update/download-url") -async def get_download_url(body: DownloadUrlReq = Body(...)): +async def get_download_url(request: Request, body: DownloadUrlReq = Body(...)): + if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel: raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") ver_id = body.version_id conn = db.get_conn() + version_row = conn.execute("SELECT app_id,channel,version FROM versions WHERE id=?", (ver_id,)).fetchone() + if not version_row or version_row["app_id"] != body.app_id or version_row["channel"] != body.channel or version_row["version"] != body.version: + conn.close(); raise HTTPException(status_code=404, detail="版本与应用/渠道不匹配") rows = conn.execute( "SELECT path, sha256, size FROM version_files WHERE version_id=?", (ver_id,) ).fetchall() - conn.close() + identity=request.state.device_identity; ip=request.client.host if request.client else ""; ua=request.headers.get("user-agent", "")[:300] + conn.executemany("""INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent) + VALUES(?,?,?,?,?,?,?,?,?,?)""", [(identity["device_id"],identity["license_id"],body.app_id,body.version,body.channel,r["path"],r["size"],"authorized",ip,ua) for r in rows]) + conn.commit(); conn.close() res = [] base_path = f"{body.app_id}/{body.channel}/{body.version}/files/" for r in rows: @@ -385,9 +1152,21 @@ async def get_download_url(body: DownloadUrlReq = Body(...)): }) return {"files": res} +@app.post("/api/v1/update/download-report") +async def report_download(request: Request, body: DownloadReportReq = Body(...)): + identity=request.state.device_identity + if identity["app_id"]!=body.app_id or identity["channel"]!=body.channel: raise HTTPException(status_code=403,detail="设备凭证与应用/渠道不匹配") + if body.result not in ("success","fail"): raise HTTPException(status_code=400,detail="下载结果无效") + conn=db.get_conn(); ip=request.client.host if request.client else "";ua=request.headers.get("user-agent","")[:300]; values=[] + for item in body.files[:5000]: + path=normalize_relative_path(str(item.get("path") or ""));size=max(0,int(item.get("size") or 0));values.append((identity["device_id"],identity["license_id"],body.app_id,body.version,body.channel,path,size,body.result,ip,ua)) + if values: conn.executemany("INSERT INTO download_logs(device_id,license_id,app_id,version,channel_code,file_path,file_size,result,ip,user_agent) VALUES(?,?,?,?,?,?,?,?,?,?)",values) + conn.commit();conn.close();return {"code":0,"logged":len(values)} + # 2b. 获取版本 manifest @app.post("/api/v1/update/manifest") -async def get_manifest(body: ManifestReq = Body(...)): +async def get_manifest(request: Request, body: ManifestReq = Body(...)): + if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel: raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配") ver_id = body.version_id conn = db.get_conn() ver = conn.execute( @@ -417,8 +1196,8 @@ async def get_manifest(body: ManifestReq = Body(...)): "app_id": ver["app_id"], "version": ver["version"], "channel": ver["channel"], - "platform": "windows", - "arch": "x64", + "platform": TARGET_PLATFORM, + "arch": TARGET_ARCH, "manifest_seq": int(ver_id), "created_at": ver["create_time"], "files": files @@ -434,7 +1213,9 @@ async def get_manifest(body: ManifestReq = Body(...)): # 3. 升级结果上报 @app.post("/api/v1/update/report") -async def report(body: ReportReq = Body(...)): +async def report(request: Request, body: ReportReq = Body(...)): + identity=request.state.device_identity + if identity["app_id"] != body.app_id or identity["device_id"] != body.device_id: raise HTTPException(status_code=403, detail="上报身份与设备凭证不匹配") conn = db.get_conn() insert_sql = """ INSERT INTO upgrade_logs(device_id,from_ver,to_ver,result,create_time) @@ -448,16 +1229,14 @@ async def report(body: ReportReq = Body(...)): # ==================== 管理后台 Admin 全套接口(统一Header鉴权) ==================== @app.post("/admin/token/change") def admin_change_token(body: ChangeAdminTokenReq, auth=Depends(admin_auth)): - global ADMIN_TOKEN_HASH + global ADMIN_TOKEN new_token = body.new_token.strip() if len(new_token) < 8: raise HTTPException(status_code=400, detail="新令牌至少需要 8 个字符") if len(new_token) > 128: raise HTTPException(status_code=400, detail="新令牌不能超过 128 个字符") - new_hash = token_digest(new_token) - ADMIN_TOKEN_HASH_PATH.write_text(new_hash, encoding="utf-8") - ADMIN_TOKEN_HASH = new_hash - return {"code": 0, "msg": "管理员令牌已更新"} + ADMIN_TOKEN = new_token + return {"code": 0, "msg": "管理员令牌已在当前服务进程生效;如需重启后继续使用,请同步修改 .env 的 ADMIN_TOKEN"} # 应用列表 GET @app.get("/admin/app/list") @@ -475,15 +1254,29 @@ def admin_add_app(body: dict, auth=Depends(admin_auth)): conn = db.get_conn() try: conn.execute("INSERT INTO apps(app_id, app_name) VALUES (?, ?)", (aid, aname)) + conn.executemany("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?)", + [(aid,"stable","正式版",1,10),(aid,"preview","预览版",1,20),(aid,"dev","开发版",1,30)]) conn.commit() except Exception as e: return {"msg": f"创建失败:{str(e)}"} conn.close() return {"msg": "应用创建成功"} +@app.get("/admin/channel/list") +def admin_channel_list(app_id: str, include_disabled: bool = True, auth=Depends(admin_auth)): + conn=db.get_conn();sql="SELECT channel_code,display_name,enabled,sort_order FROM channels WHERE app_id=?"+("" if include_disabled else " AND enabled=1")+" ORDER BY sort_order,channel_code";rows=conn.execute(sql,(app_id,)).fetchall();conn.close();return {"list":[{"channel_code":r["channel_code"],"display_name":r["display_name"],"enabled":bool(r["enabled"]),"sort_order":r["sort_order"]} for r in rows]} + +@app.post("/admin/channel/save") +def admin_channel_save(body: dict, auth=Depends(admin_auth)): + app_id=str(body.get("app_id") or "").strip();code=str(body.get("channel_code") or "").strip();name=str(body.get("display_name") or "").strip();enabled=bool(body.get("enabled",True));order=int(body.get("sort_order") or 100) + if not app_id or not validate_channel_code(code) or not name or len(name)>64: raise HTTPException(status_code=400,detail="渠道参数无效;代码仅允许字母、数字、下划线和连字符") + conn=db.get_conn(); + if not conn.execute("SELECT 1 FROM apps WHERE app_id=?",(app_id,)).fetchone():conn.close();raise HTTPException(status_code=404,detail="应用不存在") + conn.execute("INSERT INTO channels(app_id,channel_code,display_name,enabled,sort_order) VALUES(?,?,?,?,?) ON CONFLICT(app_id,channel_code) DO UPDATE SET display_name=excluded.display_name,enabled=excluded.enabled,sort_order=excluded.sort_order,updated_at=datetime('now')",(app_id,code,name,int(enabled),order));conn.commit();conn.close();return {"msg":"渠道已保存"} + # 查询和保存应用渠道策略 @app.get("/admin/policy") -def admin_get_policy(app_id: str, channel: str = "stable", auth=Depends(admin_auth)): +def admin_get_policy(app_id: str, channel: str, auth=Depends(admin_auth)): conn = db.get_conn() row = conn.execute("SELECT * FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone() conn.close() @@ -495,8 +1288,8 @@ def admin_get_policy(app_id: str, channel: str = "stable", auth=Depends(admin_au @app.post("/admin/policy/save") def admin_save_policy(body: dict, auth=Depends(admin_auth)): app_id = str(body.get("app_id") or "").strip() - channel = str(body.get("channel") or "stable").strip() - if not app_id or channel not in ("stable", "preview", "dev"): + channel = str(body.get("channel") or "").strip() + if not app_id or not validate_channel_code(channel): raise HTTPException(status_code=400, detail="App ID 或渠道无效") disabled = body.get("disabled_versions") or [] if not isinstance(disabled, list) or any(not isinstance(v, str) for v in disabled): @@ -507,6 +1300,7 @@ def admin_save_policy(body: dict, auth=Depends(admin_auth)): except ValueError: raise HTTPException(status_code=400, detail="valid_until 必须是 ISO-8601 时间") conn = db.get_conn() + require_channel(conn, app_id, channel) row = conn.execute("SELECT policy_seq FROM version_policies WHERE app_id=? AND channel=?", (app_id, channel)).fetchone() next_seq = (int(row["policy_seq"]) + 1) if row else 1 values = (next_seq, int(bool(body.get("force_update"))), int(bool(body.get("allow_rollback"))), @@ -565,8 +1359,14 @@ async def admin_publish_version(request: Request, auth=Depends(admin_auth)): raise # 尝试从 form 中获取字段 app_id = form.get("app_id") - channel = form.get("channel") or "stable" + channel = form.get("channel") or "" version = form.get("version") + try: + client_protocol = int(form.get("client_protocol") or 2) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="client_protocol 必须是正整数") + if client_protocol < 1: + raise HTTPException(status_code=400, detail="client_protocol 必须是正整数") # 收集 files(可能是多个同名字段) files = [] @@ -609,20 +1409,29 @@ async def admin_publish_version(request: Request, auth=Depends(admin_auth)): raise HTTPException(status_code=400, detail="上传内容中包含无效文件") raw_path = relative_paths[index] if relative_paths else file.filename rel_path = normalize_relative_path(str(raw_path)) + validate_release_path(rel_path) path_key = rel_path.lower() if path_key in seen_paths: raise HTTPException(status_code=400, detail=f"存在重复文件路径: {rel_path}") seen_paths.add(path_key) upload_items.append((file, rel_path)) - if "mainapp.exe" not in seen_paths: - nested_main = next((path for path in seen_paths if path.endswith("/mainapp.exe")), None) + required_main_key = RELEASE_MAIN_EXECUTABLE.casefold() + if required_main_key not in seen_paths: + nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None) if nested_main: - raise HTTPException(status_code=400, detail=f"MainApp.exe 不在发布根级,请改为选择其所在目录: {nested_main}") - raise HTTPException(status_code=400, detail="发布根目录中缺少 MainApp.exe") + raise HTTPException(status_code=400, detail=f"{RELEASE_MAIN_EXECUTABLE} 不在发布根级,请改为选择其所在目录: {nested_main}") + raise HTTPException(status_code=400, detail=f"发布根目录中缺少 {RELEASE_MAIN_EXECUTABLE}") + nested_main = next((path for path in seen_paths if path.endswith("/" + required_main_key)), None) + if nested_main: + raise HTTPException( + status_code=400, + detail=f"发布目录中存在嵌套的重复主程序 {nested_main},请使用干净的 Release 输出目录" + ) conn = db.get_conn() + require_channel(conn, str(app_id), str(channel)) publish_started = False publish_prefix = f"{app_id}/{channel}/{version}/" try: @@ -636,8 +1445,8 @@ async def admin_publish_version(request: Request, auth=Depends(admin_auth)): cur.execute("UPDATE versions SET latest=0 WHERE app_id=? AND channel=?", (app_id, channel)) cur.execute( - "INSERT INTO versions(app_id, channel, version, latest) VALUES (?,?,?,1)", - (app_id, channel, version) + "INSERT INTO versions(app_id, channel, version, latest, client_protocol) VALUES (?,?,?,1,?)", + (app_id, channel, version, client_protocol) ) new_vid = cur.lastrowid publish_started = True @@ -686,12 +1495,33 @@ async def admin_publish_version(request: Request, auth=Depends(admin_auth)): return {"msg": f"版本 {version} 发布完成,共上传 {len(upload_items)} 个文件"} +@app.post("/admin/version/offline-package") +def admin_offline_package(body: dict, auth=Depends(admin_auth)): + version_id = int(body.get("version_id") or 0) + conn=db.get_conn();ver=conn.execute("SELECT app_id,channel,version,create_time FROM versions WHERE id=?",(version_id,)).fetchone();rows=conn.execute("SELECT path,sha256,size FROM version_files WHERE version_id=? ORDER BY id",(version_id,)).fetchall();conn.close() + if not ver or not rows: raise HTTPException(status_code=404,detail="版本或版本文件不存在") + files=[{"path":r["path"],"sha256":r["sha256"],"size":r["size"],"executable":is_executable_path(r["path"])} for r in rows] + manifest={"app_id":ver["app_id"],"version":ver["version"],"channel":ver["channel"],"platform":TARGET_PLATFORM,"arch":TARGET_ARCH,"manifest_seq":version_id,"created_at":ver["create_time"],"files":files} + manifest_text=canonical_manifest_bytes(manifest).decode();manifest_signature=sign_manifest(manifest) + offset=0;entries=[] + for r in rows: entries.append({"path":r["path"],"offset":offset,"size":r["size"],"sha256":r["sha256"]});offset+=int(r["size"] or 0) + package={"format":"MUPD0001","app_id":ver["app_id"],"channel":ver["channel"],"version":ver["version"],"version_id":version_id,"created_at":datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00","Z"),"manifest_sha256":hashlib.sha256(manifest_text.encode()).hexdigest(),"payload_size":offset,"files":entries,"signature_alg":"RSA-2048-SHA256","key_id":SIGNING_KEY_ID} + package_text,package_signature=sign_policy(package) + wrapper=json.dumps({"package_text":package_text,"package_signature":package_signature,"manifest_text":manifest_text,"manifest_signature":manifest_signature},ensure_ascii=False,separators=(",",":")).encode() + prefix=f"{ver['app_id']}/{ver['channel']}/{ver['version']}/files/" + def stream(): + yield b"MUPD0001";yield len(wrapper).to_bytes(8,"little");yield wrapper + for r in rows: + yield from minio_tool.iter_file(prefix+r["path"]) + filename=f"{ver['app_id']}_{ver['channel']}_{ver['version']}.upd" + return StreamingResponse(stream(),media_type="application/octet-stream",headers={"Content-Disposition":f'attachment; filename="{filename}"',"Content-Length":str(16+len(wrapper)+offset)}) + # 获取版本列表 GET @app.get("/admin/version/list") def admin_get_version_list(app_id: str, auth=Depends(admin_auth)): conn = db.get_conn() rows = conn.execute(""" - SELECT id, version, channel, latest, create_time + SELECT id, version, channel, latest, client_protocol, create_time FROM versions WHERE app_id=? ORDER BY create_time DESC """, (app_id,)).fetchall() conn.close() @@ -702,10 +1532,26 @@ def admin_get_version_list(app_id: str, auth=Depends(admin_auth)): "version": r["version"], "channel": r["channel"], "latest": bool(r["latest"]), + "client_protocol": int(r["client_protocol"] or 1), "create_time": r["create_time"] }) return {"list": out} +# 修正历史版本的客户端协议标记 +@app.post("/admin/version/set-protocol") +def admin_set_version_protocol(body: dict, auth=Depends(admin_auth)): + version_id = int(body.get("version_id") or 0) + client_protocol = int(body.get("client_protocol") or 0) + if version_id < 1 or client_protocol < 1: + raise HTTPException(status_code=400, detail="版本 ID 和客户端协议必须是正整数") + conn = db.get_conn() + cur = conn.execute("UPDATE versions SET client_protocol=? WHERE id=?", (client_protocol, version_id)) + if cur.rowcount != 1: + conn.close(); raise HTTPException(status_code=404, detail="版本不存在") + conn.commit(); conn.close() + return {"msg": "客户端协议已更新", "version_id": version_id, "client_protocol": client_protocol} + + # 设置指定版本为渠道最新 POST json @app.post("/admin/version/set-latest") def admin_set_latest(body: dict, auth=Depends(admin_auth)): @@ -775,6 +1621,55 @@ def admin_get_report_log(auth=Depends(admin_auth)): }) return {"list": res} + +@app.get("/admin/runtime-config") +def admin_runtime_config(auth=Depends(admin_auth)): + return { + "release_main_executable": RELEASE_MAIN_EXECUTABLE, + "target_platform": TARGET_PLATFORM, + "target_arch": TARGET_ARCH, + } + + +@app.get("/admin/download-log/list") +def admin_download_log_list(app_id: str="", limit: int=300, auth=Depends(admin_auth)): + limit=max(1,min(limit,1000));conn=db.get_conn();rows=conn.execute("SELECT * FROM download_logs WHERE app_id=? ORDER BY id DESC LIMIT ?",(app_id,limit)).fetchall() if app_id else conn.execute("SELECT * FROM download_logs ORDER BY id DESC LIMIT ?",(limit,)).fetchall();conn.close();return {"list":[dict(r) for r in rows]} + +@app.get("/admin/audit-log/list") +def admin_audit_log_list(limit: int=300, auth=Depends(admin_auth)): + conn=db.get_conn();rows=conn.execute("SELECT * FROM admin_audit_logs ORDER BY id DESC LIMIT ?",(max(1,min(limit,1000)),)).fetchall();conn.close();return {"list":[dict(r) for r in rows]} + +@app.post("/admin/license/create") +def admin_license_create(body: dict, auth=Depends(admin_auth)): + app_id=str(body.get("app_id") or "").strip();channel=str(body.get("channel") or "").strip();customer=str(body.get("customer_name") or "").strip();max_devices=int(body.get("max_devices") or 1);valid_until=str(body.get("valid_until") or "").strip() + try: expiry=datetime.fromisoformat(valid_until.replace("Z","+00:00")) + except ValueError: raise HTTPException(status_code=400,detail="valid_until 必须是 ISO-8601 时间") + if not app_id or not customer or not validate_channel_code(channel) or max_devices<1 or expiry<=datetime.now(timezone.utc): raise HTTPException(status_code=400,detail="授权参数无效") + license_id="lic_"+secrets.token_hex(12);license_key="MARSCO-"+secrets.token_urlsafe(24);conn=db.get_conn();require_channel(conn,app_id,channel);conn.execute("INSERT INTO licenses(license_id,license_key_hash,customer_name,app_id,channel_code,max_devices,valid_until,status) VALUES(?,?,?,?,?,?,?,'active')",(license_id,token_digest(license_key),customer,app_id,channel,max_devices,valid_until));conn.commit();conn.close();return {"license_id":license_id,"license_key":license_key,"msg":"授权已创建;密钥仅本次返回,请立即保存"} + +@app.get("/admin/license/list") +def admin_license_list(app_id: str="", auth=Depends(admin_auth)): + conn=db.get_conn();rows=conn.execute("SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l WHERE app_id=? ORDER BY created_at DESC",(app_id,)).fetchall() if app_id else conn.execute("SELECT l.*,(SELECT COUNT(*) FROM license_devices d WHERE d.license_id=l.license_id) used_devices FROM licenses l ORDER BY created_at DESC").fetchall();conn.close();return {"list":[{k:r[k] for k in ("license_id","customer_name","app_id","channel_code","max_devices","valid_until","status","created_at")}|{"used_devices":r["used_devices"]} for r in rows]} + +@app.post("/admin/license/set-status") +def admin_license_set_status(body: dict, auth=Depends(admin_auth)): + status=str(body.get("status") or "");license_id=str(body.get("license_id") or ""); + if status not in ("active","disabled"): raise HTTPException(status_code=400,detail="授权状态无效") + conn=db.get_conn();cur=conn.execute("UPDATE licenses SET status=?,updated_at=datetime('now') WHERE license_id=?",(status,license_id));conn.commit();conn.close() + if cur.rowcount!=1: raise HTTPException(status_code=404,detail="授权不存在") + return {"msg":"授权状态已更新"} + +@app.get("/admin/device/list") +def admin_device_list(app_id: str = "", auth=Depends(admin_auth)): + conn=db.get_conn(); rows=conn.execute("SELECT * FROM devices WHERE app_id=? ORDER BY last_seen_at DESC",(app_id,)).fetchall() if app_id else conn.execute("SELECT * FROM devices ORDER BY last_seen_at DESC LIMIT 500").fetchall(); conn.close() + return {"list":[{"device_id":r["device_id"],"app_id":r["app_id"],"installation_id":r["installation_id"],"disabled":bool(r["disabled"]),"disabled_reason":r["disabled_reason"],"credential_seq":r["credential_seq"],"first_seen_at":r["first_seen_at"],"last_seen_at":r["last_seen_at"],"last_ip":r["last_ip"]} for r in rows]} + +@app.post("/admin/device/set-disabled") +def admin_device_set_disabled(body: dict, auth=Depends(admin_auth)): + device_id=str(body.get("device_id") or "").strip(); disabled=bool(body.get("disabled")); reason=str(body.get("reason") or "").strip(); conn=db.get_conn(); cur=conn.execute("UPDATE devices SET disabled=?,disabled_reason=? WHERE device_id=?",(int(disabled),reason if disabled else "",device_id)) + if cur.rowcount != 1: conn.close(); raise HTTPException(status_code=404,detail="设备不存在") + conn.commit(); conn.close(); return {"msg":"设备已禁用" if disabled else "设备已恢复","device_id":device_id} + if __name__ == "__main__": import uvicorn - uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1) \ No newline at end of file + uvicorn.run(app, host=SERVER_HOST, port=SERVER_PORT, workers=1) diff --git a/server/minio_tool.py b/server/minio_tool.py index eacc0f5..0139bb3 100644 --- a/server/minio_tool.py +++ b/server/minio_tool.py @@ -7,39 +7,69 @@ import socket import shutil from pathlib import Path import urllib3 +from urllib.parse import urlparse from urllib3.util import Retry, Timeout load_dotenv() +BASE_DIR = Path(__file__).resolve().parent + + +def env_bool(name: str, default: bool = False) -> bool: + value = os.getenv(name) + return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"} MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT") MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY") MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY") MINIO_BUCKET = os.getenv("MINIO_BUCKET") SIGN_EXPIRE_MIN = int(os.getenv("SIGN_EXPIRE_MIN") or 60) -LOCAL_UPLOAD_ROOT = Path(os.getenv("LOCAL_UPLOAD_ROOT", "local_uploads")) +LOCAL_UPLOAD_ROOT = Path(os.getenv("LOCAL_UPLOAD_ROOT", "local_uploads")).expanduser() +if not LOCAL_UPLOAD_ROOT.is_absolute(): + LOCAL_UPLOAD_ROOT = BASE_DIR / LOCAL_UPLOAD_ROOT LOCAL_FILE_URL_BASE = os.getenv("LOCAL_FILE_URL_BASE") +MINIO_SECURE = env_bool("MINIO_SECURE") +MINIO_CONNECT_TIMEOUT_SEC = float(os.getenv("MINIO_CONNECT_TIMEOUT_SEC", "2")) +MINIO_READ_TIMEOUT_SEC = float(os.getenv("MINIO_READ_TIMEOUT_SEC", "5")) +MINIO_RETRY_TOTAL = int(os.getenv("MINIO_RETRY_TOTAL", "1")) LOCAL_UPLOAD_ROOT.mkdir(parents=True, exist_ok=True) http_client = urllib3.PoolManager( - timeout=Timeout(connect=2.0, read=5.0), - retries=Retry(total=1, connect=1, read=1, status=1, backoff_factor=0.2), + timeout=Timeout(connect=MINIO_CONNECT_TIMEOUT_SEC, read=MINIO_READ_TIMEOUT_SEC), + retries=Retry(total=MINIO_RETRY_TOTAL, connect=MINIO_RETRY_TOTAL, + read=MINIO_RETRY_TOTAL, status=MINIO_RETRY_TOTAL, backoff_factor=0.2), ) mc = Minio( MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, - secure=False, + secure=MINIO_SECURE, http_client=http_client, ) +public_endpoint_raw = os.getenv("MINIO_PUBLIC_ENDPOINT", "").strip() +if public_endpoint_raw: + parsed_public_endpoint = urlparse( + public_endpoint_raw if "://" in public_endpoint_raw else f"http://{public_endpoint_raw}" + ) + public_mc = Minio( + parsed_public_endpoint.netloc, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=parsed_public_endpoint.scheme == "https", + ) +else: + public_mc = mc + def get_url(file_path: str) -> str: bucket = MINIO_BUCKET full_object_path = file_path try: mc.stat_object(bucket, full_object_path) - url = mc.presigned_get_object(bucket, full_object_path, expires=timedelta(minutes=SIGN_EXPIRE_MIN)) + url = public_mc.presigned_get_object( + bucket, full_object_path, expires=timedelta(minutes=SIGN_EXPIRE_MIN) + ) return url except Exception as err: print(f"generate url error: {err}") @@ -55,7 +85,8 @@ def get_url(file_path: str) -> str: host = '127.0.0.1' port = os.getenv('SERVER_PORT') or '8000' return f"http://{host}:{port}/static/{file_path}" - return f"http://{MINIO_ENDPOINT}/{bucket}/{full_object_path}" + scheme = "https" if MINIO_SECURE else "http" + return f"{scheme}://{MINIO_ENDPOINT}/{bucket}/{full_object_path}" def put_file(object_path: str, data: bytes, size: int): @@ -91,3 +122,29 @@ def remove_prefix(prefix: str): shutil.rmtree(local_prefix) except Exception as e: print(f"local remove_prefix error: {e}") + +def iter_file(object_path: str, chunk_size: int = 1024 * 1024): + """Stream an object without creating a second full local copy.""" + response = None + emitted = 0 + try: + response = mc.get_object(MINIO_BUCKET, object_path) + while True: + chunk = response.read(chunk_size) + if not chunk: break + emitted += len(chunk) + yield chunk + return + except Exception as err: + if emitted: + raise RuntimeError(f"MinIO stream interrupted after {emitted} bytes: {object_path}") from err + print(f"MinIO stream fallback for {object_path}: {err}") + finally: + if response is not None: + response.close(); response.release_conn() + local_path = LOCAL_UPLOAD_ROOT / object_path + with open(local_path, "rb") as source: + while True: + chunk = source.read(chunk_size) + if not chunk: break + yield chunk diff --git a/server/package-offline-server.ps1 b/server/package-offline-server.ps1 new file mode 100644 index 0000000..8a68655 --- /dev/null +++ b/server/package-offline-server.ps1 @@ -0,0 +1,95 @@ +param( + [string]$Version = "0.1.0", + [string]$OutputDir = "$PSScriptRoot/dist/SimCAEServerDockerPackage", + [switch]$SkipBuild, + [switch]$SkipPull +) + +$ErrorActionPreference = "Stop" + +$serverDir = $PSScriptRoot +$projectRoot = (Resolve-Path (Join-Path $serverDir "..")).Path +$imageName = "simcae-update-server:$Version" +$minioImage = "minio/minio:RELEASE.2025-04-22T22-12-26Z" +$mcImage = "minio/mc:RELEASE.2025-04-16T18-13-26Z" +$packageDir = $OutputDir +$imagesDir = Join-Path $packageDir "images" +$keysDir = Join-Path $packageDir "keys" +$tarFile = Join-Path $imagesDir "simcae-server-all-images_$Version.tar" + +function Require-File($path, $message) { + if (-not (Test-Path $path)) { throw $message } +} + +Require-File (Join-Path $serverDir "Dockerfile") "server/Dockerfile is missing." +Require-File (Join-Path $serverDir "docker-compose.image.yml") "server/docker-compose.image.yml is missing." +Require-File (Join-Path $serverDir ".env.docker.example") "server/.env.docker.example is missing." +Require-File (Join-Path $serverDir "keys/manifest_private_key.pem") "server/keys/manifest_private_key.pem is missing." +Require-File (Join-Path $serverDir "keys/manifest_public_key.pem") "server/keys/manifest_public_key.pem is missing." + +if (-not $SkipBuild) { + docker build -t $imageName -f (Join-Path $serverDir "Dockerfile") $projectRoot +} + +if (-not $SkipPull) { + docker pull $minioImage + docker pull $mcImage +} + +if (Test-Path $packageDir) { Remove-Item $packageDir -Recurse -Force } +New-Item $imagesDir,$keysDir -ItemType Directory -Force | Out-Null + +Copy-Item (Join-Path $serverDir "docker-compose.image.yml") (Join-Path $packageDir "docker-compose.yml") -Force +Copy-Item (Join-Path $serverDir ".env.docker.example") (Join-Path $packageDir ".env.example") -Force +$readmeSource = Get-ChildItem $serverDir -File -Filter "*.md" | Where-Object { + $_.Name -like "*Docker*.md" +} | Select-Object -First 1 +if ($readmeSource) { + Copy-Item $readmeSource.FullName (Join-Path $packageDir "README.md") -Force +} +Copy-Item (Join-Path $serverDir "keys/manifest_private_key.pem") (Join-Path $keysDir "manifest_private_key.pem") -Force +Copy-Item (Join-Path $serverDir "keys/manifest_public_key.pem") (Join-Path $keysDir "manifest_public_key.pem") -Force + +$quickReadme = @" +# SimCAE Server Offline Docker Package + +1. Copy this package to the target server and extract it. +2. Run: ./load-images.ps1 +3. Edit .env and replace tokens, passwords, and SERVER_IP. +4. Run: docker compose up -d +5. Visit: http://SERVER_IP:8000/ + +Important: keys/manifest_private_key.pem is the server signing private key. Keep it secret. +"@ +$readmePath = Join-Path $packageDir "README.md" +if (Test-Path $readmePath) { + $oldReadme = Get-Content $readmePath -Raw -Encoding UTF8 + Set-Content $readmePath ($quickReadme + "`n---`n`n" + $oldReadme) -Encoding UTF8 +} else { + Set-Content $readmePath $quickReadme -Encoding UTF8 +} + +@" +`$ErrorActionPreference = "Stop" +docker load -i .\images\simcae-server-all-images_$Version.tar +if (-not (Test-Path .\.env)) { + Copy-Item .\.env.example .\.env + Write-Host "Created .env from .env.example. Edit .env before starting services." +} else { + Write-Host ".env already exists. Keeping existing file." +} +Write-Host "Next: edit .env, then run: docker compose up -d" +"@ | Set-Content (Join-Path $packageDir "load-images.ps1") -Encoding UTF8 + +$envExamplePath = Join-Path $packageDir ".env.example" +$envText = Get-Content $envExamplePath -Raw -Encoding UTF8 +$envText = $envText.Replace("SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0", "SIMCAE_UPDATE_SERVER_IMAGE=$imageName") +Set-Content $envExamplePath $envText -Encoding UTF8 + +docker save -o $tarFile $imageName $minioImage $mcImage + +Compress-Archive -Path (Join-Path $packageDir "*") -DestinationPath "$packageDir.zip" -CompressionLevel Optimal -Force + +Write-Host "Offline package directory: $packageDir" +Write-Host "Offline package zip: $packageDir.zip" +Write-Host "Images tar: $tarFile" diff --git a/server/package-offline-server.sh b/server/package-offline-server.sh new file mode 100755 index 0000000..02de630 --- /dev/null +++ b/server/package-offline-server.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="0.1.0" +OUTPUT_DIR="" +SKIP_BUILD=0 +SKIP_PULL=0 + +usage() { + cat <<'EOF' +Usage: ./package-offline-server.sh [options] + +Options: + --version VERSION Image/package version, default: 0.1.0 + --output-dir DIR Output package directory, default: server/dist/SimCAEServerDockerPackage + --skip-build Do not build simcae-update-server image + --skip-pull Do not pull MinIO images + -h, --help Show this help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --version) + VERSION="${2:?--version requires a value}" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="${2:?--output-dir requires a value}" + shift 2 + ;; + --skip-build) + SKIP_BUILD=1 + shift + ;; + --skip-pull) + SKIP_PULL=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +require_file() { + local path="$1" + local message="$2" + if [[ ! -e "$path" ]]; then + echo "$message" >&2 + exit 1 + fi +} + +require_cmd() { + local name="$1" + if ! command -v "$name" >/dev/null 2>&1; then + echo "Required command is missing: $name" >&2 + exit 1 + fi +} + +require_cmd docker +require_cmd tar + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [[ -z "$OUTPUT_DIR" ]]; then + OUTPUT_DIR="$SCRIPT_DIR/dist/SimCAEServerDockerPackage" +fi + +IMAGE_NAME="simcae-update-server:$VERSION" +MINIO_IMAGE="minio/minio:RELEASE.2025-04-22T22-12-26Z" +MC_IMAGE="minio/mc:RELEASE.2025-04-16T18-13-26Z" +PACKAGE_DIR="$OUTPUT_DIR" +IMAGES_DIR="$PACKAGE_DIR/images" +KEYS_DIR="$PACKAGE_DIR/keys" +TAR_FILE="$IMAGES_DIR/simcae-server-all-images_$VERSION.tar" +ARCHIVE_FILE="$PACKAGE_DIR.tar.gz" + +require_file "$SCRIPT_DIR/Dockerfile" "server/Dockerfile is missing." +require_file "$SCRIPT_DIR/docker-compose.image.yml" "server/docker-compose.image.yml is missing." +require_file "$SCRIPT_DIR/.env.docker.example" "server/.env.docker.example is missing." +require_file "$SCRIPT_DIR/keys/manifest_private_key.pem" "server/keys/manifest_private_key.pem is missing." +require_file "$SCRIPT_DIR/keys/manifest_public_key.pem" "server/keys/manifest_public_key.pem is missing." + +if [[ "$SKIP_BUILD" -eq 0 ]]; then + docker build -t "$IMAGE_NAME" -f "$SCRIPT_DIR/Dockerfile" "$PROJECT_ROOT" +fi + +if [[ "$SKIP_PULL" -eq 0 ]]; then + docker pull "$MINIO_IMAGE" + docker pull "$MC_IMAGE" +fi + +rm -rf "$PACKAGE_DIR" +mkdir -p "$IMAGES_DIR" "$KEYS_DIR" + +cp "$SCRIPT_DIR/docker-compose.image.yml" "$PACKAGE_DIR/docker-compose.yml" +cp "$SCRIPT_DIR/.env.docker.example" "$PACKAGE_DIR/.env.example" + +cat > "$PACKAGE_DIR/README.md" <<'EOF_README' +# SimCAE 服务端 Docker 部署包说明 + +这个包已经包含服务端运行需要的 Docker 镜像、配置模板、签名密钥和编排文件。你的服务器无论能不能联网,都可以按下面流程部署。 + +## 一、这个包里面有什么 + +解压后目录结构如下: + +```text +SimCAEServerDockerPackage/ + README.md 当前说明文档 + docker-compose.yml 容器编排文件 + .env.example 环境变量模板 + load-images.sh 导入 Docker 镜像并创建 .env 的脚本 + images/ + simcae-server-all-images___VERSION__.tar 三个 Docker 镜像:api、minio、minio-init + keys/ + manifest_private_key.pem 服务端 Manifest 签名私钥 + manifest_public_key.pem 与客户端配套的验签公钥 +``` + +## 二、你在服务器上怎么运行 + +先把 `SimCAEServerDockerPackage.tar.gz` 拷贝到要部署的 Ubuntu 服务器上,然后执行: + +```bash +tar -xzf SimCAEServerDockerPackage.tar.gz +cd SimCAEServerDockerPackage +pwd +ls +``` + +你需要确认当前目录就是解压后的 `SimCAEServerDockerPackage` 目录,并且能看到: + +```text +docker-compose.yml +.env.example +load-images.sh +images/ +keys/ +``` + +后面所有 `docker compose` 命令都要在这个目录执行,因为 `docker-compose.yml` 和 `.env` 都在这里。 + +## 三、导入镜像并生成 .env + +在 `SimCAEServerDockerPackage` 目录执行: + +```bash +bash ./load-images.sh +``` + +这个脚本会做两件事: + +```text +1. docker load 导入 images/simcae-server-all-images___VERSION__.tar 里的镜像 +2. 如果当前目录没有 .env,就从 .env.example 复制一份 .env +``` + +## 四、修改 .env + +继续在 `SimCAEServerDockerPackage` 目录执行: + +```bash +nano .env +``` + +`.env.example` 里已经填好一组可直接试跑的默认 token 和 MinIO 账号。你通常只需要先改服务器地址: + +```text +MINIO_PUBLIC_ENDPOINT=http://你的服务器IP:9000 +``` + +正式部署时,建议同时修改这些默认值,避免所有环境共用同一套公开示例密码: + +```text +CLIENT_API_TOKEN=客户端 API token,对应客户端 app_config.json 的 client_token +ADMIN_TOKEN=后台管理员 token,网页登录时填这个值 +CRASH_REPORT_TOKEN=崩溃上传 token +CRASH_SYMBOL_TOKEN=符号上传 token +MINIO_ACCESS_KEY=MinIO 用户名 +MINIO_SECRET_KEY=MinIO 密码 +``` + +如果你在网页里点击“更改令牌”,新令牌只会在当前服务进程中立即生效。为了让服务重启后仍然使用新令牌,请同步修改 `.env` 里的 `ADMIN_TOKEN`,然后执行 `docker compose restart api`。 + +`MINIO_PUBLIC_ENDPOINT` 必须是 Windows 客户端能访问到的地址。比如服务器 IP 是 `192.168.229.128`,就填: + +```text +MINIO_PUBLIC_ENDPOINT=http://192.168.229.128:9000 +``` + +## 五、启动服务 + +确认你仍然在 `SimCAEServerDockerPackage` 目录: + +```bash +pwd +``` + +然后启动: + +```bash +docker compose up -d +``` + +如果提示 `docker: 'compose' is not a docker command`,说明服务器没有安装 Docker Compose plugin,需要先安装它。 + +## 六、查看状态和日志 + +这些命令也都在 `SimCAEServerDockerPackage` 目录执行: + +```bash +docker compose ps +docker compose logs -f api +``` + +正常情况下可以看到 `api`、`minio` 处于 running/healthy 状态。 + +## 七、访问地址 + +把下面的 `你的服务器IP` 换成真实服务器 IP: + +```text +后台/API: http://你的服务器IP:8000/ +MinIO 控制台: http://你的服务器IP:9001/ +``` + +如果服务器开启防火墙,至少放行: + +```text +8000 后台/API +9000 客户端下载升级文件 +9001 MinIO 控制台,可选 +``` + +## 八、重要提醒 + +`keys/manifest_private_key.pem` 是服务端签 Manifest 的私钥,必须保护好。客户端 SDK 里的 `manifest_public_key.pem` 必须和这个私钥配套,否则客户端会 Manifest 验签失败。 +EOF_README + +python3 - "$PACKAGE_DIR/README.md" "$VERSION" <<'PYREADME' +from pathlib import Path +import sys +path = Path(sys.argv[1]) +version = sys.argv[2] +text = path.read_text(encoding='utf-8').replace('__VERSION__', version) +path.write_text(text, encoding='utf-8') +PYREADME + +cat > "$PACKAGE_DIR/load-images.sh" <.part 保存片段。 +4. Updater 重启后仍可继续未完成文件。 +5. 单文件最多自动重试四次。 +6. 使用递增等待时间重试。 +7. 服务端不支持 Range 时安全地完整重下。 +8. 下载后验证大小和 SHA-256。 +9. 显示当前文件、已下载量、总下载量、速度和总进度。 +10. 清理不属于当前 Manifest 的旧片段。 + +2.8 客户端磁盘空间预检 + +下载前会计算: + +1. 尚未下载的字节数。 +2. 已存在的断点片段大小。 +3. 被覆盖文件需要的备份空间。 +4. 废弃文件需要的备份空间。 +5. 至少 128MB 的安全余量。 + +空间不足时会在开始下载前阻止更新,并显示所需空间和当前可用空间。 + +2.9 升级事务状态机 + +已实现 update/upgrade_state.json,包含: + +1. transaction_id。 +2. from_version。 +3. to_version。 +4. status。 +5. manifest_id。 +6. staging_dir。 +7. backup_dir。 +8. changed_paths。 +9. obsolete_paths。 +10. error_code 和 message。 + +已经实现的主要状态: + +prepared、verified、waiting_mainapp_exit、backed_up、awaiting_bootstrap、replacing、replaced、post_verify、rollback_required、rolling_back、rolled_back、committed、failed。 + +Updater 启动时会读取旧事务,并根据状态尝试恢复或回滚。 + +2.10 Bootstrap 自更新机制 + +已实现: + +1. Bootstrap 不依赖 Qt。 +2. Updater 退出后由 Bootstrap 接管安装。 +3. 可以替换 Updater.exe、Launcher.exe、MainApp.exe、Qt DLL、OpenSSL DLL、Qt 插件和业务文件。 +4. 完成后重新启动 Updater 续办事务。 +5. 回滚也由 Bootstrap 执行,避免运行中的 Updater 锁住自己。 +6. Bootstrap 自身属于不可由普通更新事务替换的根组件。 +7. 旧 Manifest 即使包含 Bootstrap,也会由客户端作为受保护文件忽略。 + +2.11 自动回滚和健康检查 + +已实现: + +1. 安装前备份旧文件。 +2. 替换失败时回滚。 +3. 安装后 Hash 校验失败时回滚。 +4. MainApp 无法启动时回滚。 +5. MainApp 15 秒内未写入健康标记时回滚。 +6. 版本状态保存失败时回滚。 +7. 回滚后恢复旧版本号。 +8. 回滚后重新启动旧 MainApp。 +9. 被删除的废弃文件也会在回滚时恢复。 +10. Updater 自身发生变化时由 Bootstrap 执行回滚。 + +2.12 版本运行策略 + +已实现: + +1. version_policies 数据表。 +2. 每个应用和渠道分别保存策略。 +3. 每次修改自动递增 policy_seq。 +4. RSA 签名策略在线下发。 +5. Launcher 验证策略签名。 +6. 签名失败时拒绝使用。 +7. 策略原子缓存到本地。 +8. policy_seq 防回滚。 +9. 强制升级。 +10. 禁用指定版本。 +11. 最低支持版本。 +12. 允许或禁止降级。 +13. 允许或禁止离线启动。 +14. 策略有效期。 +15. 自定义客户端提示。 +16. 管理页面策略编辑区。 +17. 上次在线验证时间和系统时间回拨检测。 + +2.13 受控降级和用户选择 + +已实现: + +1. 管理员可以将历史版本设置为渠道最新。 +2. 允许降级时服务端返回 rollback_allowed。 +3. 禁止降级时返回 rollback_denied。 +4. 用户可以选择是否执行降级。 +5. 用户拒绝降级后继续运行当前版本。 +6. 普通可选升级也允许用户选择稍后更新。 +7. 强制升级不能跳过。 +8. 降级复用完整事务、Bootstrap、校验、健康确认和失败回滚机制。 +9. 每个发布版本记录 client_protocol,Launcher 上报当前协议。 +10. 降级目标协议低于当前客户端协议时,服务端在安装前返回 rollback_denied。 +11. 管理后台支持查看和修正历史版本的协议标签。 + +2.14 离线运行基础 + +已实现: + +1. 在线策略本地缓存。 +2. 本地策略 RSA 验签。 +3. offline_allowed。 +4. valid_until。 +5. 策略过期时拒绝启动。 +6. policy_seq 防回滚。 +7. 记录上次成功启动时间。 +8. 记录上次在线验证时间。 +9. 检测系统时间是否回拨。 + +说明:目前实现的是“离线运行”,不是“离线升级”。 + +2.15 管理页面 + +已实现: + +1. 管理员令牌输入、隐藏、显示和保存。 +2. 修改管理员令牌。 +3. 创建和选择应用。 +4. 选择软件根目录发布。 +5. stable、preview、dev 渠道选择。 +6. 版本列表。 +7. 设置最新版本。 +8. 删除版本和云端文件。 +9. 版本策略读取和保存。 +10. 升级日志。 +11. 调试输出。 +12. 发布文件预览、大小和状态提示。 +13. 页面美化和响应式布局。 + + +2.16 一次性短期启动票据 + +已实现: + +1. Launcher 和 Updater 启动 MainApp 前生成临时 ticket 文件。 +2. 票据包含 app_id、device_id、version、nonce、issued_at 和 expires_at。 +3. 使用本机 launch_token 对票据执行 HMAC-SHA256 签名。 +4. 票据有效期为 60 秒。 +5. MainApp 先原子改名抢占票据,再读取验证并立即删除。 +6. 同一票据路径只能被一个 MainApp 进程消费。 +7. 票据绑定应用、设备和当前版本。 +8. 固定 --launcher-token 启动方式已移除。 +9. 该启动协议定义为客户端协议 P3,P3 不允许降级到 P2/P1。 + +2.17 MainApp 启动完整性准入 + +已实现: + +1. MainApp 每次启动时加载当前版本的 Manifest 缓存。 +2. 使用 config/manifest_public_key.pem 重新验证 Manifest RSA-SHA256 签名。 +3. 仅信任签名覆盖的 manifest_text,并校验 app_id、channel 和 version。 +4. 校验 Manifest 声明的所有受控文件是否存在且 SHA-256 一致。 +5. 递归扫描安装目录,拒绝 Manifest 未声明的额外 EXE/DLL,包括未授权插件。 +6. 配置、本地状态、策略、设备身份和 Bootstrap 等运行时受保护文件采用独立规则,不与版本 Hash 混用。 +7. 完整性检查位于启动健康确认之前;更新后校验失败不会写入健康标记,Updater 可触发自动回滚。 + +2.18 身份、授权、渠道、离线包和日志能力 + +已实现: + +1. 设备身份签发:/api/v1/device/issue 根据 license_key、installation_id 和 machine_hash 签发 device_id。 +2. 客户端身份凭证:client_identity.dat 持久化,服务端使用 RSA-SHA256 签名,客户端请求携带 X-Device-Credential。 +3. 服务端逐请求验证设备凭证、授权状态、设备禁用状态、app_id 和 channel。 +4. License 授权:licenses 和 license_devices 表,支持有效期、启用/禁用、最大设备数和设备占用。 +5. 授权密钥只保存 SHA-256,明文只在创建时返回一次。 +6. 动态渠道:channels 表按 app_id 隔离,管理后台支持新增、编辑、启用和停用。 +7. 发布、策略、授权、设备登记和更新检查统一校验渠道。 +8. 离线更新包:管理后台可生成 MUPD0001/.upd 包,包内包含签名 Manifest、包头签名、文件偏移、大小和 SHA-256。 +9. Launcher/Updater 支持导入离线包,离线安装复用现有事务、Bootstrap、校验、健康确认和回滚机制。 +10. 下载日志:记录下载授权、客户端完成结果、文件大小、IP、User-Agent 和时间。 +11. 管理员审计日志:记录 /admin 写操作、管理员令牌指纹、路径、结果、状态码、IP 和 User-Agent。 + +2.19 SimCAE Crash Report 后端第一阶段 + +已实现: + +1. GET /api/v1/health,返回 simcae-crash-server 健康状态。 +2. POST /api/v1/crash-reports,接收 multipart/form-data 崩溃报告。 +3. Bearer Token 鉴权,崩溃上传 token、符号上传 token、管理 token 分权。 +4. Idempotency-Key 与 metadata.clientReportId 绑定。 +5. 同一 clientReportId 首次上传返回 201 和 duplicate=false。 +6. 同一 clientReportId 重复上传且内容一致返回 200 和 duplicate=true。 +7. 同一 clientReportId 但内容不一致返回 409 idempotency_conflict。 +8. 校验 metadata JSON 必填字段、schemaVersion、product 和 clientReportId UUID。 +9. 校验 metadata.files 中声明的 minidump SHA-256。 +10. 保存 metadata.json、crash.dmp、可选 attachments.zip 和 server.json。 +11. crash_reports 表记录 report_id、client_report_id、版本、gitCommit、异常码、收包时间、文件 Hash、文件大小和存储路径。 +12. GET /api/v1/crash-reports/{reportId} 可用管理 token 查询报告状态。 +13. GET /api/v1/crash-reports/{reportId}/files/{fileName} 可用管理 token 下载私有原始文件,并记录访问审计。 +14. POST /api/v1/symbols 接收 CI/发布流程上传的 metadata.json 和 symbols.zip。 +15. crash_symbol_uploads 表按 product、appVersion、gitCommit、buildType、platform 索引符号包。 +16. 已配置 CRASH_STORAGE_ROOT,Docker 部署时落到 /data/crash_storage 持久化目录。 + +说明:第一阶段只做“可靠接收、校验、保存、索引和查询”。自动符号化、聚合统计、告警、问题分派和专门后台页面属于第二阶段。 + +============================================================ +三、部分实现的功能 +============================================================ + +3.1 启动票据 + +已完成并移入“2.16 一次性短期启动票据”。 + +3.2 MainApp 启动完整性检查 + +已完成并移入“2.17 MainApp 启动完整性准入”。当 MainApp.exe 位于 Manifest 内时,其自身也会参与 Hash 验证。 + +3.3 回滚完整性 + +文件恢复和版本号恢复已经实现,但仍缺少: + +1. 回滚完成后加载旧 Manifest 并进行完整 Hash 校验。 +2. 更详细的逐文件回滚错误。 +3. 回滚失败后的修复安装入口。 +4. 完整断电、杀进程、文件占用测试。 + +3.4 Manifest 安全字段 + +RSA 签名已经实现,但仍缺少: + +1. signature_alg 字段。 +2. key_id 字段。 +3. 当前和上一公钥同时内置。 +4. 公钥轮换流程。 +5. 密钥吊销机制。 + +3.5 升级日志 + +当前只记录 device_id、旧版本、新版本、success/fail 和时间。 + +尚缺少: + +1. app_id 和 channel。 +2. transaction_id。 +3. error_code。 +4. 失败阶段和失败文件。 +5. 下载字节数和耗时。 +6. 回滚结果。 +7. 操作系统、架构和客户端 IP。 + +3.6 REST API 契约 + +当前更新检查主要接收 app_id、current_version 和 channel。 + +需求文档中的以下字段尚未进入完整闭环: + +1. client_id。 +2. device_id。 +3. license_id。 +4. platform。 +5. arch。 +6. operation。 +7. target_version。 + +3.7 管理员登录 + +当前采用单管理员 Token,服务端用 .env 中的 ADMIN_TOKEN 校验,并且已经记录 /admin 写操作审计。 + +尚缺少: + +1. admin_users 表。 +2. 正式登录接口。 +3. Token 过期时间。 +4. 多管理员和角色权限。 +5. 登录失败审计。 +6. 会话吊销和 Token 轮换流程。 + +3.8 数据库结构 + +已有 apps、versions、version_files、version_policies、upgrade_logs、update_report、devices、licenses、license_devices、channels、download_logs、admin_audit_logs、crash_reports、crash_symbol_uploads、crash_file_access_logs。 + +部分字段仍未达到完整产品化设计,例如: + +1. versions 缺少 status、allow_rollback、rollback_targets、描述和发布时间等字段。 +2. version_files 缺少 storage_key、file_type、is_required。 +3. apps 字段较少。 +4. 缺少较完整的外键、索引和约束。 +5. Crash Report 还缺少符号化结果、聚合状态、负责人和处理备注等二阶段字段。 + +3.9 精确降级控制 + +已有允许/禁止降级,但仍缺少: + +1. rollback_targets 目标白名单。 +2. 数据格式兼容性规则。 +3. 主动输入目标版本。 +4. 不同版本间的允许降级关系(当前仅有协议版本兼容门槛)。 +5. “允许用户降级”和“管理员强制回退”的独立策略。 + +3.10 Crash Report 第二阶段能力 + +第一阶段上传、保存、去重和符号包接收已经完成,但仍缺少: + +1. 自动符号化,把 crash.dmp 转成可读调用栈。 +2. 根据 appVersion、gitCommit、buildType 自动匹配 symbols.zip。 +3. 崩溃报告列表、搜索、下载和统计后台页面。 +4. 按异常码、版本、GPU、命令、项目等维度聚合。 +5. 保留期清理,例如 90 天或 180 天。 +6. 上传限流和异常峰值告警。 +7. 生产环境 HTTPS、对象存储私有权限和访问审计完善。 + +============================================================ +四、核心缺口和当前状态 +============================================================ + +4.3 设备身份和激活 + +已实现,待统一联调验收: + +1. 服务端 devices 表及 installation_id 唯一登记。 +2. /api/v1/device/issue 首次签发和凭证恢复。 +3. client_identity.dat 持久化。 +4. RSA-SHA256 设备凭证签名及客户端本地验签。 +5. 客户端请求自动携带 X-Device-Credential。 +6. 服务端逐请求验签、查设备记录、凭证序列和禁用状态。 +7. app_id、device_id 与请求内容绑定。 +8. 管理后台设备列表及禁用/恢复操作。 +9. 机器特征只上传 SHA-256,不上传原始机器标识。 + +共享 client_token 目前仅作为客户端安装介质的首次登记门槛;正式更新接口还必须通过设备凭证验证。 + +4.4 License 授权系统 + +已实现,待统一联调验收: + +1. licenses 和 license_devices 数据表。 +2. 授权密钥只保存 SHA-256,明文仅在创建时返回一次。 +3. 授权绑定 app_id 和 channel。 +4. 授权有效期和 active/disabled 状态。 +5. max_devices 最大设备数及原子名额占用。 +6. 设备与 license_id 绑定,阻止安装实例静默切换授权。 +7. 设备身份凭证包含 license_id、channel 和 valid_until,并由 RSA-SHA256 签名。 +8. MainApp 离线启动时验证签名授权凭证及有效期。 +9. 服务端每次在线请求实时检查授权状态、期限、应用和渠道。 +10. HTTP 401/403 授权拒绝不再被客户端误判为离线模式。 +11. 管理后台支持创建、列表、设备占用量和启用/禁用授权。 + +尚未实现 HTTPS;正式环境中 license_key 登记请求必须通过 HTTPS 传输。 + +4.5 动态渠道管理 + +已实现,待统一联调验收: + +1. channels 表,渠道按 app_id 隔离。 +2. 渠道代码、显示名称、启用状态和排序。 +3. 管理后台新增、编辑、启用和停用渠道。 +4. 发布、版本策略和 License 下拉框从渠道 API 动态加载。 +5. 新应用自动创建兼容用 stable、preview、dev 初始渠道,之后可自行修改或停用。 +6. 历史应用启动迁移时自动补建初始渠道。 +7. 服务端发布、策略保存、License 创建、设备登记和更新检查统一验证渠道存在且启用。 +8. 设备凭证与 app_id + channel 双重绑定,不能跨渠道请求 Manifest 或下载链接。 +9. 客户端 channel 为普通渠道代码,不再限制为固定枚举。 + +渠道采用停用而非物理删除,避免破坏历史版本、授权、设备和日志的引用关系。 + +4.6 离线更新包 + +已实现,待统一联调验收: + +1. 自描述 MUPD0001/.upd 二进制容器。 +2. 管理后台按已发布版本流式生成和下载离线包。 +3. 包内复用在线版本的全量 Manifest 和 RSA-SHA256 签名。 +4. 包头包含应用、渠道、版本、Manifest 摘要、文件路径、偏移、大小和 Hash,并独立执行 RSA-SHA256 签名。 +5. 服务端从 MinIO 或本地回退存储流式输出,不生成同体积临时副本。 +6. Launcher 在服务器不可用时提供离线包选择,也支持 --import-offline 主动导入。 +7. Updater 支持 --offline-package,先验包签名、Manifest 签名、身份、路径、边界和 Hash。 +8. 文件逐块提取到现有事务 staging,不一次性加载整个包。 +9. 离线安装复用 backup、Bootstrap、安装后全量校验、健康确认和自动回滚。 +10. Bootstrap 恢复阶段读取本地签名 Manifest 缓存,不依赖网络。 +11. 离线导入遵守本地签名策略的有效期、offline_allowed、禁用版本和降级许可。 +12. 离线包生成属于可审计的后台 POST 操作。 + +当前容器不压缩文件,优先保证无需额外 Qt ZIP 依赖、可流式处理和格式可验证;后续可以在保持签名格式兼容的前提下增加逐文件压缩算法字段。 + +4.7 插件白名单 + +已通过签名 Manifest 实现 DLL/EXE Hash 白名单和未知 DLL/EXE 拒绝。尚未实现插件接口版本检查、插件独立签名和 Windows 发布者验证。 + +4.8 服务端限流 + +尚未实现更新检查限流、下载链接限流、完整包下载次数限制、每日字节额度、管理 API 限流以及 HTTP 429/RATE_LIMITED。 + +4.9 下载日志 + +已实现,待统一联调验收: + +1. download_logs 表及设备、License、应用、渠道、版本、文件、字节数、结果、IP、User-Agent 和时间字段。 +2. 下载链接签发时记录 authorized。 +3. Updater 下载并完成 SHA-256 校验后逐文件批量上报 success。 +4. 下载失败时逐文件批量上报 fail。 +5. 下载日志查询 API 和管理后台列表。 + +由于文件由 MinIO 预签名 URL 直传,服务端授权记录与客户端完成记录分开保存,避免把“获得 URL”误判为“下载成功”。 + +4.10 管理员审计日志 + +已实现,待统一联调验收: + +1. admin_audit_logs 表。 +2. 统一中间件记录所有 /admin 写操作。 +3. 记录管理员令牌截断 Hash 指纹,不保存令牌明文。 +4. 记录操作路径、HTTP 方法、查询目标、成功/失败、状态码、IP、User-Agent 和时间。 +5. 鉴权失败及业务失败同样进入审计。 +6. 审计日志查询 API 和管理后台列表。 + +审计中间件不保存请求正文,避免 License 密钥、新管理员令牌等敏感内容进入日志。 + +4.11 代码签名 + +尚未实现 Windows Authenticode、发布者验证和 EXE/DLL 代码签名检查。 + +4.12 灰度发布 + +尚未实现按设备、客户、地区、百分比或批次灰度,以及失败率自动停止。 + +4.13 多平台 + +目前只支持 Windows x64,尚未支持 Windows ARM64、Linux、macOS 和多平台 Manifest 分流。 + +============================================================ +五、需求文档 Demo 里程碑状态 +============================================================ + +第一阶段:基础框架——基本完成。 +缺口:Demo 插件尚未真正通过 QLibrary/接口协议加载。 + +第二阶段:服务端基础能力——基本完成。 +已补齐 FastAPI、SQLite、MinIO、本地回退、管理页、动态渠道、设备身份和 License 基础能力。 + +第三阶段:在线更新——基本完成。 +正常升级已实际测试成功,差异下载、断点续传、事务、Bootstrap 替换、健康确认和自动回滚都已进入代码闭环。 + +第四阶段:版本策略——大部分完成。 +已实现签名策略、policy_seq、防回滚、强制升级、禁用版本、最低版本、离线有效期和受控降级;缺口是 rollback_targets、数据兼容性规则和强制回退独立策略。 + +第五阶段:离线能力——基本完成,待更多实机验收。 +已经支持离线运行、策略有效期、系统时间回拨检测和离线更新包导入。 + +第六阶段:可靠性与安全——大部分完成,仍需生产化加固。 +已经支持事务回滚、一次性启动票据、policy_seq、时间回拨检测、启动完整性准入、EXE/DLL Hash 白名单、下载日志和管理员审计;尚未实现服务端限流、Windows 代码签名、正式管理员角色体系和完整故障矩阵测试。 + +SimCAE Crash Report 第一阶段——已实现。 +已支持崩溃包上传、幂等去重、Hash 校验、原始文件私有保存、报告查询、原始文件下载审计和符号包上传;缺口是自动符号化、统计聚合、专门后台页面、保留期清理和生产 HTTPS/限流。 + +============================================================ +六、需求文档验收用例状态 +============================================================ + +1. 通过 Launcher 正常启动 MainApp:已实现。 +2. 直接启动 MainApp 被拒绝:已实现,并采用 60 秒一次性 HMAC 启动票据。 +3. MainApp 显示主程序和 DLL 版本:已实现简化版。 +4. 后台发布版本:已实现。 +5. 客户端在线升级:已实现并实测。 +6. 升级后 DLL 变化:支持。 +7. 升级后 MainApp 自动重启:已实现。 +8. 手动篡改 DLL 后启动失败:已实现,待 Windows 实机验收。 +9. 手动篡改 version_policy 后启动失败:已实现 RSA 验签。 +10. 强制升级:已实现,待最终客户端测试。 +11. 禁用版本:已实现,待最终客户端测试。 +12. preview 客户端获取 preview 更新:基础支持,待测试。 +13. 禁止降级:已实现并进行过 API 测试。 +14. 离线凭证未过期时启动:已实现,待测试。 +15. 离线凭证过期后拒绝启动:已实现,待测试。 +16. 模拟升级失败后成功回滚:代码已实现,待完整故障测试。 + +============================================================ +七、推荐后续开发顺序 +============================================================ + +建议依次推进: + +1. 做一轮完整 Windows 实机回归:成功升级、强制升级、版本禁用、受控降级、离线启动、离线包导入、断点续传、文件占用、杀进程、断电模拟、回滚和策略篡改。 +2. 接入 SimCAECrashReporter.exe,按文档上传 metadata.json、crash.dmp 和 attachments.zip。 +3. 为 Crash Report 增加管理后台页面:列表、详情、下载 metadata/dmp/attachments、符号包状态。 +4. 实现 Crash Report 自动符号化:按 product/appVersion/gitCommit/buildType/platform 匹配 symbols.zip,生成调用栈。 +5. 补服务端限流:更新检查、下载授权、崩溃上传、符号上传、管理接口都需要 429 保护。 +6. 补生产化安全:HTTPS、CORS 白名单、管理员用户/角色/会话、Token 轮换、私有对象存储权限。 +7. 补 Windows 代码签名和发布者验证,避免只依赖文件 Hash。 +8. 补插件接口版本准入:插件不仅要在 Manifest 白名单里,还要声明 ABI/API 版本并由 MainApp 校验。 +9. 补 rollback_targets 精确降级目标和数据兼容性规则。 +10. 再做灰度发布、多平台 Manifest 分流、失败率自动停止和告警。 + +当前下一项最适合做的主功能:SimCAE Crash Reporter 客户端联调 + Crash Report 管理后台列表。这样可以最快证明新文档接口真的能被客户端使用。 + +============================================================ +八、这个项目到底是什么 +============================================================ + +一句话说明: + +这是一个给 Windows 桌面软件使用的“安全自动升级 + 版本发布管理 + 崩溃报告接收”系统。 + +它不是单纯下载一个新 exe 覆盖旧 exe,而是一整套发布、校验、授权、回滚和审计链路。目标是让客户端软件可以安全地在线升级、离线升级、禁止不合规版本运行,并在崩溃后把原始 dump 和环境信息传回服务器。 + +8.1 服务端是什么 + +服务端在 server/ 下,核心是 FastAPI 应用。它负责: + +1. 管理应用、版本、渠道和策略。 +2. 接收管理员发布的软件文件。 +3. 把文件存到 MinIO 或本地回退目录。 +4. 为每个版本生成 Manifest。 +5. 对 Manifest、策略、设备身份、离线包做 RSA 签名。 +6. 给客户端签发临时下载 URL。 +7. 接收升级结果、下载结果、设备登记和 License 授权。 +8. 提供 admin.html 管理网页。 +9. 接收 SimCAE 崩溃报告和符号包。 + +服务端主要存储有三类: + +1. SQLite:保存结构化数据,例如 apps、versions、version_files、devices、licenses、crash_reports。 +2. MinIO/本地回退目录:保存自动升级发布文件。 +3. crash_storage:保存崩溃报告原始文件,例如 metadata.json、crash.dmp、attachments.zip、symbols.zip。 + +8.2 客户端是什么 + +客户端在 client/ 下,主要由几个程序配合: + +1. Launcher.exe:入口程序。负责检查更新、验证策略、决定是否启动 MainApp 或 Updater。 +2. Updater.exe:真正下载和安装文件的程序。负责 Manifest 验证、差异下载、断点续传、备份、校验、提交或回滚。 +3. Bootstrap.exe:原生小程序。负责替换正在被占用的 EXE/DLL,尤其是 Updater 自己无法替换自己时。 +4. MainApp.exe:业务主程序。启动时验证一次性启动票据和当前安装目录完整性,健康后写标记给 Updater。 +5. Common:客户端公共工具代码,例如配置、HTTP、文件 Hash、策略验签、设备身份、启动票据等。 + +未来 SimCAE 崩溃采集还有三个程序角色: + +1. SimCAE.exe:业务主程序,初始化崩溃采集并写入元数据。 +2. crashpad_handler.exe 或 SimCAECrashHandler.exe:崩溃后抓 minidump。 +3. SimCAECrashReporter.exe:询问用户是否上传,并调用 /api/v1/crash-reports。 + +8.3 一次在线升级是怎么发生的 + +典型流程如下: + +1. 用户启动 Launcher。 +2. Launcher 读取 app_config.json、local_state.json 和设备身份凭证。 +3. Launcher 请求 /api/v1/update/check。 +4. 服务端根据 app_id、channel、current_version、设备身份、License 和策略判断是否有更新。 +5. 服务端返回目标版本和签名策略。 +6. Launcher 用内置公钥验证策略签名。 +7. 如果需要更新,Launcher 启动 Updater。 +8. Updater 请求 /api/v1/update/manifest。 +9. 服务端返回签名 Manifest。 +10. Updater 验证 Manifest 签名。 +11. Updater 对比本地文件 SHA-256,只下载缺失或变化的文件。 +12. Updater 请求 /api/v1/update/download-url 获取临时下载地址。 +13. Updater 下载文件到 staging,并校验大小和 SHA-256。 +14. Updater 备份旧文件。 +15. Bootstrap 接管替换文件。 +16. Updater 进行安装后完整校验。 +17. Updater 启动 MainApp 并等待健康标记。 +18. MainApp 验证启动票据和安装目录完整性,成功后写健康标记。 +19. Updater 提交事务,写入 current_version。 +20. Updater 上报升级结果。 + +任何关键步骤失败,都尽量回滚到旧版本。 + +8.4 一次崩溃上报是怎么发生的 + +典型流程如下: + +1. SimCAE 崩溃后,本地崩溃处理进程生成 crash.dmp。 +2. SimCAECrashReporter.exe 准备 metadata.json,里面有版本、gitCommit、系统、异常码、用户备注、文件 Hash 等。 +3. Reporter 用 multipart/form-data 请求 POST /api/v1/crash-reports。 +4. 请求头带 Authorization: Bearer 。 +5. 请求头带 Idempotency-Key,值等于 metadata.clientReportId。 +6. 服务端校验 token、metadata、clientReportId、minidump Hash。 +7. 服务端保存 metadata.json、crash.dmp、attachments.zip 和 server.json。 +8. 服务端写入 crash_reports 表。 +9. 服务端返回 reportId。 +10. 如果客户端超时重试,同一个 clientReportId 且内容一致会返回原来的 reportId,不会重复保存。 + +CI 或发布脚本还可以调用 POST /api/v1/symbols 上传 PDB/EXE/DLL 符号包,为后续自动符号化做准备。 + +============================================================ +九、核心概念解释:Manifest、Hash、签名等 +============================================================ + +9.1 Manifest 是什么 + +Manifest 可以理解成“这个版本应该长什么样”的文件清单。 + +在本项目里,一个版本的 Manifest 通常包含: + +1. app_id:哪个软件。 +2. version:哪个版本。 +3. channel:哪个渠道。 +4. platform 和 arch:适用平台,例如 Windows x64。 +5. manifest_seq:Manifest 序列。 +6. created_at:生成时间。 +7. files:文件列表。 +8. 每个文件的相对路径、大小、SHA-256 和 executable 标记。 +9. signature:服务端 RSA 签名。 + +它的作用: + +1. Updater 用它知道要下载哪些文件。 +2. Updater 用它知道哪些文件没变,可以跳过下载。 +3. Updater 用它校验下载文件有没有损坏或被替换。 +4. MainApp 启动时用它校验安装目录是否被篡改。 +5. 离线包里也带 Manifest,保证离线安装和在线安装使用同一套可信清单。 + +9.2 Hash 是什么 + +Hash 是文件内容的指纹。本项目主要使用 SHA-256。 + +同一个文件内容算出来的 SHA-256 一定相同;只要文件改了一个字节,SHA-256 基本就会完全变掉。 + +在本项目里,Hash 的用法很多: + +1. 发布版本时,服务端计算每个文件的 SHA-256,写入 version_files 和 Manifest。 +2. Updater 下载文件后重新计算 SHA-256,和 Manifest 对比。 +3. 如果本地文件 SHA-256 已经等于 Manifest 中的值,就跳过下载。 +4. MainApp 启动时计算 EXE/DLL 的 SHA-256,发现不在 Manifest 或 Hash 不一致就拒绝启动。 +5. 离线包提取文件时也按 SHA-256 校验。 +6. Crash Report 上传时,metadata.files 里声明 crash.dmp 的 SHA-256,服务端收到后重新计算并比对。 +7. License key 和管理员 token 不保存明文,只保存 SHA-256 或指纹。 + +注意:Hash 只能证明“内容是否一致”,不能证明“这个 Hash 是可信的”。如果攻击者能同时改文件和 Hash,单靠 Hash 就不够。所以还需要 RSA 签名。 + +9.3 RSA 签名是什么 + +RSA 签名可以理解成服务端给某段关键内容盖章。 + +本项目里,服务端持有私钥,客户端内置公钥。服务端用私钥签名,客户端用公钥验证。客户端没有私钥,所以伪造不了签名。 + +本项目签名覆盖的内容包括: + +1. Manifest。 +2. version_policy。 +3. client_identity。 +4. 离线更新包包头。 + +为什么需要签名: + +1. 防止 Manifest 被篡改。 +2. 防止策略被篡改,例如把“禁止离线启动”改成“允许”。 +3. 防止旧策略被随便替换回来。 +4. 防止伪造设备身份凭证。 +5. 防止离线包被人重打包。 + +Hash 和签名的关系: + +1. Hash 负责校验文件内容。 +2. 签名负责校验“这份清单/策略/身份是不是服务端认可的”。 +3. 两者组合起来,才能做到既完整又可信。 + +9.4 version_policy 是什么 + +version_policy 是服务端下发给 Launcher 的运行策略。 + +它包含: + +1. 是否强制升级。 +2. 是否允许降级。 +3. 是否允许离线启动。 +4. 策略有效期。 +5. 最低支持版本。 +6. 禁用版本列表。 +7. policy_seq。 +8. 用户提示信息。 + +policy_seq 是策略序列号。每次管理员保存策略,序列号递增。客户端会记录见过的最大 policy_seq,拒绝更旧的策略,防止有人把老策略文件放回来。 + +9.5 启动票据是什么 + +启动票据是 Launcher 或 Updater 启动 MainApp 前临时生成的一次性凭证。 + +它解决的问题是:不允许用户绕过 Launcher 直接双击 MainApp。 + +票据里有 app_id、device_id、version、nonce、issued_at、expires_at,并用本机 launch_token 做 HMAC-SHA256 签名。MainApp 启动后必须抢占、验证并删除票据,验证失败就退出。 + +9.6 事务、staging、backup 和 Bootstrap 是什么 + +更新不能简单地“边下载边覆盖”,否则中途断电或文件占用会把软件弄坏。 + +所以项目采用事务式更新: + +1. staging:新文件先下载到临时安装区。 +2. backup:替换前备份旧文件。 +3. upgrade_state.json:记录当前更新走到哪一步。 +4. Bootstrap:负责真正替换正在被占用的 EXE/DLL。 +5. post_verify:替换后全量校验。 +6. commit:MainApp 健康后才提交新版本。 +7. rollback:失败时按 backup 恢复旧版本。 + +这样即使 Updater 中途退出,下次启动也能根据 upgrade_state.json 继续完成或回滚。 + +9.7 预签名 URL 是什么 + +服务端不直接把所有大文件通过 FastAPI 返回给客户端,而是把文件放在 MinIO。客户端真正下载文件时,先向服务端请求 /api/v1/update/download-url。 + +服务端检查设备身份、License、版本和文件列表后,返回一组临时有效的下载 URL。 + +这样做的好处: + +1. 大文件传输交给对象存储。 +2. URL 有时效,不是永久公开链接。 +3. 服务端可以在签发 URL 时记录下载授权日志。 +4. 客户端下载完成后再上报 success/fail。 + +9.8 clientReportId 和 Idempotency-Key 是什么 + +这是崩溃报告上传里的防重复机制。 + +客户端每个崩溃报告生成一个 UUID,写入 metadata.clientReportId,同时放到 HTTP 头 Idempotency-Key。 + +服务端规则: + +1. 第一次看到这个 ID:保存报告,返回新的 reportId。 +2. 再次看到这个 ID 且内容一致:返回原来的 reportId,duplicate=true。 +3. 再次看到这个 ID 但内容不一致:返回 409。 + +这样客户端上传超时后可以放心重试,不会制造一堆重复报告。 + +9.9 symbols.zip 是什么 + +Windows 崩溃 dump 只有地址还不够,人很难直接看懂。要把地址转成函数名、文件名、行号,就需要发布版本对应的 PDB/EXE/DLL 符号文件。 + +symbols.zip 就是 CI 或发布流程上传的符号包。它按 product、appVersion、gitCommit、buildType、platform 保存。后续自动符号化时,服务端会用 crash report 里的版本信息找到对应符号包,再把 crash.dmp 转成人能读的调用栈。 + +9.10 Token、License 和设备身份的区别 + +这几个东西容易混: + +1. CLIENT_API_TOKEN:客户端安装包自带的公共门槛,用于首次设备登记等基础访问。 +2. license_key:客户授权密钥,证明这个客户/项目有权使用某个 app/channel,并限制设备数和有效期。 +3. device_id/client_identity.dat:服务端给某台安装实例签发的设备身份,之后每次更新请求都要带。 +4. X-Admin-Token:管理后台请求头里传的管理员令牌;当前服务端用 .env 里的 ADMIN_TOKEN 校验。 +5. CRASH_REPORT_TOKEN:SimCAECrashReporter.exe 上传崩溃报告用的 Bearer Token。 +6. CRASH_SYMBOL_TOKEN:CI 上传 symbols.zip 用的 Bearer Token。 +7. CRASH_ADMIN_TOKEN:查询和下载崩溃原始文件用;如果不配置,就复用当前管理员令牌。 + +简单理解: + +1. Token 是“能不能调用某类接口”。 +2. License 是“这个客户有没有授权”。 +3. 设备身份是“这台机器是不是已经被服务端登记过”。 +4. Manifest + Hash + RSA 签名是“这个版本的文件是不是完整且可信”。 + +============================================================ +十、SDK 和 Docker:给其他软件接入时怎么理解 +============================================================ + +10.1 什么是 SDK + +SDK 是 Software Development Kit,中文通常叫“软件开发工具包”。 + +它不是单个 exe,也不只是源码,而是一套让别人能把你的能力接到自己软件里的交付包。一个合格 SDK 通常包含: + +1. 可直接使用的二进制文件,例如 Launcher.exe、Updater.exe、Bootstrap.exe。 +2. 必要的运行时 DLL,例如 Qt、OpenSSL、平台插件等。 +3. 配置模板,例如 app_config.example.json。 +4. 接入文档,例如如何配置 app_id、channel、api_base_url、license_key。 +5. 打包脚本,例如 package-client.ps1。 +6. 示例项目或 Demo。 +7. API/命令行约定,例如主程序必须由 Launcher 启动,MainApp 需要写健康标记。 +8. 服务端接口说明,例如 /api/v1/update/check、/api/v1/crash-reports。 + +所以老板说“客户端打包成 SDK 给其他软件用”,在本项目里可以理解为: + +把自动升级客户端能力整理成一个可复用接入包。其他软件只要按说明放入自己的主程序和配置,就可以复用我们的 Launcher、Updater、Bootstrap、Manifest 校验、差异更新、回滚、离线包和设备授权能力。 + +10.2 当前项目更接近哪种 SDK + +目前客户端还不是传统意义上的“给别人 include 一个头文件、link 一个 lib”的 SDK。 + +当前更接近“独立更新器 SDK”或“外置更新壳 SDK”: + +1. 其他软件仍然有自己的主程序,例如 SimCAE.exe 或 MyApp.exe。 +2. 我们提供 Launcher.exe、Updater.exe、Bootstrap.exe 和配置模板。 +3. 用户以后从 Launcher.exe 启动软件,而不是直接双击业务主程序。 +4. Launcher 检查更新和策略,必要时启动 Updater。 +5. Updater 下载并替换业务软件目录里的文件。 +6. MainApp/业务主程序需要配合健康标记、启动票据和完整性检查规则。 + +也就是说,它不是“库 SDK”,而是“升级运行时 SDK”。 + +如果以后要做成更标准的 C++ SDK,可以再拆出: + +1. UpdateClientCore.lib / dll:封装检查更新、下载、校验、上报。 +2. CrashReporterSDK.lib / dll:封装崩溃上报、metadata 生成、附件打包。 +3. include/*.h:给接入方调用的头文件。 +4. samples/:最小接入例子。 + +这属于下一阶段产品化封装,不影响当前先交付“独立更新器 SDK”。 + +10.3 当前客户端 SDK 建议交付目录 + +建议最终交付类似下面的目录: + +UpdateClientSDK/ + README.md + docs/ + 接入说明.md + 服务端接口说明.md + 常见错误.md + bin/ + Launcher.exe + Updater.exe + Bootstrap.exe + Qt5Core.dll + Qt5Gui.dll + Qt5Widgets.dll + Qt5Network.dll + platforms/qwindows.dll + openssl 相关 DLL + config/ + app_config.example.json + manifest_public_key.pem + scripts/ + package-client.ps1 + samples/ + minimal_app/ + MainApp.exe 或示例源码 + app_config.json 示例 + +对接方真正拿到后,通常只需要做这些事: + +1. 把自己的主程序和依赖 DLL 放到发布目录。 +2. 设置 app_config.json:server 地址、app_id、channel、当前版本、license_key、主程序名等。 +3. 以后让用户启动 Launcher.exe。 +4. 在管理后台创建应用、License、渠道和发布版本。 +5. 发布新版本时选择干净的 Release 输出目录。 + +10.4 当前已有 package-client.ps1 的作用 + +client/package-client.ps1 已经是 SDK/客户端包雏形。 + +它目前会做这些事: + +1. 从 out/bin 收集已编译好的客户端文件。 +2. 检查必填配置,例如 app_id、channel、api_base_url、current_version、client_token、launch_token、license_key、主程序名等。 +3. 检查 Launcher、Updater、Bootstrap、MainApp 是否存在。 +4. 拒绝 PDB、ILK、Debug Qt DLL 等调试产物进入发布包。 +5. 检查主程序是否被嵌套放错目录。 +6. 拷贝 app_config.json 到 config/。 +7. 删除 client_identity.dat、local_state.json、version_policy.dat 等运行时状态,避免把某台机器的身份带给别人。 +8. 拷贝当前版本签名 Manifest 缓存。 +9. 输出 UpdateClient.zip。 + +它现在更像“打最终客户端安装包”的脚本。要升级成真正 SDK,可以在它外面再包一层目录结构和说明文档。 + +10.5 一个实际接入例子 + +假设有一个第三方软件叫 MyCAD.exe,想使用我们的自动升级能力。 + +接入方式可以是: + +1. 在服务端管理后台创建 app_id=mycad。 +2. 创建渠道 stable。 +3. 创建 License,把 license_key 给 MyCAD 项目。 +4. MyCAD 的发布目录里包含: + - MyCAD.exe + - MyCAD 需要的 DLL 和资源 + - Launcher.exe + - Updater.exe + - Bootstrap.exe + - config/app_config.json + - config/manifest_public_key.pem +5. app_config.json 里写: + - app_id=mycad + - channel=stable + - main_executable=MyCAD.exe + - api_base_url=http://服务器IP:8000 + - license_key=服务端生成的授权密钥 +6. 用户桌面快捷方式指向 Launcher.exe。 +7. 管理员以后在后台发布 MyCAD 的新版本。 +8. 用户启动 Launcher.exe 时自动检查、下载、替换并启动 MyCAD.exe。 + +这就是“把客户端升级能力作为 SDK 给其他软件使用”。 + +10.6 什么是 Docker + +Docker 可以理解为“把服务端运行环境打包成一个标准盒子”。 + +本机直接运行 server/venv/bin/python3 main.py 时,依赖的是当前机器上的 Python 虚拟环境、当前目录、当前配置文件。 + +Docker 运行时,依赖的是镜像里的 Python、镜像里的依赖、容器里的 /app 目录和 docker-compose.yml 映射进去的数据目录。 + +好处是: + +1. 换一台服务器也能按同样方式启动。 +2. 不怕目标机器 Python 包版本乱。 +3. 服务端、MinIO、初始化 bucket 可以一起编排。 +4. 数据目录可以明确挂载,方便备份和迁移。 +5. 出问题时可以通过 docker compose logs 查看服务日志。 + +10.7 当前服务端 Docker 已经具备什么 + +当前 server/Dockerfile 已经具备: + +1. 使用 python:3.12-slim 作为基础镜像。 +2. 安装 server/requirements.txt 里的 FastAPI、MinIO、cryptography 等依赖。 +3. 拷贝 main.py、db.py、minio_tool.py、tables.sql。 +4. 拷贝 client/admin.html 到容器内 /app/admin.html。 +5. 使用非 root 用户 updateapp 运行。 +6. 暴露 8000 端口。 +7. 提供 healthcheck。 + +当前 server/docker-compose.yml 已经具备: + +1. minio:对象存储,保存升级文件。 +2. minio-init:首次启动时创建 bucket。 +3. api:FastAPI 服务。 +4. /data 持久化目录,用来保存 mini.db、上传文件、crash_storage 等。 +5. /run/secrets/update-keys 只读挂载签名私钥。 +6. 8000 端口对外提供管理后台和 API。 +7. 9000 端口对外提供 MinIO 文件下载。 + +所以可以说:服务端已经具备 Docker 运行条件。 + +10.8 Docker 部署基本步骤 + +首次部署建议: + +1. 进入 server 目录。 +2. 准备 .env。 +3. 设置 ADMIN_TOKEN、CLIENT_API_TOKEN、MINIO_ACCESS_KEY、MINIO_SECRET_KEY、MINIO_BUCKET、MINIO_PUBLIC_ENDPOINT、CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN。 +4. 准备签名私钥 keys/manifest_private_key.pem。 +5. 准备 runtime、minio_data 等持久化目录。 +6. 执行 docker compose build。 +7. 执行 docker compose up -d。 +8. 用 docker compose ps 查看状态。 +9. 用 docker compose logs --tail=100 api 查看服务日志。 +10. 浏览器访问 http://服务器IP:8000/。 + +示例命令: + +cd server +cp -n .env.example .env +mkdir -p runtime minio_data keys +docker compose config +docker compose build +docker compose up -d +docker compose ps +docker compose logs --tail=100 api + +停止服务: + +docker compose down + +注意:不要随便执行 docker compose down -v,因为 -v 会删除 volume,可能导致数据库或对象存储数据丢失。 + +10.9 SDK 和 Docker 的关系 + +SDK 是给“客户端软件接入方”用的。 + +Docker 是给“服务端部署人员”用的。 + +两者不是一类东西: + +1. SDK 解决“别的软件怎么接入自动升级/崩溃上报”。 +2. Docker 解决“服务端怎么稳定部署和运行”。 + +一个完整交付可以这样分: + +1. server-deploy/:服务端 Docker 部署包,包含 Dockerfile、docker-compose.yml、.env.example、README。 +2. UpdateClientSDK/:客户端接入包,包含 Launcher、Updater、Bootstrap、配置模板、脚本和接入说明。 +3. CrashReporterSDK/:崩溃上报接入包,包含 Reporter、metadata 模板、上传协议说明和示例。 + +10.10 当前离“SDK 交付”还差什么 + +已经具备的条件: + +1. 客户端核心程序已经跑通。 +2. 服务端接口已经基本完整。 +3. 管理后台可以发布版本和管理策略。 +4. Docker 部署文件已经具备。 +5. package-client.ps1 已经能打客户端分发包。 + +还建议补齐: + +1. SDK README:接入方从零到跑起来的步骤。 +2. app_config.example.json:填写项解释和示例值。 +3. 最小 Demo:一个最小 MainApp 示例,证明 SDK 可以接入别的软件。 +4. 错误码文档:常见 HTTP 错误、客户端错误、升级失败阶段说明。 +5. SDK 打包脚本:输出 UpdateClientSDK.zip,而不只是 UpdateClient.zip。 +6. 版本命名规范:SDK 自己也要有版本号,例如 UpdateSDK-0.1.0。 +7. Docker 部署 README:生产环境如何配置 HTTPS、Token、MinIO 公网地址和备份。 +8. Crash Reporter 接入 Demo:演示 metadata.json、crash.dmp 和 attachments.zip 上传。 + +建议下一步做两个包: + +1. server/docker 部署包:给运维或服务器部署人员。 +2. client/UpdateClientSDK.zip:给其他软件开发人员。 + +10.11 现在具体怎么做 + +建议按下面顺序落地: + +第一步:确认服务端 Docker 包。 + +1. 进入 server 目录。 +2. 复制 .env.example 为 .env。 +3. 修改 ADMIN_TOKEN、CLIENT_API_TOKEN、MINIO_ACCESS_KEY、MINIO_SECRET_KEY、MINIO_BUCKET、MINIO_PUBLIC_ENDPOINT、CRASH_REPORT_TOKEN、CRASH_SYMBOL_TOKEN。 +4. 准备 keys/manifest_private_key.pem。 +5. 执行 docker compose config,确认配置无误。 +6. 执行 docker compose build。 +7. 执行 docker compose up -d。 +8. 浏览器打开 http://服务器IP:8000/。 +9. 在后台创建应用、渠道、License。 +10. 发布一个初始版本。 + +第二步:准备客户端 SDK 包。 + +1. 确认 client/out/bin 是 Release 输出目录。 +2. 确认 out/bin 里有 Launcher.exe、Updater.exe、Bootstrap.exe、Qt DLL、platforms/qwindows.dll。 +3. 确认 config/manifest_public_key.pem 是服务端私钥对应的公钥。 +4. 填好 client/config/app_config.example.json 里的示例字段。 +5. 在 Windows PowerShell 执行: + +cd client +.\package-sdk.ps1 -SourceDir .\out\bin -OutputDir .\dist\UpdateClientSDK -ZipFile .\dist\UpdateClientSDK.zip -SdkVersion 0.1.0 + +第三步:给接入方一个最小验证方式。 + +1. 解压 UpdateClientSDK.zip。 +2. 把接入方自己的 YourApp.exe 放到 bin 目录同级的最终产品目录中。 +3. 把 config/app_config.example.json 复制成 config/app_config.json。 +4. 修改 app_id、channel、api_base_url、client_token、license_key、main_executable。 +5. 从 Launcher.exe 启动。 +6. 确认能登记设备、拉取策略、启动业务主程序。 + +第四步:生成具体产品包。 + +SDK 是给开发者看的;具体产品给客户安装时,使用 package-client.ps1: + +cd client +.\package-client.ps1 -SourceDir .\out\bin -ConfigFile .\config\app_config.json -OutputDir .\dist\UpdateClient -ZipFile .\dist\UpdateClient.zip + +第五步:补接入方文档和 Demo。 + +1. 把 client/SDK_README.md 随 SDK 一起交付。 +2. 准备一个最小 MainApp 示例,演示 --ticket-file 和 --health-file。 +3. 准备一份常见错误说明。 +4. 准备一份服务端 Docker 部署说明。 +5. 最后再考虑拆出 C++ include/lib 形式的更传统 SDK。 +```