Files
auto_control/web/device_agent_api.py
T
butubb dad1af8b0b feat: 设备端应用商店(平台侧)—— Agent 直连拉清单/下载/安装,绕开 MIUI 的 USB 安装拦截
背景:`adb install` 在 MIUI 上被「USB 安装」拦下(USER_RESTRICTED),必须人工点确认;
而**由设备上的 App 自己调系统安装器**走的是普通应用安装流程,不触发那道拦截。

平台侧实现(设备端 Agent APK 在另一个仓库,契约见 doc/DEVICE_AGENT.md):

- web/device_agent_api.py(新):
  * 设备侧 `/api/device/agent/{bootstrap,apk/<id>,report}` —— 无登录会话,靠
    `X-Device-Token`(常量时间比对)鉴权;`X-Device-Fingerprint` 让平台认出是哪台设备
    (换 IP 也认得出,并把平台分配的名称回给它);**默认关闭,关闭时统一 404**,
    不暴露接口存在性;只读清单与文件,不返回任何配置/密钥
  * 管理侧 `/api/agent-store/{config,logs}` —— 登录 + 应用管理权限;启用时自动生成令牌,
    可一键重置(旧令牌立即失效)
- core/models.py:新增 `device_install_log`(下载/安装记录,自动裁剪保留 500 条)
- 应用管理页新增「📱 设备端应用商店」面板:开关 / 接入地址 / 令牌(复制·重置)/ 安装记录;
  面板里写明它与「平台批量推送」的分工
- doc/DEVICE_AGENT.md(新):**两个仓库共享的接口契约** —— 接入流程、三个设备接口的
  完整规格与错误码、adb 指令协议(剪贴板/身份显示/配置下发)、版本兼容约定、安全须知;
  API.md、DATA_MODEL.md、doc/README.md 索引同步

验证:
- 后端 9 组用例:默认关闭 404、无/错令牌 401、指纹认出设备名、清单、下载、上报与失败
  原因落库、关闭后重新 404、重置令牌后旧令牌立即失效、未登录管理端被拒 —— 全通过
- 真实浏览器:面板渲染、点开关 → 自动生成令牌 → 设备端带令牌拉到清单且 device.name=A08、
  无 JS 报错;测试后已把开关还原为关闭
- 全量回归 105 项通过(42 GET + 62 写探测 + 7 关键业务)
2026-09-13 14:40:38 +08:00

263 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备端 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/<apk_id>")
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/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]})