新增了客户端打包成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 @@
**
!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/客户端包。