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": "上报成功"}