254 lines
13 KiB
Python
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": "上报成功"}
|