Files
update-server/app/api/routes/client_update.py
T

254 lines
13 KiB
Python

import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Body, HTTPException, Request
import minio_tool
from app.repositories import client_update_repository
from app.schemas.client_update import (
CheckUpdateRequest,
DeviceIssueRequest,
DownloadReportRequest,
DownloadUrlRequest,
ManifestRequest,
UpgradeReportRequest,
)
from app.services.common_service import is_executable_path, policy_row_to_dict, validate_channel_code, version_key
from app.services.signing_service import canonical_manifest_bytes, sign_device_identity, sign_manifest, sign_policy
TARGET_PLATFORM = __import__("os").getenv("TARGET_PLATFORM", "windows").strip().lower() or "windows"
TARGET_ARCH = __import__("os").getenv("TARGET_ARCH", "x64").strip() or "x64"
SIGNING_KEY_ID = __import__("os").getenv("SIGNING_KEY_ID", "manifest-key-v1")
router = APIRouter(tags=["client-update"])
def normalize_relative_path(raw_path: str) -> str:
from pathlib import PurePosixPath
if not isinstance(raw_path, str):
raise HTTPException(status_code=400, detail="文件相对路径无效")
normalized = raw_path.replace(chr(92), "/")
if normalized.startswith("/"):
raise HTTPException(status_code=400, detail=f"文件路径必须是相对路径: {raw_path}")
normalized = normalized.strip("/")
if not normalized or chr(0) in normalized:
raise HTTPException(status_code=400, detail="文件相对路径为空或包含非法字符")
path = PurePosixPath(normalized)
if path.is_absolute() or any(part in ("", ".", "..") for part in path.parts):
raise HTTPException(status_code=400, detail=f"不安全的文件路径: {raw_path}")
if path.parts and ":" in path.parts[0]:
raise HTTPException(status_code=400, detail=f"文件路径不能包含盘符: {raw_path}")
return path.as_posix()
@router.post("/api/v1/device/issue")
async def issue_device(request: Request, body: DeviceIssueRequest = Body(...)):
# 设备登记:客户端首次启动时用 License 换取服务端签名的设备凭证。
# 后续更新接口依赖这个凭证里的 app_id/channel/device_id/license_id,而不是只相信客户端自报字段。
app_id, channel, installation_id = body.app_id.strip(), body.channel.strip(), body.installation_id.strip()
if not app_id or not validate_channel_code(channel) or not (16 <= len(installation_id) <= 128):
raise HTTPException(status_code=400, detail="设备登记参数无效")
ip = request.client.host if request.client else ""
result = client_update_repository.issue_device(
app_id,
channel,
hashlib.sha256(body.license_key.strip().encode("utf-8")).hexdigest(),
installation_id,
body.machine_hash.strip().lower(),
ip,
)
if result["status"] == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if result["status"] == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
if result["status"] == "license_invalid":
raise HTTPException(status_code=403, detail={"error": "license_invalid", "msg": "授权密钥无效、已过期或不适用于当前应用/渠道"})
if result["status"] == "device_disabled":
raise HTTPException(status_code=403, detail={"error": "device_disabled", "msg": result["reason"]})
if result["status"] == "license_conflict":
raise HTTPException(status_code=409, detail="该安装实例已绑定其他授权")
if result["status"] == "license_device_limit":
raise HTTPException(status_code=403, detail={"error": "license_device_limit", "msg": "授权设备数量已达到上限"})
issued_at = result["issued_at"]
license_row = result["license"]
identity = {
"device_id": result["device_id"],
"license_id": license_row["license_id"],
"app_id": app_id,
"channel": channel,
"installation_id": installation_id,
"credential_seq": result["credential_seq"],
"issued_at": issued_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"valid_until": license_row["valid_until"],
"signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID,
}
identity_text, signature = sign_device_identity(identity)
return {"identity": identity, "identity_text": identity_text, "signature": signature}
@router.post("/api/v1/update/check")
async def check_update(request: Request, body: CheckUpdateRequest = Body(...)):
# 更新检查同时返回“能不能运行”和“要不要更新”。
# 客户端即使没有新版本,也会拿到签名策略,用于离线启动、禁用版本和防回滚判断。
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
app_id, cur_ver, channel = body.app_id, body.current_version, body.channel
context = client_update_repository.get_update_context(app_id, channel)
if context["status"] == "channel_not_found":
raise HTTPException(status_code=400, detail={"error": "channel_not_found", "msg": f"渠道 {channel} 不存在"})
if context["status"] == "channel_disabled":
raise HTTPException(status_code=403, detail={"error": "channel_disabled", "msg": f"渠道 {channel} 已停用"})
ver = context["version"]
settings = policy_row_to_dict(context["policy"])
latest_ver = ver["version"] if ver else ""
version_id = int(ver["id"]) if ver else 0
target_protocol = int(ver["client_protocol"] or 1) if ver else 0
protocol_compatible = not ver or target_protocol >= body.client_protocol
upgrade_available = bool(latest_ver) and version_key(latest_ver) > version_key(cur_ver)
rollback_candidate = bool(latest_ver) and version_key(latest_ver) < version_key(cur_ver)
rollback_allowed = rollback_candidate and bool(settings["allow_rollback"]) and protocol_compatible
need_update = upgrade_available or rollback_allowed
disabled = cur_ver in settings["disabled_versions"]
below_min = bool(settings["min_supported_version"]) and version_key(cur_ver) < version_key(settings["min_supported_version"])
allow_run = not disabled and not below_min
force_update = bool(settings["force_update"] and upgrade_available) or disabled or below_min
if disabled:
action, message = "blocked", settings["message"] or f"当前版本 {cur_ver} 已被管理员禁用"
elif below_min:
action, message = "force_update", settings["message"] or f"当前版本低于最低支持版本 {settings['min_supported_version']}"
elif force_update:
action, message = "force_update", settings["message"] or "必须升级到最新版本后才能继续使用"
elif rollback_allowed:
action, message = "rollback_allowed", settings["message"] or f"管理员要求回退到版本 {latest_ver}"
elif rollback_candidate and not protocol_compatible:
action, message = "rollback_denied", f"目标版本 {latest_ver} 的客户端协议为 {target_protocol},低于当前协议 {body.client_protocol},禁止降级"
elif rollback_candidate:
action, message = "rollback_denied", settings["message"] or f"渠道目标版本 {latest_ver} 低于当前版本,策略禁止降级"
elif upgrade_available:
action, message = "optional_update", settings["message"] or "发现可用新版本"
else:
action, message = "allow", settings["message"] or "当前版本允许使用"
issued_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
policy = {
"app_id": app_id,
"channel": channel,
"current_version": cur_ver,
"policy_seq": settings["policy_seq"],
"allow_run": allow_run,
"force_update": force_update,
"allow_rollback": settings["allow_rollback"],
"rollback_targets": [],
"offline_allowed": settings["offline_allowed"],
"issued_at": issued_at,
"valid_until": settings["valid_until"],
"latest_version": latest_ver,
"min_supported_version": settings["min_supported_version"],
"disabled_versions": settings["disabled_versions"],
"git_tags_enabled": settings["git_tags_enabled"],
"message": message,
"signature_alg": "RSA-2048-SHA256",
"key_id": SIGNING_KEY_ID,
}
policy_text, policy_signature = sign_policy(policy)
policy["signature"] = policy_signature
return {
"allow_run": allow_run,
"action": action,
"message": message,
"need_update": need_update,
"release_available": bool(ver),
"current_version": cur_ver,
"latest_version": latest_ver,
"version_id": version_id,
"force_update": force_update,
"allow_rollback": settings["allow_rollback"],
"rollback": rollback_allowed,
"client_protocol": body.client_protocol,
"target_client_protocol": target_protocol,
"protocol_compatible": protocol_compatible,
"policy_seq": settings["policy_seq"],
"policy_valid_until": settings["valid_until"],
"policy": policy,
"policy_text": policy_text,
"policy_signature": policy_signature,
}
@router.post("/api/v1/update/download-url")
async def get_download_url(request: Request, body: DownloadUrlRequest = Body(...)):
# 下载链接不直接暴露 MinIO 永久地址,而是按版本和设备凭证生成短期可用的预签名 URL。
# 这样既能让客户端直接下载大文件,又能保留服务端授权控制。
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
ip = request.client.host if request.client else ""
ua = request.headers.get("user-agent", "")[:300]
result = client_update_repository.get_download_files(body.version_id, body.app_id, body.channel, body.version, identity, ip, ua)
if result["status"] != "ok":
raise HTTPException(status_code=404, detail="版本与应用/渠道不匹配")
base_path = f"{body.app_id}/{body.channel}/{body.version}/files/"
return {
"files": [
{"path": row["path"], "url": minio_tool.get_url(base_path + row["path"]), "sha256": row["sha256"], "size": row["size"]}
for row in result["files"]
]
}
@router.post("/api/v1/update/download-report")
async def report_download(request: Request, body: DownloadReportRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
if body.result not in ("success", "fail"):
raise HTTPException(status_code=400, detail="下载结果无效")
ip = request.client.host if request.client else ""
ua = request.headers.get("user-agent", "")[:300]
values = []
for item in body.files[:5000]:
path = normalize_relative_path(str(item.get("path") or ""))
size = max(0, int(item.get("size") or 0))
values.append((identity["device_id"], identity["license_id"], body.app_id, body.version, body.channel, path, size, body.result, ip, ua))
logged = client_update_repository.insert_download_report_logs(values)
return {"code": 0, "logged": logged}
@router.post("/api/v1/update/manifest")
async def get_manifest(request: Request, body: ManifestRequest = Body(...)):
# Manifest 是某个版本的文件清单:路径、大小、SHA256、是否可执行。
# 客户端必须先验证服务端签名,再按清单下载和校验文件,防止升级包被篡改。
if request.state.device_identity["app_id"] != body.app_id or request.state.device_identity["channel"] != body.channel:
raise HTTPException(status_code=403, detail="设备凭证与应用/渠道不匹配")
ver, rows = client_update_repository.get_manifest_version_files(body.version_id)
if not ver:
raise HTTPException(status_code=404, detail="Version not found")
files = [{"path": row["path"], "sha256": row["sha256"], "size": row["size"], "executable": is_executable_path(row["path"])} for row in rows]
manifest = {
"app_id": ver["app_id"],
"version": ver["version"],
"channel": ver["channel"],
"platform": TARGET_PLATFORM,
"arch": TARGET_ARCH,
"manifest_seq": int(body.version_id),
"created_at": ver["create_time"],
"files": files,
}
manifest_text = canonical_manifest_bytes(manifest).decode("utf-8")
signature = sign_manifest(manifest)
manifest["signature"] = signature
return {"manifest": manifest, "manifest_text": manifest_text, "signature": signature}
@router.post("/api/v1/update/report")
async def report(request: Request, body: UpgradeReportRequest = Body(...)):
identity = request.state.device_identity
if identity["app_id"] != body.app_id or identity["device_id"] != body.device_id:
raise HTTPException(status_code=403, detail="上报身份与设备凭证不匹配")
client_update_repository.insert_upgrade_log(body.device_id, body.from_version, body.to_version, body.result)
return {"code": 0, "msg": "上报成功"}