"""设备端 Agent 专用接口(设备主动来取,**不走登录会话**,靠设备令牌鉴权)。 用途:设备上装的聚合 Agent(剪贴板注入 / 身份显示 / 应用商店)用这组接口 自报身份、拉可安装的应用清单、下载 APK、回报安装结果。 为什么另开一组接口而不是复用管理端 API: * 设备登录不了后台会话,也不该拿 admin 的凭据; * 平台 → 设备的**指令**仍然走 adb(`am start`),这组接口只解决 「设备主动来取东西」这一半,边界清楚。 安全设计(这组接口是平台唯一对设备开放的面): 1. **默认关闭**:`app_meta.agent_store_enabled != "1"` 时统一返回 404, 不暴露接口是否存在; 2. 必须带 `X-Device-Token`,与 `app_meta.agent_device_token` **常量时间**比对; 3. 只暴露 APK 元数据与文件本身 —— 不返回任何配置、密钥、设备凭据; 4. 令牌可一键重置(重置后旧令牌立即失效)。 管理端接口在文件后半段(`/api/agent-store/*`,走正常登录 + 权限)。 """ import hmac import os import secrets import time from flask import Blueprint, jsonify, request, send_file from config import APK_DIR from core import device_pool from core.db_config import meta_get, meta_set from core.logger import get_logger from web.auth import PERM_APKS, perm_required bp = Blueprint("device_agent", __name__) _log = get_logger("web.devagent") # app_meta 键 _K_ENABLED = "agent_store_enabled" # "1" 开启;默认关 _K_TOKEN = "agent_device_token" # 设备令牌(首次开启时生成) _K_CREATED = "agent_device_token_at" # 令牌生成/重置时间 _API_VERSION = 1 # 本组接口的版本(契约里写死给 agent 对照) _MAX_LOG = 500 # 记录表最多保留条数(超了删最旧) # ================== 公共:开关与令牌 ================== def store_enabled(): return (meta_get(_K_ENABLED) or "0") == "1" def get_token(): return meta_get(_K_TOKEN) or "" def ensure_token(regenerate=False): """取设备令牌,没有(或要求重置)就生成一个。""" tok = get_token() if regenerate or not tok: tok = secrets.token_urlsafe(24) meta_set(_K_TOKEN, tok) meta_set(_K_CREATED, time.strftime("%Y-%m-%d %H:%M:%S")) _log.info("设备端商店令牌已%s", "重置" if regenerate else "生成") return tok def _deny(): """统一拒绝:功能关着就 404(不暴露存在性),令牌不对 401。""" if not store_enabled(): return jsonify({"ok": False, "error": "not found"}), 404 tok = request.headers.get("X-Device-Token", "") or "" real = get_token() if not real or not tok or not hmac.compare_digest(tok, real): _log.warning("设备端接口鉴权失败(来源 %s)", request.remote_addr) return jsonify({"ok": False, "error": "unauthorized"}), 401 return None def _identify(): """从请求头识别是哪台设备:指纹优先(换过 IP 也认得出),其次序列号。""" fp = (request.headers.get("X-Device-Fingerprint", "") or "").strip() serial = (request.headers.get("X-Device-Serial", "") or "").strip() name = "" try: if fp: # 注意:find_by_fingerprint / list_configured 返回的都是 dict(不是 ORM 对象) d = device_pool.find_by_fingerprint(fp) if d: return fp, d.get("serial", serial), d.get("name") or "", True if serial: for d in device_pool.list_configured(enabled_only=False): if d.get("serial") == serial: return fp, serial, d.get("name") or "", True except Exception as e: _log.warning("识别设备失败: %s", e) return fp, serial, name, False # ================== 设备侧接口 ================== @bp.route("/api/device/agent/bootstrap") def api_dev_agent_bootstrap(): """设备开机/定时来拉:我是谁 + 有哪些应用可以装。 请求头:`X-Device-Token`(必带)、`X-Device-Fingerprint`、`X-Device-Serial` 响应:设备身份(平台分配的名称)+ 应用清单(含下载地址) """ deny = _deny() if deny: return deny fp, serial, name, known = _identify() from web import context apks = [] try: for a in context.apk_mgr.list_all(): apks.append({ "id": a.get("id", ""), "display_name": a.get("display_name") or a.get("filename") or "", "package_name": a.get("package_name") or "", "version_name": a.get("version_name") or "", "version_code": a.get("version_code") or 0, "size": a.get("size") or 0, "upload_time": a.get("upload_time") or "", "download_path": "/api/device/agent/apk/{}".format(a.get("id", "")), }) except Exception as e: _log.error("设备端拉取应用清单失败: %s", e) return jsonify({"ok": False, "error": "清单读取失败"}), 500 _log.info("设备端拉清单: %s(%s) 应用 %d 个", name or "未登记", serial or fp, len(apks)) return jsonify({ "ok": True, "agent_api_version": _API_VERSION, "server_time": time.strftime("%Y-%m-%d %H:%M:%S"), "platform": { # 用请求里的 Host 回填,设备按自己实际能连通的地址访问 "url": request.host_url.rstrip("/"), }, "device": {"fingerprint": fp, "serial": serial, "name": name, "known": known}, "apks": apks, }) @bp.route("/api/device/agent/apk/") def api_dev_agent_apk(apk_id): """下载 APK 文件(设备自己拉,比 adb push 快且可并行)。""" deny = _deny() if deny: return deny from core.models import ApkFile row = ApkFile.query.get(apk_id) if not row: return jsonify({"ok": False, "error": "apk not found"}), 404 path = os.path.join(APK_DIR, row.filename or (apk_id + ".apk")) fname = row.display_name or row.filename or apk_id if not os.path.exists(path): return jsonify({"ok": False, "error": "apk file missing"}), 404 _log.info("设备端下载 APK: %s", apk_id) return send_file(path, as_attachment=True, download_name="{}.apk".format(fname), mimetype="application/vnd.android.package-archive") @bp.route("/api/device/agent/install", methods=["POST"]) def api_dev_agent_install(): """设备请求**平台**帮它装这个应用(平台走 adb,静默,没有系统安装框)。 为什么这么设计:普通 App 自己调系统安装器,MIUI 上要过「继续 → 勾选未检测 → 继续更新」甚至 ICP 备案检查好几步;而 `adb install` 在同一台设备上实测是 **静默**的(升级 2.7s、全新安装 7.9s,零弹框)。 所以设备端商店的「安装/更新」按钮走这里:**用户还是在手机上选**(应用商店体验), 真正干活的是平台,几秒钟装完。万一 adb 被 MIUI 拦了,返回 ok=false, 设备端自己退回本地安装(有弹框,但至少能装)。 """ deny = _deny() if deny: return deny data = request.json or {} apk_id = str(data.get("apk_id", "")) if not apk_id: return jsonify({"ok": False, "error": "缺少 apk_id"}), 400 fp, serial, name, known = _identify() if not serial: return jsonify({"ok": False, "error": "无法识别设备"}), 400 if not known: return jsonify({"ok": False, "error": "这台设备还没登记进平台设备池"}), 409 from core.models import ApkFile if not ApkFile.query.get(apk_id): return jsonify({"ok": False, "error": "apk not found"}), 404 from web import context try: ok, msg = context.apk_mgr.install(apk_id, [serial]) except Exception as e: _log.error("设备端请求安装失败: %s", e) return jsonify({"ok": False, "error": "平台安装任务启动失败"}), 500 _log.info("设备端请求安装: %s(%s) apk=%s -> %s", name or "未登记", serial, apk_id, msg) if not ok: return jsonify({"ok": False, "error": msg}), 400 return jsonify({"ok": True, "msg": msg, "serial": serial}) @bp.route("/api/device/agent/report", methods=["POST"]) def api_dev_agent_report(): """设备上报下载/安装结果,平台侧留下记录(应用管理页可见)。 请求体:{apk_id, action: download|install_ok|install_fail, package_name?, version_name?, message?, serial?} """ deny = _deny() if deny: return deny data = request.json or {} action = str(data.get("action", ""))[:20] if action not in ("download", "install_ok", "install_fail"): return jsonify({"ok": False, "error": "action 非法"}), 400 fp, serial, name, _known = _identify() if data.get("serial"): serial = str(data["serial"])[:120] from core.models import DeviceInstallLog, db try: db.session.add(DeviceInstallLog( fingerprint=fp, serial=serial, device_name=name, apk_id=str(data.get("apk_id", ""))[:32], package_name=str(data.get("package_name", ""))[:200], version_name=str(data.get("version_name", ""))[:50], action=action, message=str(data.get("message", ""))[:500], created_at=time.strftime("%Y-%m-%d %H:%M:%S"))) db.session.commit() _prune_logs() except Exception as e: db.session.rollback() _log.error("设备端安装记录写入失败: %s", e) return jsonify({"ok": False, "error": "记录失败"}), 500 _log.info("设备端上报: %s(%s) %s %s %s", name or "未登记", serial or fp, action, data.get("apk_id", ""), data.get("message", "")) return jsonify({"ok": True}) def _prune_logs(keep=_MAX_LOG): """记录表只留最近 N 条(失败忽略,不影响上报)。""" try: from core.models import DeviceInstallLog, db n = db.session.query(DeviceInstallLog).count() if n <= keep: return old = (db.session.query(DeviceInstallLog.id) .order_by(DeviceInstallLog.id).limit(n - keep).all()) if old: db.session.query(DeviceInstallLog).filter( DeviceInstallLog.id.in_([r[0] for r in old])).delete( synchronize_session=False) db.session.commit() except Exception as e: _log.warning("清理设备端安装记录失败: %s", e) # ================== 管理端接口(登录 + 权限)================== @bp.route("/api/agent-store/config", methods=["GET", "POST"]) @perm_required(PERM_APKS) def api_agent_store_config(): """查看/修改设备端商店配置(应用管理页)。GET 也要权限:令牌属于敏感信息。""" if request.method == "GET": return jsonify({ "ok": True, "enabled": store_enabled(), "token": get_token(), "token_created_at": meta_get(_K_CREATED) or "", "api_version": _API_VERSION, "base_url_hint": request.host_url.rstrip("/"), }) data = request.json or {} if "enabled" in data: meta_set(_K_ENABLED, "1" if data["enabled"] else "0") if data["enabled"]: ensure_token() if data.get("regenerate_token"): ensure_token(regenerate=True) _log.info("设备端商店配置更新: enabled=%s", store_enabled()) return jsonify({"ok": True, "enabled": store_enabled(), "token": get_token()}) @bp.route("/api/agent-store/logs") @perm_required(PERM_APKS) def api_agent_store_logs(): """设备端安装记录(最近 N 条)。""" limit = min(int(request.args.get("limit", 50) or 50), 200) from core.models import DeviceInstallLog rows = (DeviceInstallLog.query .order_by(DeviceInstallLog.id.desc()).limit(limit).all()) return jsonify({"ok": True, "logs": [{ "id": r.id, "device_name": r.device_name or "", "serial": r.serial or "", "fingerprint": r.fingerprint or "", "apk_id": r.apk_id or "", "package_name": r.package_name or "", "version_name": r.version_name or "", "action": r.action or "", "message": r.message or "", "created_at": r.created_at or "", } for r in rows]})