From 9e545cb32871351341870f321f07cf8fcecfaaf6 Mon Sep 17 00:00:00 2001 From: nikelaluo <2135665716@qq.com> Date: Tue, 7 Jul 2026 09:48:01 +0000 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BA=86=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=89=93=E5=8C=85=E6=88=90sdk=E7=9A=84=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=92=8C=E6=9C=8D=E5=8A=A1=E7=AB=AFdocker=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E6=89=93=E5=8C=85=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E4=BA=86=E4=B8=80=E4=BA=9B=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 8 + .gitignore | 65 +- client/Common/CMakeLists.txt | 68 +- client/Common/ConfigHelper.cpp | 7 + client/Common/DeviceIdentityHelper.cpp | 41 + client/Common/DeviceIdentityHelper.h | 15 + client/Common/HttpHelper.cpp | 100 +- client/Common/IntegrityHelper.cpp | 132 ++ client/Common/IntegrityHelper.h | 20 + client/Common/PolicyHelper.cpp | 1 + client/Common/PolicyHelper.h | 1 + client/Common/TicketHelper.cpp | 91 ++ client/Common/TicketHelper.h | 13 + client/Launcher/CMakeLists.txt | 4 +- client/Launcher/UpdateLogic.cpp | 3 + client/Launcher/main.cpp | 74 +- client/MainApp/CMakeLists.txt | 4 +- client/MainApp/main.cpp | 57 +- client/READMe.txt | 30 +- client/SDK_README.md | 129 ++ client/SimCAE_Crash_Report后端接口规范.docx | Bin 0 -> 41211 bytes client/SimCAE自动升级SDK接入说明_v0.1.docx | Bin 0 -> 25616 bytes client/Updater/CMakeLists.txt | 18 +- client/Updater/UpdaterLogic.cpp | 60 + client/Updater/UpdaterLogic.h | 4 + client/Updater/main.cpp | 151 +- client/admin.html | 599 +++++++- client/config/app_config.example.json | 25 +- client/config/local_state.json | 6 - client/config/version_policy.dat | 1 - client/install-sdk.ps1 | 41 + client/package-client.ps1 | 87 ++ client/package-sdk.ps1 | 86 ++ client/软件自动升级系统接入说明.docx | Bin 0 -> 5122 bytes client/项目进度.txt | 538 ------- server/.dockerignore | 8 + server/.env.docker.example | 69 + server/.env.example | 75 + server/Dockerfile | 27 + server/db.py | 14 +- server/docker-compose.image.yml | 72 + server/docker-compose.yml | 74 + server/main.py | 1025 ++++++++++++- server/minio_tool.py | 69 +- server/package-offline-server.ps1 | 95 ++ server/package-offline-server.sh | 292 ++++ server/requirements.txt | 6 + server/tables.sql | 122 ++ server/服务端Docker镜像交付说明.md | 196 +++ 项目总览.md | 1503 +++++++++++++++++++ 50 files changed, 5251 insertions(+), 875 deletions(-) create mode 100644 .dockerignore create mode 100644 client/Common/DeviceIdentityHelper.cpp create mode 100644 client/Common/DeviceIdentityHelper.h create mode 100644 client/Common/IntegrityHelper.cpp create mode 100644 client/Common/IntegrityHelper.h create mode 100644 client/Common/TicketHelper.cpp create mode 100644 client/Common/TicketHelper.h create mode 100644 client/SDK_README.md create mode 100644 client/SimCAE_Crash_Report后端接口规范.docx create mode 100644 client/SimCAE自动升级SDK接入说明_v0.1.docx delete mode 100644 client/config/local_state.json delete mode 100644 client/config/version_policy.dat create mode 100644 client/install-sdk.ps1 create mode 100644 client/package-client.ps1 create mode 100644 client/package-sdk.ps1 create mode 100644 client/软件自动升级系统接入说明.docx delete mode 100644 client/项目进度.txt create mode 100644 server/.dockerignore create mode 100644 server/.env.docker.example create mode 100644 server/.env.example create mode 100644 server/Dockerfile create mode 100644 server/docker-compose.image.yml create mode 100644 server/docker-compose.yml create mode 100644 server/package-offline-server.ps1 create mode 100755 server/package-offline-server.sh create mode 100644 server/requirements.txt create mode 100644 server/服务端Docker镜像交付说明.md create mode 100644 项目总览.md 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 0000000000000000000000000000000000000000..92ab6f5e4f08498e7bbeddd5820dad21af113b44 GIT binary patch literal 41211 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 Vp9~21gJlHk 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 DF}afP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7661a2a307c57cb733a058f9065997a2401477eb GIT binary patch literal 5122 zcmZ`-1yGb<-(9+6>Fz~h$t6?}B&E9>mQE2^I%TCLBwUd0WC5-=TfC0u%FvEY<>YU`%Q<=N0;(PNmXb+s&xGIa?DPjaImX? zep6a>)wb0={`u0NCna~u2A}Rl3C<46#%~hiiVnrWZ@Ip?T7T=0 znBnr5g8RPEpaB2`_p9X#J6A6O{=epg2?^qEK|<`+O8Mn-0u0$QC+f&)$}|l5m@GvR z6407Yps=7;Z>QtUr9;au6U<&bcF1qQ+I*&SP`NgKiebFRv+-q(n}$f78JUwXE3$r_ zEQ))(?wQ&PHeQ`gj22xrmk9pP`#SCOl6(7?@Og z?C0OOahgbO8N(J=#_jdtrcA4CN#7c637cA=N#O5P6nQQCgNXrv1w#M;cu&RGx##t;O=tX!=@FZpXX$Zr^$`|`du zXiC#|JmhK_P*lhTcLLg1`%a_=qQN%;kF#YiqaS|9hbf0l;&c!xhihjLMul5%f1sc_ z$KhzEA?&^?v~ck7z4YHM8~Nt7nuLK`b-O(hm#)D+ULkR}zFk+(Xr5Sm6%~UPMLvQ3 zi(fn*Al>q6)i$^A(oFnr-F5jH^LcRC;WbhSwQx4eB^^R58O(p2u<_=kY6pKf7w1Wh z)Bt6o1PfL%tMOITUdj$K2b$o9@@OX!9AGAWX|r0gbEQ{HslVC6^wkdHpC0X;#&{4@ zz)c^-Kfv~=?JBi)w5_b)NVGTQ%++ngXyG$HtJ~ur1&#&=AbEyHfkFe>ujI#RGyj?*ohgfg?JT&Gny1@3>T$Sep^O{7}-W8ih}SmXe)ugo-gAT zTk3ra${+k@-IP5$i(l0=m{G8WYYj89nvrn!H>jOS>cbfTr>$lgL&b@sobA%bY>ll= zLP^7W1UQt5htV=D!@$n|+T}?0711Uq)ReJd&^+&6! zpe7&YNPPi@hJ!MT1zpOZ4LcGeWL`9MY{Q>xbRc#7s zj5V}HIjo&67fsL4e)9Tcikz4wm>cpW0z1WcejHgUr0myS7zXOj1oS*c3|6-G%m=Fe z-RZG9T~6%AGEG@;#&`&VusAr2nEI}^*63rfp_l^elu;W}11-ykb00E(%lZ)veXV6_ zxoPAG<-U$cnWGHC3GEh7j8-W%-yw?}sS#q9Ck*F5N#`;OwZih-Cl$>zYf2?9d3fC^ zr5Ad{>^*cKPmms4+rEGb6|@yga3siI+;49ZS5-vR&iLD?@=YZFnm%o)N_kImZOBsp z>&uZxmR)00InT`womv{UrlKBHcdHEjZCefA`cejRG1+Q&KS#83NaJwmg?2Kvr ze3?(zt^!3SP}PI3N0~V3_5q zxMQx41M>-ceRUS2K;AWC_Bc3xV-SFh!%Tdjog?7a6VZl}6bh6unr|>nY zJwaLr?c)4wbbGlpEU7?4-C1SN&}`v5%Q&cq9E0gd9E@D0vO77`Wm+0DNe1eY?Is`! z@|5@X-%OrUve6AfTF|M6KaEg!K>F0(fZHQQ&3Q&Li|wl5=_C83E~Ce46c3L{z3BH@ z`brFt1tOHzaCVuGXzZ7_GM{0G-L!WPM<7$)Xi7|96nMo2!Lx>4jXK6@oN=)o&l1vd z6e-^Z5gAnNxpI@NAsy#9J^4V2}Fz>J9p0m-PmeY)e=9|i7U($4)!mE`2% zoSMTL_duXBtuR*sxCP|JGqD)^G-G8-dKEwR7ZmNyV*+?L1kY^Dd@MBZVjX_yZmLlM z{z|HjoS|ZJf>)(y+MMx9B6JexTor0E491X>QPVc>-++e z#|XC;0;^6pSoJh6N`{J6Qd1wit*=bO0{8A8nO?{=G>zuM8*j5*2HTO^s@9*x+Y&At zFbP9$V0E4~0|9AUL;`qQ%qtpMcQ_9R)N&#qZ++NrMq^FEc!6i=XAK@jB1UD$&w0|Ft~2JMh~5Aj-r#hOtirIB^q0CA{O zc*^Ohh-LiADE3>v96aO!BYOU69~liNk3_|V53COTRZJ~|%Mv9@uWF{*Xh6eGBo_f( z-Vje}FbEkE1~oC&;}0i1WHk1#Fj*+yw=FJjqGU1ibVXB`S_HoPWMeH9$w`qJA{*^9 zl7icamIq1L$gD)$c@1IZdr9Ly8k7FIQ#4C(^_`d@I^3}agzRDX5&^#rO0S&*f~6pG zd63Hb<<7kFVGK7&)m#rJLn~xkqmqBO?|Mu2=MbGgy%bXqdt8TO4>xIdlI3||@zU=| z+60ZRps7zNbS9b3$LZCaX8E+N*%7?B50cXK6$Db1Oy?)PhOaH*{zxdz5F5@swKYsY z#PI859W$lirv}rf{1O491*~e?(LxEY09v06>X2#k(`3$e#I@!7VSA+)@&pO^E19_J zU21BxjeYw1+E=zIj76FFD!d3q$IOYl$oa9c=VyrMWLlatKOFeDfrAb36%p0XGtdNc zNb&e9%l<9vcdRYu%OSYoc1X`=tX<(`v*&&nl>%y6HwT;21VL*w62*?nkD0mv6QiOm zlY+C&)6SXdpiz$f8sx|7Im4M@lS0M{$b-1qHB)k7Ek?2C+3ZyMA;fkXqT9C&CO9r`2~HJG4tqE!097#JQ{TvZea@Kf(`ODQE_}Vv z{bGn0y<6>7N>n6aN#+)bY!F5lbrCbnJAwyx2sf1gNa#jr;AzO6l%?tJ(4CP)pz^yj zU1X-`{VP1`bsI@sNmk;W2`0{mu5|-2mkSX65#(|YndD1Gh^F+b3W`Nqlx}qs#RPTo z03FpHN29b8WtRAZ4n_H_wF7fV1-gh>*0O{bbAFMwNKi*#Uenjfx7CBHi)^r8WQ#<5 z1brNlfg6=@)tXh@$M1%=9Tn8;IDF(xlVt)@GBZ3~A%;zA?DHsWwlP>qvceCIkD*T6 zfEPU@6UHr7N0AAe5y-~VyWYCX9$*jo+YMDlxN{2%KM;pJZ({4|^>r_~S0xW8TI=Y*O%Ea{ z!8jqWQNl9Zo+ZX-nY4-jIE^h))AY$<=Z0)Fh>E-FNYDLEGIB zWRU>h?+hZ7LqQG070T&m)+ZnlCW6GW_<(xg_!>KvM#HpZmd3*qX1{u+G|JbXF%g^m zBM1b4D#+LK=*`c^e%k9T1`Gay4))IsnlDjPO@fMC)pLbGjR4Cko+jD{7;-UL>MkMZ ztis}2yLy~WTi-U2Wxt+Y{XDXinDjP!!mkGsgo@__Tk`Y764c+@)PVQvaX{_DbDtQi z;@ca2oyDo0&5=;j5~gbCXME53fb6IjsZv%nd3r2xL*Au|df7^$qo+S{!ok+bH2&_D z{HpXcKvIV8L)0grI$3{D#Y)ta$%kGZ5~nxp-a-_K+F~>9+*An*qr^MMw!6MW%1VL2S$>^k@TvR?rmtgy1xk?8v zBml2s`GobzxisJ{wpP4fQMyC?48@|-^C?7vm>A-FHTRhn*5fqe>ejsc1$>9cY2;(} zhWM~JiPmk(DZ zju0fh_u(Q98EDk~H2Cy3-LtD`&a^7*!9{$E9v+5@WYqlI7wUH(gEQezxa1GOQ59U_ z$!YG>2A&^?x4!}xOWHrP=`2S#{7Bv;r++lceNAqY3ZVrOY!7V>ZA1XMGi36qPx&d< z+QB3=?L_mLRm_hfL#o1v1T=royJx;H8XuZTQkS;KGapNkB~H->QhacJj+tOz?-2iI z^+C2LVPNj2r_zck2jIYTrs@Z~4K_OTCirmo+s~ttPEyCPP*FM}6wanAT%5K~$>cDy zUL9^Z5jEiOX~k^-^`j)6Ne`1iEENi0qG?S@>NIsS8Z>*r=SRIw6!-j`h&G!#+t=(B zAWQMhJ5&Ts2{`A_)+ajW#BZO-B-;X0PZ{{cv5q1hf2t^QFbLDlDgZyZH|F5XVmDH*SsRv7b0`U2;qG^O`&$|E(DofCh>_(b9PukURU z#*HBEYu=DMhue>HCjB0}(#y-g5mNWBCF)HdbK?!BmMR)L1;+m$Ro%DlA5YkQFaOWr z>dyjyW;y?a0{~%YEcYe;E7|!I_-AtRAK>0S@PFkff5QJ1^MBwOr2m5dKhplQ(x1}# hPbIJWMCTs|`J0fmRI#xC`UdNM$lV{c{4c!${s)XbKvn<% literal 0 HcmV?d00001 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。 +```