新增了客户端打包成sdk的功能和服务端docker镜像打包的功能,并修复了一些问题

This commit is contained in:
2026-07-07 09:48:01 +00:00
parent cd3dca042d
commit 9e545cb328
50 changed files with 5251 additions and 875 deletions
+8
View File
@@ -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
+45 -20
View File
@@ -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
+37 -31
View File
@@ -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
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
$<$<CONFIG:Release>:${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
$<$<CONFIG:Debug>:${OPENSSL_LIB_DEBUG}>
$<$<CONFIG:Release>:${OPENSSL_LIB_RELEASE}>
)
target_link_libraries(Common
PRIVATE Qt5::Core Qt5::Network Qt5::Widgets
INTERFACE libssl.lib libcrypto.lib
)
+7
View File
@@ -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");
+41
View File
@@ -0,0 +1,41 @@
#include "DeviceIdentityHelper.h"
#include "ConfigHelper.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QSaveFile>
#include <QSysInfo>
#include <QUuid>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#endif
DeviceIdentityHelper::DeviceIdentityHelper(const QString& dir):m_installDir(dir){}
QString DeviceIdentityHelper::deviceId() const{return m_deviceId;}
QString DeviceIdentityHelper::errorString() const{return m_error;}
bool DeviceIdentityHelper::verifySignature(const QByteArray& payload,const QString& sig64){
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);Q_UNUSED(sig64);m_error="OpenSSL unavailable";return false;
#else
QFile f(QDir(m_installDir).filePath("config/manifest_public_key.pem")); if(!f.open(QIODevice::ReadOnly)){m_error="device public key missing";return false;}
QByteArray kd=f.readAll();BIO* b=BIO_new_mem_buf(kd.constData(),kd.size());EVP_PKEY* k=b?PEM_read_bio_PUBKEY(b,nullptr,nullptr,nullptr):nullptr;if(b)BIO_free(b);if(!k){m_error="device public key invalid";return false;}
EVP_MD_CTX* c=EVP_MD_CTX_new();QByteArray sig=QByteArray::fromBase64(sig64.toUtf8());bool ok=c&&EVP_DigestVerifyInit(c,nullptr,EVP_sha256(),nullptr,k)==1&&EVP_DigestVerifyUpdate(c,payload.constData(),payload.size())==1&&EVP_DigestVerifyFinal(c,reinterpret_cast<const unsigned char*>(sig.constData()),sig.size())==1;if(c)EVP_MD_CTX_free(c);EVP_PKEY_free(k);if(!ok)m_error="device credential RSA signature invalid";return ok;
#endif
}
bool DeviceIdentityHelper::loadAndVerify(const QString& appId,const QString& channel){
QFile f(QDir(m_installDir).filePath("config/client_identity.dat"));if(!f.open(QIODevice::ReadOnly))return false;QJsonParseError e;auto d=QJsonDocument::fromJson(f.readAll(),&e);if(e.error!=QJsonParseError::NoError||!d.isObject()){m_error="device credential JSON invalid";return false;}auto w=d.object();QByteArray text=w.value("identity_text").toString().toUtf8();if(text.isEmpty()||!verifySignature(text,w.value("signature").toString()))return false;auto identity=QJsonDocument::fromJson(text).object();QDateTime expiry=QDateTime::fromString(identity.value("valid_until").toString(),Qt::ISODate);if(identity.value("app_id").toString()!=appId||identity.value("channel").toString()!=channel||identity.value("license_id").toString().isEmpty()||identity.value("installation_id").toString().isEmpty()||identity.value("device_id").toString().isEmpty()){m_error="device/license credential identity mismatch";return false;}if(!expiry.isValid()||expiry<=QDateTime::currentDateTimeUtc()){m_error="license expired";return false;}m_deviceId=identity.value("device_id").toString();return true;
}
bool DeviceIdentityHelper::verifyLocal(const QString& appId,const QString& channel){m_error.clear();return loadAndVerify(appId,channel);}
bool DeviceIdentityHelper::ensureIssued(const QString& base,const QString& token,const QString& appId,const QString& channel,const QString& licenseKey){
m_error.clear();if(loadAndVerify(appId,channel)){ConfigHelper::instance().setValue("Update","device_id",m_deviceId);return true;}
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;
}
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <QString>
class DeviceIdentityHelper {
public:
explicit DeviceIdentityHelper(const QString& installDir);
bool ensureIssued(const QString& apiBaseUrl, const QString& clientToken, const QString& appId,
const QString& channel, const QString& licenseKey);
bool verifyLocal(const QString& appId, const QString& channel);
QString deviceId() const;
QString errorString() const;
private:
bool loadAndVerify(const QString& expectedAppId, const QString& expectedChannel);
bool verifySignature(const QByteArray& payload, const QString& signatureBase64);
QString m_installDir, m_deviceId, m_error;
};
+52 -48
View File
@@ -1,49 +1,53 @@
#include "HttpHelper.h"
#include <QNetworkProxy>
#include "ConfigHelper.h"
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8());
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 <QNetworkProxy>
#include "ConfigHelper.h"
#include <QFile>
#include <QDir>
#include <QApplication>
void HttpHelper::postRequest(const QString& url, const QJsonObject& jsonBody,
std::function<void(int code, const QJsonObject& resp)> callback)
{
QNetworkAccessManager* manager = new QNetworkAccessManager();
manager->setProxy(QNetworkProxy::NoProxy);
QNetworkRequest req(url);
req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
// Add auth token header
QString token = ConfigHelper::instance().getValue("Server", "client_token");
req.setRawHeader("X-Client-Token", token.toUtf8());
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();
}
+132
View File
@@ -0,0 +1,132 @@
#include "IntegrityHelper.h"
#include <QCryptographicHash>
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSet>
#ifdef HAVE_OPENSSL
#include <openssl/evp.h>
#include <openssl/pem.h>
#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<QString> 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<const unsigned char*>(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<QString> 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;
}
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <QString>
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;
};
+1
View File
@@ -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;
+1
View File
@@ -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;
+91
View File
@@ -0,0 +1,91 @@
#include "TicketHelper.h"
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMessageAuthenticationCode>
#include <QSaveFile>
#include <QStandardPaths>
#include <QUuid>
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;
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <QString>
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);
};
+2 -2
View File
@@ -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 $<TARGET_FILE:Launcher>
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Launcher>
COMMENT "自动部署Qt依赖dll"
)
endif()
endif()
+3
View File
@@ -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;
+68 -6
View File
@@ -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 <QFile>
#include <QFileDialog>
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));
+2 -2
View File
@@ -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 $<TARGET_FILE:MainApp>
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:MainApp>
)
endif()
endif()
+38 -19
View File
@@ -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();
}
}
+27 -3
View File
@@ -1,3 +1,27 @@
client目录下的CMakeList.txt用来统一管理client下的Launcher工程和MainApp工程和Updater工程
cofig.ini要跟exe同级目录
客户端配置说明
==============
统一从程序目录下的 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 的发布源目录。
+129
View File
@@ -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=<path>` 参数,验证并消费一次性启动票据。
2. 如果收到 `--health-file=<path>` 参数,启动成功后向该路径写入 `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. 发布失败提示主程序不在根目录:选择发布目录时要选择业务主程序所在目录,而不是上层或下层目录。
Binary file not shown.
Binary file not shown.
+9 -9
View File
@@ -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()
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
# 关键:给Updater自身添加OpenSSL头文件目录,解决#include openssl/*找不到
target_include_directories(Updater PRIVATE ${OPENSSL_INC})
# 仅链接Common和QtOpenSSL库由Common INTERFACE自动传递
# 仅链接Common和QtOpenSSL库由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 $<TARGET_FILE:Updater>
COMMAND ${WINDEPLOYQT} $<IF:$<CONFIG:Debug>,--debug,--release> $<TARGET_FILE:Updater>
)
endif()
endif()
+60
View File
@@ -12,6 +12,7 @@
#include <QCryptographicHash>
#include <QDir>
#include <QJsonDocument>
#include <QSaveFile>
#include <QApplication>
#include <algorithm>
#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<unsigned char>(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<qint64>(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;
+4
View File
@@ -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;
};
+115 -36
View File
@@ -17,6 +17,9 @@
#include "UpdateTransaction.h"
#include "FileHelper.h"
#include "ConfigHelper.h"
#include "TicketHelper.h"
#include "PolicyHelper.h"
#include <QVersionNumber>
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<qint64>(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));
+528 -71
View File
@@ -5,65 +5,339 @@
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Marsco 发布控制台</title>
<style>
body { font-family: Arial, Helvetica, sans-serif; margin: 20px; background: #f4f6fb; color: #222; }
h1, h2 { margin: 0 0 12px; }
.section { background: #fff; border: 1px solid #d8dbe2; border-radius: 10px; padding: 16px; margin-bottom: 18px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.row label { min-width: 110px; margin: 0; }
.row input, .row select, .row button { padding: 8px 10px; font-size: 14px; }
.row input, .row select { min-width: 240px; border: 1px solid #c9cdd5; border-radius: 6px; }
.row button { border: 1px solid #5a8efc; background: #5a8efc; color: #fff; border-radius: 6px; cursor: pointer; }
.row button:hover { background: #4b7ce6; }
table { width: 100%; border-collapse: collapse; margin-top: 10px; }
th, td { border: 1px solid #e1e4ea; padding: 10px; text-align: left; vertical-align: middle; }
th { background: #f1f5fb; }
td button { margin-right: 6px; }
pre { background: #0f172a; color: #f8fafc; border-radius: 10px; padding: 14px; min-height: 120px; overflow-x: auto; }
.small { font-size: 13px; color: #6b7280; }
.wide { width: 100%; }
:root { --ink:#172033; --muted:#667085; --line:#e4e9f1; --paper:#fff; --brand:#3157d5; --brand-dark:#2447bc; --soft:#f4f6fa; --danger:#d92d20; --success:#067647; }
:root {
--bg:#f6f7fb;
--surface:#fff;
--ink:#172033;
--muted:#667085;
--line:#dfe5ee;
--field:#fdfefe;
--brand:#3157d5;
--brand-hover:#2447bc;
--danger:#d92d20;
--success:#067647;
--warning:#9a6700;
}
* { box-sizing:border-box; }
body { max-width:1180px; margin:0 auto 60px; padding:0 24px; font-family:Inter,"Microsoft YaHei",system-ui,-apple-system,sans-serif; color:var(--ink); background:var(--soft); font-size:14px; }
body::before { content:""; position:absolute; inset:0 0 auto; height:190px; z-index:-1; background:linear-gradient(120deg,#121a32,#294681); }
h1 { margin:0 -24px 28px; padding:32px 24px 55px; color:#fff; font-size:30px; letter-spacing:-.03em; }
h1::before { content:"MARSCO UPDATE SERVICE"; display:block; margin-bottom:7px; color:#adc0ff; font-size:11px; letter-spacing:.16em; }
h1::after { content:" · 软件发布与升级管理"; color:#d7e0ff; font-size:14px; font-weight:400; letter-spacing:0; }
h2 { margin:0; font-size:17px; }
.section { border:1px solid var(--line); border-radius:16px; padding:22px; margin-bottom:18px; box-shadow:0 8px 28px rgba(24,36,61,.06); }
.section h2 { padding-bottom:14px; margin-bottom:17px; border-bottom:1px solid #edf0f4; }
.row { gap:12px; margin-top:12px; align-items:flex-end; }
.row label { min-width:auto; color:#344054; font-size:13px; font-weight:650; }
.row input,.row select { min-width:220px; padding:10px 12px; border:1px solid #d0d5dd; border-radius:9px; outline:none; transition:.18s ease; }
.row input:focus,.row select:focus { border-color:#7893e8; box-shadow:0 0 0 3px rgba(49,87,213,.12); }
.row button,td button { padding:9px 13px; border:1px solid #d0d5dd; border-radius:9px; background:#fff; color:#344054; font-weight:650; cursor:pointer; transition:.16s ease; }
.row button:hover,td button:hover { transform:translateY(-1px); border-color:#98a2b3; box-shadow:0 3px 9px rgba(16,24,40,.08); }
#saveTokenBtn,#addAppBtn,#publishBtn { color:#fff; border-color:var(--brand); background:var(--brand); }
#saveTokenBtn:hover,#addAppBtn:hover,#publishBtn:hover { background:var(--brand-dark); }
button:disabled { opacity:.55; cursor:not-allowed; transform:none!important; }
input[type=file] { padding:8px; background:#fafbfc; }
input[type=file]::file-selector-button { border:0; border-radius:7px; padding:7px 11px; margin-right:10px; color:#344054; background:#e9edf5; cursor:pointer; }
table { margin-top:14px; border-collapse:separate; border-spacing:0; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
th,td { padding:12px 14px; border:0; border-bottom:1px solid #edf0f4; }
th { color:#475467; background:#f8f9fb; font-size:12px; letter-spacing:.02em; }
html { background:var(--bg); }
body {
max-width:1280px;
margin:0 auto;
padding:0 24px 48px;
color:var(--ink);
background:var(--bg);
font:14px/1.45 Inter,"Microsoft YaHei",system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
}
h1 {
margin:0 -24px 22px;
padding:24px;
color:var(--ink);
background:var(--surface);
border-bottom:1px solid var(--line);
box-shadow:0 1px 2px rgba(16,24,40,.04);
font-size:26px;
line-height:1.2;
letter-spacing:0;
}
h1::before {
content:"MARSCO UPDATE SERVICE";
display:block;
margin-bottom:6px;
color:#5d6b82;
font-size:11px;
font-weight:700;
letter-spacing:.14em;
}
h1::after {
content:" · 软件发布与升级管理";
color:var(--muted);
font-size:14px;
font-weight:400;
}
h2 {
margin:0 0 16px;
padding-bottom:12px;
border-bottom:1px solid #edf0f4;
font-size:16px;
line-height:1.3;
letter-spacing:0;
}
.section {
margin-bottom:16px;
padding:18px;
overflow-x:auto;
background:var(--surface);
border:1px solid var(--line);
border-radius:8px;
box-shadow:0 1px 3px rgba(16,24,40,.06);
}
.row {
display:flex;
flex-wrap:wrap;
align-items:center;
gap:10px 12px;
margin-top:12px;
}
.section h2 + .row { margin-top:0; }
.row label {
flex:0 0 116px;
min-width:0;
margin:0;
color:#344054;
font-size:13px;
font-weight:650;
line-height:1.25;
}
.row label input[type="checkbox"] { margin:0 7px 0 0; }
.row input,
.row select {
flex:1 1 220px;
width:auto;
min-width:180px;
max-width:360px;
height:38px;
padding:8px 11px;
color:var(--ink);
background:var(--field);
border:1px solid #cfd6e2;
border-radius:6px;
outline:none;
transition:border-color .16s ease, box-shadow .16s ease, background .16s ease;
}
.row input[type="number"] { flex-basis:120px; max-width:160px; }
.row input[type="checkbox"] {
flex:0 0 auto;
width:16px;
min-width:16px;
height:16px;
padding:0;
}
.row input[type="file"] {
flex:1 1 420px;
max-width:none;
min-height:38px;
height:auto;
padding:7px;
background:#fafbfc;
}
.row input[type="file"]::file-selector-button {
margin-right:10px;
padding:7px 11px;
color:#344054;
background:#e9edf5;
border:0;
border-radius:6px;
cursor:pointer;
}
.row input:focus,
.row select:focus {
border-color:#7893e8;
background:#fff;
box-shadow:0 0 0 3px rgba(49,87,213,.12);
}
.row .wide,
.row input.wide {
flex:1 1 540px;
max-width:none;
}
.row button,
td button {
min-height:38px;
padding:8px 12px;
color:#344054;
background:#fff;
border:1px solid #cfd6e2;
border-radius:6px;
font-weight:650;
white-space:nowrap;
cursor:pointer;
transition:border-color .16s ease, box-shadow .16s ease, transform .16s ease, background .16s ease;
}
.row button {
flex:0 0 auto;
display:inline-flex;
align-items:center;
justify-content:center;
height:38px;
}
.action-row {
justify-content:flex-start;
padding-left:128px;
}
.checkbox-label {
flex:0 0 auto!important;
display:inline-flex;
align-items:center;
gap:7px;
min-width:96px!important;
color:#344054;
}
.checkbox-label input[type="checkbox"] { margin:0; }
.row button:hover,
td button:hover {
border-color:#98a2b3;
box-shadow:0 3px 9px rgba(16,24,40,.08);
transform:translateY(-1px);
}
#saveTokenBtn,
#addAppBtn,
#publishBtn,
#saveChannelBtn,
#savePolicyBtn,
#createLicenseBtn {
color:#fff;
border-color:var(--brand);
background:var(--brand);
}
#saveTokenBtn:hover,
#addAppBtn:hover,
#publishBtn:hover,
#saveChannelBtn:hover,
#savePolicyBtn:hover,
#createLicenseBtn:hover { background:var(--brand-hover); }
#changeTokenBtn,
#confirmChangeTokenBtn {
color:var(--brand);
border-color:#b9c7ef;
background:#f7f9ff;
}
#confirmChangeTokenBtn {
color:#fff;
border-color:var(--brand);
background:var(--brand);
}
#toggleTokenBtn { min-width:64px; }
button:disabled {
opacity:.55;
cursor:not-allowed;
box-shadow:none!important;
transform:none!important;
}
table {
width:100%;
min-width:720px;
margin-top:14px;
overflow:hidden;
border:1px solid var(--line);
border-radius:8px;
border-collapse:separate;
border-spacing:0;
background:#fff;
}
th,
td {
padding:11px 12px;
border:0;
border-bottom:1px solid #edf0f4;
text-align:left;
vertical-align:middle;
}
th {
color:#475467;
background:#f8f9fb;
font-size:12px;
font-weight:700;
letter-spacing:0;
white-space:nowrap;
}
td { color:#253047; }
td:last-child { white-space:nowrap; }
td button { margin:2px 4px 2px 0; }
tbody tr:last-child td { border-bottom:0; }
tbody tr:hover { background:#fafbff; }
td button[data-action=delete] { color:var(--danger); border-color:#fecdca; background:#fff7f6; }
.badge { display:inline-flex; border-radius:999px; padding:4px 8px; font-size:12px; font-weight:700; color:#475467; background:#f2f4f7; }
td button[data-action="delete"] {
color:var(--danger);
border-color:#fecdca;
background:#fff7f6;
}
.badge {
display:inline-flex;
align-items:center;
min-height:22px;
padding:3px 8px;
color:#475467;
background:#f2f4f7;
border-radius:999px;
font-size:12px;
font-weight:700;
white-space:nowrap;
}
.badge.success { color:var(--success); background:#ecfdf3; }
.badge.fail { color:var(--danger); background:#fef3f2; }
.empty { padding:30px 12px!important; color:var(--muted); text-align:center; }
pre { margin:0; min-height:105px; max-height:320px; padding:15px; color:#d8e4ff; background:#11182a; border-radius:11px; font:12px/1.65 "Cascadia Code",Consolas,monospace; white-space:pre-wrap; }
.small { margin-top:10px; color:var(--muted); }
.token-change { margin-top:16px; padding:14px; border:1px solid #dce3f1; border-radius:11px; background:#f8faff; }
.empty {
padding:28px 12px!important;
color:var(--muted);
text-align:center;
}
.small {
display:inline-flex;
align-items:center;
min-height:28px;
margin:0;
color:var(--muted);
font-size:13px;
line-height:1.35;
}
pre {
margin:14px 0 0;
min-height:105px;
max-height:320px;
padding:14px;
overflow:auto;
color:#d8e4ff;
background:#11182a;
border-radius:8px;
font:12px/1.65 "Cascadia Code",Consolas,monospace;
white-space:pre-wrap;
}
.token-change {
margin-top:14px;
padding:14px;
background:#f8faff;
border:1px solid #dce3f1;
border-radius:8px;
}
.token-change[hidden] { display:none; }
#toggleTokenBtn { min-width:64px; }
#changeTokenBtn { color:var(--brand); border-color:#b9c7ef; background:#f7f9ff; }
#confirmChangeTokenBtn { color:#fff; border-color:var(--brand); background:var(--brand); }
.toast { position:fixed; right:22px; bottom:22px; z-index:20; max-width:380px; padding:12px 15px; color:#fff; background:#1d2939; border-radius:10px; box-shadow:0 12px 32px rgba(16,24,40,.25); opacity:0; transform:translateY(12px); pointer-events:none; transition:.22s ease; }
.toast {
position:fixed;
right:22px;
bottom:22px;
z-index:20;
max-width:380px;
padding:12px 15px;
color:#fff;
background:#1d2939;
border-radius:8px;
box-shadow:0 12px 32px rgba(16,24,40,.25);
opacity:0;
transform:translateY(12px);
pointer-events:none;
transition:.22s ease;
}
.toast.show { opacity:1; transform:translateY(0); }
.toast.error { background:#b42318; }
@media(max-width:760px){body{padding:0 12px}h1{margin:0 -12px 20px;padding:24px 12px 46px;font-size:24px}.section{padding:17px}.row{align-items:stretch;flex-direction:column}.row input,.row select,.row button{width:100%;min-width:0}table{display:block;overflow-x:auto;white-space:nowrap}}
@media(max-width:820px) {
body { padding:0 12px 36px; }
h1 { margin:0 -12px 16px; padding:20px 12px; font-size:23px; }
h1::after { display:block; margin-top:4px; }
.section { padding:15px; margin-bottom:12px; }
.row { display:grid; grid-template-columns:1fr; align-items:stretch; }
.row label,
.row input,
.row select,
.row button,
.row .wide,
.row input.wide {
width:100%;
max-width:none;
min-width:0;
}
.row label { min-height:auto; }
.action-row { padding-left:0; }
.row input[type="checkbox"] { width:16px; min-width:16px; }
table { min-width:760px; }
.toast { right:12px; left:12px; max-width:none; }
}
</style>
</head>
<body><div id="toast" class="toast"></div>
@@ -77,9 +351,19 @@
<button id="saveTokenBtn" type="button">登录并保存</button>
<button id="changeTokenBtn" type="button">更改令牌</button>
</div>
<div class="small">令牌默认隐藏,仅保存在当前浏览器;服务端只保存令牌哈希</div>
<div class="small">令牌默认隐藏,仅保存在当前浏览器;服务端按 .env 中的 ADMIN_TOKEN 校验</div>
<div class="small">当前连接服务端:<strong id="apiBaseLabel"></strong></div>
<div id="tokenChangePanel" class="token-change" hidden>
<div class="row"><label for="newAdminToken">新令牌</label><input id="newAdminToken" type="password" autocomplete="new-password" placeholder="至少 8 个字符" /><label for="confirmAdminToken">确认新令牌</label><input id="confirmAdminToken" type="password" autocomplete="new-password" placeholder="再次输入新令牌" /><button id="confirmChangeTokenBtn" type="button">确认更改</button><button id="cancelChangeTokenBtn" type="button">取消</button></div>
<div class="row">
<label for="newAdminToken">新令牌</label>
<input id="newAdminToken" type="password" autocomplete="new-password" placeholder="至少 8 个字符" />
<label for="confirmAdminToken">确认新令牌</label>
<input id="confirmAdminToken" type="password" autocomplete="new-password" placeholder="再次输入新令牌" />
</div>
<div class="row action-row">
<button id="confirmChangeTokenBtn" type="button">确认更改</button>
<button id="cancelChangeTokenBtn" type="button">取消</button>
</div>
</div>
</div>
@@ -99,6 +383,18 @@
</div>
</div>
<div class="section">
<h2>渠道管理</h2>
<div class="row">
<label for="channelCode">渠道代码</label><input id="channelCode" placeholder="例如 enterprise">
<label for="channelName">显示名称</label><input id="channelName" placeholder="例如 企业版">
<label for="channelOrder">排序</label><input id="channelOrder" type="number" value="100">
<label class="checkbox-label"><input id="channelEnabled" type="checkbox" checked>启用</label>
</div>
<div class="row action-row"><button id="saveChannelBtn" type="button">新增或保存</button><button id="refreshChannelsBtn" type="button">刷新渠道</button></div>
<table><thead><tr><th>代码</th><th>名称</th><th>状态</th><th>排序</th><th>操作</th></tr></thead><tbody id="channelRows"></tbody></table>
</div>
<div class="section">
<h2>发布新版本</h2>
<div class="row">
@@ -107,7 +403,8 @@
<label for="publishVersion">版本号</label>
<input id="publishVersion" type="text" value="1.0.0" />
<label for="publishChannel">渠道</label>
<select id="publishChannel"><option value="stable">stable(正式版)</option><option value="preview">preview(预览版)</option><option value="dev">dev(开发版)</option></select>
<select id="publishChannel"></select>
<label for="publishProtocol">客户端协议</label><input id="publishProtocol" type="number" min="1" value="3" />
</div>
<div class="row">
<label for="publishFiles">软件根目录</label>
@@ -131,6 +428,7 @@
<th>ID</th>
<th>版本</th>
<th>渠道</th>
<th>协议</th>
<th>最新</th>
<th>创建时间</th>
<th>操作</th>
@@ -143,7 +441,7 @@
<div class="section">
<h2>版本运行策略</h2>
<div class="row">
<label for="policyChannel">渠道</label><select id="policyChannel"><option>stable</option><option>preview</option><option>dev</option></select>
<label for="policyChannel">渠道</label><select id="policyChannel"></select>
<label><input id="policyForceUpdate" type="checkbox" /> 强制升级</label>
<label><input id="policyAllowRollback" type="checkbox" /> 允许降级</label>
<label><input id="policyOfflineAllowed" type="checkbox" checked /> 允许离线启动</label>
@@ -160,6 +458,25 @@
</div>
</div>
<div class="section">
<h2>License 授权</h2>
<div class="row">
<label for="licenseCustomer">客户</label><input id="licenseCustomer" placeholder="客户或项目名称">
<label for="licenseChannel">渠道</label><select id="licenseChannel"></select>
<label for="licenseMaxDevices">最大设备数</label><input id="licenseMaxDevices" type="number" min="1" value="1">
<label for="licenseValidUntil">有效期</label><input id="licenseValidUntil" value="2099-12-31T23:59:59Z">
</div>
<div class="row action-row"><button id="createLicenseBtn" type="button">创建授权</button><button id="refreshLicensesBtn" type="button">刷新授权</button></div>
<div class="row"><label for="createdLicenseKey">新授权密钥</label><input id="createdLicenseKey" class="wide" readonly placeholder="仅显示一次;创建后请立即复制到客户端 app_config.json 的 license_key"></div>
<table><thead><tr><th>License ID</th><th>客户</th><th>渠道</th><th>设备</th><th>有效期</th><th>状态</th><th>操作</th></tr></thead><tbody id="licenseRows"></tbody></table>
</div>
<div class="section">
<h2>设备管理</h2>
<div class="row"><button id="refreshDevicesBtn" type="button">刷新设备</button><span class="small">显示当前应用登记的设备;禁用后客户端请求会立即被拒绝</span></div>
<table><thead><tr><th>设备 ID</th><th>状态</th><th>凭证序列</th><th>最近访问</th><th>IP</th><th>操作</th></tr></thead><tbody id="deviceRows"></tbody></table>
</div>
<div class="section">
<h2>升级日志</h2>
<div class="row">
@@ -179,14 +496,26 @@
</table>
</div>
<div class="section">
<h2>文件下载日志</h2><div class="row"><button id="refreshDownloadLogsBtn">刷新下载日志</button></div>
<table><thead><tr><th>设备</th><th>License</th><th>版本/渠道</th><th>文件</th><th>大小</th><th>结果</th><th>IP</th><th>时间</th></tr></thead><tbody id="downloadLogRows"></tbody></table>
</div>
<div class="section">
<h2>管理员审计日志</h2><div class="row"><button id="refreshAuditLogsBtn">刷新审计日志</button></div>
<table><thead><tr><th>管理员指纹</th><th>操作</th><th>结果</th><th>状态码</th><th>IP</th><th>时间</th></tr></thead><tbody id="auditLogRows"></tbody></table>
</div>
<div class="section">
<h2>调试输出</h2>
<pre id="output">准备就绪...</pre>
</div>
<script>
const apiBase = 'http://' + (location.hostname || '127.0.0.1') + ':8000';
const apiBase = (location.protocol === 'http:' || location.protocol === 'https:')
? location.origin
: 'http://127.0.0.1:8000';
const adminTokenInput = document.getElementById('adminToken');
const apiBaseLabel = document.getElementById('apiBaseLabel');
const toggleTokenBtn = document.getElementById('toggleTokenBtn');
const tokenChangePanel = document.getElementById('tokenChangePanel');
const newAdminTokenInput = document.getElementById('newAdminToken');
@@ -198,16 +527,23 @@
const publishAppIdInput = document.getElementById('publishAppId');
const publishVersionInput = document.getElementById('publishVersion');
const publishChannelInput = document.getElementById('publishChannel');
const publishProtocolInput = document.getElementById('publishProtocol');
const publishFilesInput = document.getElementById('publishFiles');
const publishFileSummary = document.getElementById('publishFileSummary');
const publishFilePreview = document.getElementById('publishFilePreview');
const publishBtn = document.getElementById('publishBtn');
const versionRows = document.getElementById('versionRows');
const reportRows = document.getElementById('reportRows');
const deviceRows = document.getElementById('deviceRows');
const licenseRows = document.getElementById('licenseRows');
const channelRows = document.getElementById('channelRows');
const downloadLogRows = document.getElementById('downloadLogRows');
const auditLogRows = document.getElementById('auditLogRows');
let currentToken = localStorage.getItem('admin_token') || '';
let apps = [];
let currentAppId = '';
let channels = [];
function log(message) {
if (typeof message === 'string') {
@@ -239,6 +575,7 @@
const url = apiBase + path;
const reqOptions = {
...options,
cache: 'no-store',
headers: { ...(options.headers || {}), ...getHeaders(options.contentType) },
};
delete reqOptions.contentType;
@@ -321,28 +658,43 @@
}
const chrBackslash = String.fromCharCode(92);
let releaseMainExecutable = 'MainApp.exe';
function selectedUploadEntries() {
const excludedFiles = new Set(['client.ini', 'bootstrap.exe', 'config/app_config.json', 'config/local_state.json', 'config/client_identity.dat', 'config/version_policy.dat']);
return Array.from(publishFilesInput.files).map(file => ({ file, path: relativePathForFile(file) })).filter(item => {
const path = item.path.toLowerCase();
return !excludedFiles.has(path) && !path.startsWith('update/') && !path.startsWith('update_temp/') && !path.endsWith('.pdb') && !path.endsWith('.ilk');
const parts = path.split('/');
const blockedDirectory = parts.slice(0, -1).some(part => part === '.git' || part === '.vs' || part === 'cmakefiles' || part === 'debug' || part === 'update' || part === 'update_temp' || part.startsWith('build') || part.endsWith('_autogen'));
return !excludedFiles.has(path) && !blockedDirectory && !path.endsWith('.pdb') && !path.endsWith('.ilk') && !path.endsWith('.obj');
});
}
function validateReleaseRoot(entries) {
if (!entries.length) return '请选择软件发布根目录';
const lowerPaths = entries.map(item => item.path.toLowerCase());
if (!lowerPaths.includes('mainapp.exe')) {
const nestedMain = lowerPaths.find(path => path.endsWith('/mainapp.exe'));
const mainName = releaseMainExecutable.toLowerCase();
if (!lowerPaths.includes(mainName)) {
const nestedMain = lowerPaths.find(path => path.endsWith('/' + mainName));
if (nestedMain) {
const expectedRoot = nestedMain.slice(0, -'/mainapp.exe'.length);
return '选择层级过高:MainApp.exe 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
const expectedRoot = nestedMain.slice(0, -(mainName.length + 1));
return '选择层级过高:' + releaseMainExecutable + ' 位于 ' + expectedRoot + '/ 下,请直接选择该目录';
}
return '所选目录根级没有 MainApp.exe,不能作为软件发布根目录';
return '所选目录根级没有 ' + releaseMainExecutable + ',不能作为软件发布根目录';
}
const duplicateMain = lowerPaths.find(path => path.endsWith('/' + mainName));
if (duplicateMain) return '目录中存在嵌套的重复主程序 ' + duplicateMain + ',请选择干净的 Release 输出目录';
return '';
}
async function loadRuntimeConfig() {
try {
const data = await request('/admin/runtime-config');
releaseMainExecutable = data.release_main_executable || releaseMainExecutable;
} catch (err) {
log({ warning: '读取发布配置失败,将使用默认主程序名', details: err.message || err });
}
}
function renderSelectedDirectory() {
const allCount = publishFilesInput.files.length;
const entries = selectedUploadEntries();
@@ -356,19 +708,29 @@
publishFilePreview.textContent = paths.length ? paths.join('\n') + (entries.length > 30 ? '\n... 其余 ' + (entries.length - 30) + ' 个文件' : '') : '尚未选择发布目录';
}
function renderChannels() {
const enabled=channels.filter(c=>c.enabled); const selects=[publishChannelInput,document.getElementById('policyChannel'),document.getElementById('licenseChannel')];
selects.forEach(select=>{const previous=select.value;select.innerHTML='';enabled.forEach(c=>{const o=document.createElement('option');o.value=c.channel_code;o.textContent=`${c.channel_code}${c.display_name}`;select.appendChild(o);});if(enabled.some(c=>c.channel_code===previous))select.value=previous;});
channelRows.innerHTML=channels.length?'':'<tr><td colspan="5" class="empty">暂无渠道</td></tr>';channels.forEach(c=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${c.channel_code}</td><td>${c.display_name}</td><td><span class="badge ${c.enabled?'success':'fail'}">${c.enabled?'启用':'停用'}</span></td><td>${c.sort_order}</td><td><button data-channel-code="${c.channel_code}">编辑</button></td>`;channelRows.appendChild(tr);});
}
async function loadChannels(){const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){channels=[];renderChannels();return;}try{const data=await request(`/admin/channel/list?app_id=${encodeURIComponent(appId)}`);channels=data.list||[];renderChannels();}catch(err){log(err);notify('读取渠道失败',true);}}
async function saveChannel(){const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){notify('请先选择应用',true);return;}const body={app_id:appId,channel_code:document.getElementById('channelCode').value.trim(),display_name:document.getElementById('channelName').value.trim(),sort_order:Number(document.getElementById('channelOrder').value),enabled:document.getElementById('channelEnabled').checked};try{await request('/admin/channel/save',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});notify('渠道已保存');await loadChannels();}catch(err){log(err);notify('保存渠道失败',true);}}
async function publishVersion() {
const appId = publishAppIdInput.value.trim();
const version = publishVersionInput.value.trim();
const channel = publishChannelInput.value.trim() || 'stable';
const channel = publishChannelInput.value.trim();
const entries = selectedUploadEntries();
if (!appId) { log('请选择或填写 App ID'); return; }
if (!version) { log('请输入版本号'); return; }
if (!channel) { notify('当前应用没有已启用渠道', true); return; }
const rootError = validateReleaseRoot(entries);
if (rootError) { log(rootError); notify(rootError, true); return; }
const formData = new FormData();
formData.append('app_id', appId);
formData.append('version', version);
formData.append('channel', channel);
formData.append('client_protocol', String(Math.max(1, Number(publishProtocolInput.value) || 3)));
for (const item of entries) {
formData.append('files', item.file, item.file.name);
formData.append('relative_paths', item.path);
@@ -412,17 +774,20 @@
try {
const data = await request(`/admin/version/list?app_id=${encodeURIComponent(appId)}`);
const rows = data.list || [];
versionRows.innerHTML = rows.length ? '' : '<tr><td colspan="6" class="empty">该应用还没有发布版本</td></tr>';
versionRows.innerHTML = rows.length ? '' : '<tr><td colspan="7" class="empty">该应用还没有发布版本</td></tr>';
rows.forEach(item => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${item.id}</td>
<td>${item.version}</td>
<td>${item.channel}</td>
<td><span class="badge">P${item.client_protocol || 1}</span></td>
<td><span class="badge ${item.latest ? 'success' : ''}">${item.latest ? '当前最新' : '历史版本'}</span></td>
<td>${item.create_time || ''}</td>
<td>
<button type="button" data-action="set-latest" data-id="${item.id}">设为最新</button>
<button type="button" data-action="set-protocol" data-id="${item.id}" data-protocol="${item.client_protocol || 1}">修改协议</button>
<button type="button" data-action="offline-package" data-id="${item.id}" data-version="${item.version}">下载 .upd</button>
<button type="button" data-action="delete" data-id="${item.id}">删除</button>
</td>
`;
@@ -434,6 +799,11 @@
}
}
async function downloadOfflinePackage(versionId, version) {
notify('正在生成离线包,请保持页面打开');
try { const resp=await fetch(`${apiBase}/admin/version/offline-package`,{method:'POST',headers:getHeaders('application/json'),body:JSON.stringify({version_id:Number(versionId)})}); if(!resp.ok)throw new Error(`HTTP ${resp.status}: ${await resp.text()}`); const blob=await resp.blob(); const url=URL.createObjectURL(blob); const a=document.createElement('a');a.href=url;a.download=`${currentAppId}_${version}.upd`;document.body.appendChild(a);a.click();a.remove();URL.revokeObjectURL(url);notify('离线包已生成'); } catch(err){log(err);notify('离线包生成失败',true);}
}
async function setLatest(versionId) {
log(`正在设置版本 ${versionId} 为最新...`);
try {
@@ -449,6 +819,18 @@
}
}
async function setVersionProtocol(versionId, currentProtocol) {
const raw = window.prompt('请输入该版本实际支持的客户端协议号', String(currentProtocol || 1));
if (raw === null) return;
const protocol = Number(raw);
if (!Number.isInteger(protocol) || protocol < 1) { notify('协议号必须是正整数', true); return; }
try {
const body = await request('/admin/version/set-protocol', {method:'POST', contentType:'application/json',
body:JSON.stringify({version_id:Number(versionId), client_protocol:protocol})});
log(body); notify('版本协议已更新为 P' + protocol); await loadVersions();
} catch (err) { log(err); notify('修改协议失败', true); }
}
async function deleteVersion(versionId) {
if (!window.confirm(`确定删除版本记录 #${versionId} 及其云端文件吗?此操作不可撤销。`)) return;
log(`正在删除版本 ${versionId} ...`);
@@ -502,6 +884,36 @@
} catch (err) { log(err); notify('保存策略失败', true); }
}
async function loadLicenses() {
const appId=currentAppId||publishAppIdInput.value.trim(); if(!appId){licenseRows.innerHTML='<tr><td colspan="7" class="empty">请先选择应用</td></tr>';return;}
try { const data=await request(`/admin/license/list?app_id=${encodeURIComponent(appId)}`); const rows=data.list||[]; licenseRows.innerHTML=rows.length?'':'<tr><td colspan="7" class="empty">暂无授权</td></tr>'; rows.forEach(item=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${item.license_id}</td><td>${item.customer_name}</td><td>${item.channel_code}</td><td>${item.used_devices} / ${item.max_devices}</td><td>${item.valid_until}</td><td><span class="badge ${item.status==='active'?'success':'fail'}">${item.status==='active'?'有效':'已禁用'}</span></td><td><button data-license-id="${item.license_id}" data-status="${item.status}">${item.status==='active'?'禁用':'启用'}</button></td>`;licenseRows.appendChild(tr);}); } catch(err){log(err);notify('读取授权失败',true);}
}
async function createLicense(){
const appId=currentAppId||publishAppIdInput.value.trim();if(!appId){notify('请先选择应用',true);return;} const body={app_id:appId,customer_name:document.getElementById('licenseCustomer').value.trim(),channel:document.getElementById('licenseChannel').value,max_devices:Number(document.getElementById('licenseMaxDevices').value),valid_until:document.getElementById('licenseValidUntil').value.trim()};
try{const result=await request('/admin/license/create',{method:'POST',contentType:'application/json',body:JSON.stringify(body)});document.getElementById('createdLicenseKey').value=result.license_key;log(result);notify('授权已创建,请立即复制密钥');await loadLicenses();}catch(err){log(err);notify('创建授权失败',true);}
}
async function toggleLicense(id,status){try{await request('/admin/license/set-status',{method:'POST',contentType:'application/json',body:JSON.stringify({license_id:id,status:status==='active'?'disabled':'active'})});await loadLicenses();notify('授权状态已更新');}catch(err){log(err);notify('修改授权失败',true);}}
async function loadDevices() {
const appId = currentAppId || publishAppIdInput.value.trim();
if (!appId) { deviceRows.innerHTML = '<tr><td colspan="6" class="empty">请先选择应用</td></tr>'; return; }
try {
const data = await request(`/admin/device/list?app_id=${encodeURIComponent(appId)}`);
const rows = data.list || [];
deviceRows.innerHTML = rows.length ? '' : '<tr><td colspan="6" class="empty">暂无登记设备</td></tr>';
rows.forEach(item => { const tr=document.createElement('tr'); tr.innerHTML=`<td>${item.device_id}</td><td><span class="badge ${item.disabled?'fail':'success'}">${item.disabled?'已禁用':'正常'}</span></td><td>${item.credential_seq}</td><td>${item.last_seen_at||''}</td><td>${item.last_ip||''}</td><td><button data-device-id="${item.device_id}" data-disabled="${item.disabled}">${item.disabled?'恢复':'禁用'}</button></td>`; deviceRows.appendChild(tr); });
} catch (err) { log(err); notify('读取设备列表失败', true); }
}
async function toggleDevice(deviceId, disabled) {
let reason=''; if (!disabled) { reason=window.prompt('请输入禁用原因', '管理员禁用') || ''; if (!reason) return; }
try { const result=await request('/admin/device/set-disabled',{method:'POST',contentType:'application/json',body:JSON.stringify({device_id:deviceId,disabled:!disabled,reason})}); log(result); notify(result.msg); await loadDevices(); } catch(err) { log(err); notify('设备状态修改失败',true); }
}
function formatLogBytes(bytes){const n=Number(bytes)||0;return n>=1048576?(n/1048576).toFixed(2)+' MB':n>=1024?(n/1024).toFixed(1)+' KB':n+' B';}
async function loadDownloadLogs(){const appId=currentAppId||publishAppIdInput.value.trim();try{const data=await request(`/admin/download-log/list?app_id=${encodeURIComponent(appId)}&limit=300`);const rows=data.list||[];downloadLogRows.innerHTML=rows.length?'':'<tr><td colspan="8" class="empty">暂无下载日志</td></tr>';rows.forEach(x=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${x.device_id}</td><td>${x.license_id}</td><td>${x.version} / ${x.channel_code}</td><td>${x.file_path}</td><td>${formatLogBytes(x.file_size)}</td><td><span class="badge ${x.result==='success'?'success':x.result==='fail'?'fail':''}">${x.result}</span></td><td>${x.ip||''}</td><td>${x.created_at||''}</td>`;downloadLogRows.appendChild(tr);});}catch(err){log(err);notify('读取下载日志失败',true);}}
async function loadAuditLogs(){try{const data=await request('/admin/audit-log/list?limit=300');const rows=data.list||[];auditLogRows.innerHTML=rows.length?'':'<tr><td colspan="6" class="empty">暂无审计日志</td></tr>';rows.forEach(x=>{const tr=document.createElement('tr');tr.innerHTML=`<td>${x.actor_hash}</td><td>${x.method} ${x.path}</td><td><span class="badge ${x.result==='success'?'success':'fail'}">${x.result}</span></td><td>${x.status_code}</td><td>${x.ip||''}</td><td>${x.created_at||''}</td>`;auditLogRows.appendChild(tr);});}catch(err){log(err);notify('读取审计日志失败',true);}}
async function loadReports() {
log('正在加载升级日志...');
try {
@@ -539,12 +951,17 @@
document.getElementById('saveTokenBtn').addEventListener('click', async () => {
const token = adminTokenInput.value.trim();
if (!token) { notify('令牌不能为空', true); return; }
const previousToken = currentToken;
currentToken = token;
localStorage.setItem('admin_token', currentToken);
try {
await loadApps();
await request('/admin/auth/check');
localStorage.setItem('admin_token', currentToken);
await loadConsoleData();
notify('令牌验证成功并已保存');
} catch (err) {
currentToken = previousToken;
updateTokenInput();
log(err);
notify('令牌验证失败', true);
}
});
@@ -561,10 +978,13 @@
});
document.getElementById('confirmChangeTokenBtn').addEventListener('click', async () => {
const currentInputToken = adminTokenInput.value.trim();
const nextToken = newAdminTokenInput.value.trim();
const confirmation = confirmAdminTokenInput.value.trim();
if (!currentInputToken) { notify('请先输入当前管理员令牌', true); return; }
if (nextToken.length < 8) { notify('新令牌至少需要 8 个字符', true); return; }
if (nextToken !== confirmation) { notify('两次输入的新令牌不一致', true); return; }
currentToken = currentInputToken;
try {
const result = await request('/admin/token/change', { method: 'POST', contentType: 'application/json', body: JSON.stringify({ new_token: nextToken }) });
currentToken = nextToken;
@@ -574,19 +994,30 @@
newAdminTokenInput.value = '';
confirmAdminTokenInput.value = '';
log(result);
notify('管理员令牌已更改');
notify('管理员令牌已在当前服务进程生效,请同步修改 .env');
} catch (err) {
log(err);
notify('更改令牌失败,请确认当前令牌正确', true);
const detail = err && err.body && err.body.detail ? err.body.detail : '请确认当前令牌正确';
notify('更改令牌失败:' + detail, true);
}
});
document.getElementById('refreshAppsBtn').addEventListener('click', loadApps);
document.getElementById('refreshChannelsBtn').addEventListener('click', loadChannels);
document.getElementById('saveChannelBtn').addEventListener('click', saveChannel);
channelRows.addEventListener('click',event=>{const b=event.target.closest('button[data-channel-code]');if(!b)return;const c=channels.find(x=>x.channel_code===b.dataset.channelCode);if(!c)return;document.getElementById('channelCode').value=c.channel_code;document.getElementById('channelName').value=c.display_name;document.getElementById('channelOrder').value=c.sort_order;document.getElementById('channelEnabled').checked=c.enabled;});
document.getElementById('addAppBtn').addEventListener('click', addApp);
publishBtn.addEventListener('click', publishVersion);
publishFilesInput.addEventListener('change', renderSelectedDirectory);
document.getElementById('refreshVersionsBtn').addEventListener('click', loadVersions);
document.getElementById('refreshReportsBtn').addEventListener('click', loadReports);
document.getElementById('refreshDownloadLogsBtn').addEventListener('click', loadDownloadLogs);
document.getElementById('refreshAuditLogsBtn').addEventListener('click', loadAuditLogs);
document.getElementById('refreshDevicesBtn').addEventListener('click', loadDevices);
document.getElementById('refreshLicensesBtn').addEventListener('click', loadLicenses);
document.getElementById('createLicenseBtn').addEventListener('click', createLicense);
licenseRows.addEventListener('click', event=>{const b=event.target.closest('button[data-license-id]');if(b)toggleLicense(b.dataset.licenseId,b.dataset.status);});
deviceRows.addEventListener('click', event => { const b=event.target.closest('button[data-device-id]'); if(b) toggleDevice(b.dataset.deviceId, b.dataset.disabled === 'true'); });
document.getElementById('loadPolicyBtn').addEventListener('click', loadPolicy);
document.getElementById('savePolicyBtn').addEventListener('click', savePolicy);
document.getElementById('policyChannel').addEventListener('change', loadPolicy);
@@ -597,7 +1028,7 @@
publishAppIdInput.value = currentAppId;
}
currentAppLabel.textContent = currentAppId || '未选择';
if (currentAppId) loadPolicy();
if (currentAppId) { loadChannels().then(() => loadPolicy()); loadLicenses(); loadDevices(); loadDownloadLogs(); }
});
versionRows.addEventListener('click', async (event) => {
@@ -607,15 +1038,41 @@
const id = button.dataset.id;
if (action === 'set-latest') {
await setLatest(id);
} else if (action === 'set-protocol') {
await setVersionProtocol(id, button.dataset.protocol);
} else if (action === 'offline-package') {
await downloadOfflinePackage(id, button.dataset.version);
} else if (action === 'delete') {
await deleteVersion(id);
}
});
window.addEventListener('load', async () => {
updateTokenInput();
async function loadConsoleData() {
await loadRuntimeConfig();
renderSelectedDirectory();
await loadApps();
await loadReports();
await loadLicenses();
await loadDevices();
await loadDownloadLogs();
await loadAuditLogs();
}
function renderLoggedOutState() {
versionRows.innerHTML = '<tr><td colspan="7" class="empty">登录后显示版本列表</td></tr>';
reportRows.innerHTML = '<tr><td colspan="5" class="empty">登录后显示升级日志</td></tr>';
downloadLogRows.innerHTML = '<tr><td colspan="8" class="empty">登录后显示下载日志</td></tr>';
auditLogRows.innerHTML = '<tr><td colspan="6" class="empty">登录后显示审计日志</td></tr>';
deviceRows.innerHTML = '<tr><td colspan="6" class="empty">登录后显示设备</td></tr>';
licenseRows.innerHTML = '<tr><td colspan="7" class="empty">登录后显示授权</td></tr>';
channelRows.innerHTML = '<tr><td colspan="5" class="empty">登录后显示渠道</td></tr>';
log('请输入管理员令牌后点击“登录并保存”。页面不会在打开时自动读取服务端日志。当前连接服务端:' + apiBase);
}
window.addEventListener('load', () => {
apiBaseLabel.textContent = apiBase;
updateTokenInput();
renderLoggedOutState();
});
</script>
</body>
+16 -9
View File
@@ -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"
}
-6
View File
@@ -1,6 +0,0 @@
{
"max_policy_seq": 0,
"last_success_run_at": "",
"last_online_verified_at": "",
"last_success_version": ""
}
-1
View File
@@ -1 +0,0 @@
{"disabled_versions":[],"force_update":false,"offline_allowed":true,"policy_seq":1,"valid_until":"2099-12-31T23:59:59Z","signature":"d6982cb02d9a0c6fb6b7176961a7736dbee0decfbd5e422bd189521c72664205"}
+41
View File
@@ -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."
+87
View File
@@ -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"
+86
View File
@@ -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"
Binary file not shown.
-538
View File
@@ -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/<sha256>.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 启动时的核心文件完整性检查。这两项是目前客户端启动准入中最大的安全缺口。
+8
View File
@@ -0,0 +1,8 @@
**
!Dockerfile
!requirements.txt
!main.py
!db.py
!minio_tool.py
!tables.sql
!admin.html
+69
View File
@@ -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
+75
View File
@@ -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
+27
View File
@@ -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"]
+11 -3
View File
@@ -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()
init_db()
+72
View File
@@ -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
+74
View File
@@ -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
+960 -65
View File
File diff suppressed because it is too large Load Diff
+63 -6
View File
@@ -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
+95
View File
@@ -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"
+292
View File
@@ -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" <<EOF
#!/usr/bin/env bash
set -euo pipefail
docker load -i ./images/simcae-server-all-images_$VERSION.tar
if [[ ! -f .env ]]; then
cp .env.example .env
echo "Created .env from .env.example. Edit .env before starting services."
else
echo ".env already exists. Keeping existing file."
fi
echo "Next: edit .env, then run: docker compose up -d"
EOF
chmod +x "$PACKAGE_DIR/load-images.sh"
python3 - "$PACKAGE_DIR/.env.example" "$IMAGE_NAME" <<'PYENV'
from pathlib import Path
import sys
path = Path(sys.argv[1])
image = sys.argv[2]
text = path.read_text(encoding='utf-8')
text = text.replace('SIMCAE_UPDATE_SERVER_IMAGE=simcae-update-server:0.1.0', f'SIMCAE_UPDATE_SERVER_IMAGE={image}')
path.write_text(text, encoding='utf-8')
PYENV
docker save -o "$TAR_FILE" "$IMAGE_NAME" "$MINIO_IMAGE" "$MC_IMAGE"
rm -f "$ARCHIVE_FILE"
tar -czf "$ARCHIVE_FILE" -C "$(dirname "$PACKAGE_DIR")" "$(basename "$PACKAGE_DIR")"
echo "Offline package directory: $PACKAGE_DIR"
echo "Offline package archive: $ARCHIVE_FILE"
echo "Images tar: $TAR_FILE"
+6
View File
@@ -0,0 +1,6 @@
cryptography==49.0.0
fastapi==0.138.0
minio==7.2.20
python-dotenv==1.2.2
python-multipart==0.0.32
uvicorn==0.49.0
+122
View File
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS versions (
channel TEXT DEFAULT 'stable', -- 更新渠道:stable正式稳定版 / beta测试版
version TEXT NOT NULL, -- 版本号,如1.0.0、1.0.2
latest INTEGER DEFAULT 0, -- 是否为当前渠道最新版本:1=是最新,0=历史旧版
client_protocol INTEGER NOT NULL DEFAULT 1, -- 该版本客户端支持的更新/策略协议版本
create_time TEXT DEFAULT (datetime('now')), -- 新增:版本创建时间
UNIQUE(app_id, channel, version) -- 联合唯一约束:同一个软件+渠道不能重复存在相同版本
);
@@ -63,3 +64,124 @@ CREATE TABLE IF NOT EXISTS version_policies (
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(app_id, channel)
);
-- 7. RSA 签发的客户端设备身份
CREATE TABLE IF NOT EXISTS devices (
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL UNIQUE, app_id TEXT NOT NULL,
installation_id TEXT NOT NULL, machine_hash TEXT NOT NULL DEFAULT '', credential_seq INTEGER NOT NULL DEFAULT 1,
disabled INTEGER NOT NULL DEFAULT 0, disabled_reason TEXT NOT NULL DEFAULT '',
first_seen_at TEXT DEFAULT (datetime('now')), last_seen_at TEXT DEFAULT (datetime('now')), last_ip TEXT NOT NULL DEFAULT '',
UNIQUE(app_id, installation_id)
);
-- 8. 软件授权及其设备占用关系
CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT, license_id TEXT NOT NULL UNIQUE, license_key_hash TEXT NOT NULL UNIQUE,
customer_name TEXT NOT NULL, app_id TEXT NOT NULL, channel_code TEXT NOT NULL DEFAULT 'stable',
max_devices INTEGER NOT NULL DEFAULT 1, valid_until TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS license_devices (
license_id TEXT NOT NULL, device_id TEXT NOT NULL UNIQUE, bound_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY(license_id,device_id)
);
-- 9. 每个应用独立维护的动态发布渠道
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT, app_id TEXT NOT NULL, channel_code TEXT NOT NULL,
display_name TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 100,
created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(app_id, channel_code)
);
-- 10. 文件下载授权与客户端完成结果
CREATE TABLE IF NOT EXISTS download_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, device_id TEXT NOT NULL, license_id TEXT NOT NULL,
app_id TEXT NOT NULL, version TEXT NOT NULL, channel_code TEXT NOT NULL, file_path TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0, result TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '', created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_download_logs_created ON download_logs(created_at);
CREATE INDEX IF NOT EXISTS idx_download_logs_device ON download_logs(device_id,created_at);
-- 11. 管理后台写操作审计
CREATE TABLE IF NOT EXISTS admin_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT, actor_hash TEXT NOT NULL, action TEXT NOT NULL,
method TEXT NOT NULL, path TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', result TEXT NOT NULL,
status_code INTEGER NOT NULL, ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_admin_audit_created ON admin_audit_logs(created_at);
-- 12. SimCAE 崩溃报告原始数据索引
CREATE TABLE IF NOT EXISTS crash_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL UNIQUE,
client_report_id TEXT NOT NULL UNIQUE,
content_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'stored',
symbolication_status TEXT NOT NULL DEFAULT 'not_started',
product TEXT NOT NULL,
app_version TEXT NOT NULL,
git_commit TEXT NOT NULL,
build_type TEXT NOT NULL,
channel TEXT NOT NULL,
exception_code TEXT NOT NULL DEFAULT '',
crash_time_utc TEXT NOT NULL,
received_at_utc TEXT NOT NULL,
remote_address TEXT NOT NULL DEFAULT '',
content_length INTEGER NOT NULL DEFAULT 0,
metadata_sha256 TEXT NOT NULL,
metadata_size INTEGER NOT NULL DEFAULT 0,
minidump_sha256 TEXT NOT NULL,
minidump_size INTEGER NOT NULL DEFAULT 0,
attachments_sha256 TEXT NOT NULL DEFAULT '',
attachments_size INTEGER NOT NULL DEFAULT 0,
storage_path TEXT NOT NULL,
metadata_json TEXT NOT NULL,
server_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_crash_reports_build ON crash_reports(product,app_version,git_commit);
CREATE INDEX IF NOT EXISTS idx_crash_reports_exception ON crash_reports(exception_code);
CREATE INDEX IF NOT EXISTS idx_crash_reports_received ON crash_reports(received_at_utc);
-- 13. SimCAE 符号包上传索引
CREATE TABLE IF NOT EXISTS crash_symbol_uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol_upload_id TEXT NOT NULL UNIQUE,
identity_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'stored',
product TEXT NOT NULL,
app_version TEXT NOT NULL,
git_commit TEXT NOT NULL,
build_type TEXT NOT NULL,
platform TEXT NOT NULL,
toolchain TEXT NOT NULL,
created_at_utc TEXT NOT NULL,
received_at_utc TEXT NOT NULL,
metadata_sha256 TEXT NOT NULL,
metadata_size INTEGER NOT NULL DEFAULT 0,
symbols_sha256 TEXT NOT NULL,
symbols_size INTEGER NOT NULL DEFAULT 0,
storage_path TEXT NOT NULL,
metadata_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_crash_symbols_build ON crash_symbol_uploads(product,app_version,git_commit,build_type,platform);
CREATE INDEX IF NOT EXISTS idx_crash_symbols_received ON crash_symbol_uploads(received_at_utc);
-- 14. 崩溃原始文件访问审计
CREATE TABLE IF NOT EXISTS crash_file_access_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL,
file_name TEXT NOT NULL,
actor_hash TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_crash_file_access_report ON crash_file_access_logs(report_id,created_at);
+196
View File
@@ -0,0 +1,196 @@
# 服务端 Docker 镜像交付说明
这份说明用于把 SimCAE 自动升级/崩溃上报服务端交付给开发组或部署人员。
## 1. 要不要给开发组发 Docker 镜像
分两种情况:
- 如果开发组只负责接入客户端 SDK,通常不需要给他们镜像,只需要给一个已经部署好的测试服务地址、token、License 和 SDK 文档。
- 如果开发组需要本地完整联调,建议给他们一个服务端 Docker 部署包。部署包不要只有一个镜像,还要包含 docker-compose.image.yml、.env 示例、keys 目录说明。
## 2. 服务端包含哪些容器
当前服务端不是单容器,而是三个服务:
- api:FastAPI 服务,提供升级接口、后台页面、Manifest 签名、崩溃上报接口。
- minio:对象存储,用于保存版本文件。
- minio-init:启动时自动创建 MinIO bucket。
所以交付时推荐给 Docker Compose 部署包。
## 3. 构建服务端镜像
在项目根目录执行,注意 Dockerfile 的构建上下文必须是项目根目录,因为镜像会复制 server 代码和 client/admin.html。
```powershell
cd C:\Users\admin\Desktop
docker build `
-t simcae-update-server:0.1.0 `
-f server\Dockerfile `
.
```
验证镜像:
```powershell
docker images simcae-update-server
```
## 4. 导出镜像给别人
如果接收方机器可以访问 Docker Hub,只导出 api 镜像即可:
```powershell
docker save `
-o simcae-update-server_0.1.0.tar `
simcae-update-server:0.1.0
```
如果接收方机器不能联网,建议把 api、minio、minio-init 使用的三个镜像一起导出:
```powershell
docker pull minio/minio:RELEASE.2025-04-22T22-12-26Z
docker pull minio/mc:RELEASE.2025-04-16T18-13-26Z
docker save `
-o simcae-server-all-images_0.1.0.tar `
simcae-update-server:0.1.0 `
minio/minio:RELEASE.2025-04-22T22-12-26Z `
minio/mc:RELEASE.2025-04-16T18-13-26Z
```
建议交付目录:
```text
SimCAEServerDockerPackage/
simcae-update-server_0.1.0.tar
docker-compose.image.yml
.env.example
keys/
manifest_private_key.pem
README.md
```
注意:manifest_private_key.pem 是服务端签 Manifest 的私钥。客户端 SDK 中的 manifest_public_key.pem 必须和它匹配。
## 4.1 推荐:在 Ubuntu 上使用离线打包脚本
如果接收方服务器不能联网,推荐直接使用 Ubuntu/bash 脚本生成完整离线包。脚本会完成:构建 api 镜像、拉取 MinIO 镜像、导出三张镜像、复制 docker-compose.yml、复制 .env.example、复制 keys、生成快速启动说明,并压缩成 tar.gz。
在当前 Ubuntu 机器上执行:
```bash
cd /path/to/project/server
bash ./package-offline-server.sh \
--version 0.1.0 \
--output-dir ./dist/SimCAEServerDockerPackage
```
生成结果:
```text
server/dist/SimCAEServerDockerPackage/
images/
simcae-server-all-images_0.1.0.tar
keys/
manifest_private_key.pem
manifest_public_key.pem
docker-compose.yml
.env.example
README.md
QUICK_START.md
load-images.sh
server/dist/SimCAEServerDockerPackage.tar.gz
```
`SimCAEServerDockerPackage.tar.gz` 拷贝到离线服务器即可。
如果是在 Windows 机器上打包,也可以使用 `package-offline-server.ps1`,但 Ubuntu 服务端主流程建议使用 `package-offline-server.sh`
## 5. 离线 Ubuntu 服务器如何运行
接收方服务器需要提前安装好 Docker Engine 和 Docker Compose plugin。离线包拷过去后执行:
```bash
tar -xzf SimCAEServerDockerPackage.tar.gz
cd SimCAEServerDockerPackage
bash ./load-images.sh
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`
启动:
```bash
docker compose up -d
```
查看状态:
```bash
docker compose ps
```
查看日志:
```bash
docker compose logs -f api
```
访问:
```text
后台/API: http://服务器IP:8000/
MinIO 控制台: http://服务器IP:9001/
```
如果服务器开启了防火墙,至少放行:
```text
8000 后台/API
9000 客户端下载升级文件
9001 MinIO 控制台,可按需限制访问
```
## 6. 客户端 SDK 需要哪些服务端信息
发给客户端开发组的信息通常是:
```text
api_base_url=http://服务器IP:8000
client_token=CLIENT_API_TOKEN 的值
license_key=后台创建的 License
manifest_public_key.pem=与服务端 manifest_private_key.pem 匹配的公钥
crash_report_token=CRASH_REPORT_TOKEN 的值,如果接入崩溃上报
```
## 7. 生产部署提醒
- 不要把生产私钥和 token 发到无关人员手里。
- 正式环境需要备份 runtime、minio_data、keys。
- 对外开放端口至少包括 8000 和 9000;9001 是 MinIO 控制台,生产环境可限制访问。
- 修改服务端签名私钥后,必须重新生成客户端 manifest_public_key.pem,并重新打 SDK/客户端包。
+1503
View File
File diff suppressed because it is too large Load Diff