一、账号台账(新表 device_account,schema v8) - core/ledger.py:CRUD、Excel 粘贴解析(Tab 分隔 / 表头乱序缺列 / 是·否布尔 / 逐行留痕)、 按范围取号(本机台账 / 全部 / 按设备分组)、设备端账号块、dry_run 预览 - 新「账号」顶级 Tab(权限 devices):列表 + 搜索 + 设备筛选 + 增删改 + 粘贴导入(默认先预览再写,逐行显示 新增/覆盖/跳过/失败 + 留痕) - 设备池加「账号 N」列(0 个显示"未登记"),点开看该设备账号明细 - 任务「条件判断」新增 cmp_source / cmp_group:候选值可直接来自台账, 与手填值**合并(OR)**;取不到号时回落手填值,并把原因写进步骤明细与日志 (否则"永远走 else 分支"而任务照样显示成功,最难查) - 设备端契约:身份页多推 accounts_b64(base64 JSON;默认不含手机号; 超预算按整条丢且绝不算字节切),见 doc/DEVICE_AGENT.md §5.2.1 - 备份/文档红线:TABLE_LABELS 加「账号台账」;DATA_MODEL/API/ARCHITECTURE/DEPLOY/ TASK_DEV/DEVICE_AGENT/DEVELOPMENT/README 同步;顺手补上 DATA_MODEL 漏列的 done_mark ⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值":绝不能拿去填「去重」的身份元素 (身份是元素原文逐字算 key,格式不同会让去重静默失效)。代码与文档都写明了。 二、剪贴板注入通道(修:平台还在调早期的独立 APK) - 改为按顺序尝试:设备端 Agent(com.example.deviceagent/.ClipActivity)→ 旧版独立 ClipInject 兜底;两个都没有时报"需要设备端 Agent"(不再只说 ClipInject) - 读回验证改成**轮询到 3 秒**:透明 Activity 要等窗口拿到焦点才写, 原来只睡 0.5s 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
443 lines
19 KiB
Python
443 lines
19 KiB
Python
"""设备端 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 base64
|
||
import hmac
|
||
import json
|
||
import os
|
||
import secrets
|
||
import time
|
||
|
||
from flask import Blueprint, jsonify, request, send_file
|
||
|
||
from config import APK_DIR
|
||
from core import device_pool, ledger
|
||
from core.db_config import meta_get, meta_set
|
||
from core.logger import get_logger
|
||
from web.auth import PERM_APKS, PERM_DEVICES, 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_devices(): # 返回字典(list_configured 只有 serial)
|
||
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:
|
||
# 用「按包名合并」的列表:同一个应用只出现一行(最新版),
|
||
# 设备上不会看到同一个 App 的 v1.2 / v1.3 两行
|
||
for a in context.apk_mgr.list_latest():
|
||
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/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/show-info", methods=["POST"])
|
||
@perm_required(PERM_DEVICES)
|
||
def api_agent_show_info():
|
||
"""让设备上的 Agent 打开「身份大字页」(监控页那个按钮调它)。
|
||
|
||
替代原来那套「推 HTML 让设备浏览器打开定位页」——那套在 MIUI 上要装浏览器、
|
||
页面还依赖设备能访问平台;Agent 是常驻 App,一条 `am start` 就出画面。
|
||
|
||
请求:{"serial": "…", "close": false}
|
||
返回:{ok, msg};设备没装 Agent 时明确报出来(不静默失败)
|
||
"""
|
||
from core.adb_helper import _adb
|
||
|
||
data = request.json or {}
|
||
serial = (data.get("serial") or "").strip()
|
||
close = bool(data.get("close"))
|
||
if not serial:
|
||
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||
|
||
if close:
|
||
args = ["--es", "close", "1"]
|
||
else:
|
||
# 设备信息:名称/IP/平台地址 —— Agent 用它们显示大字页
|
||
name, ip = "", ""
|
||
ip = serial.split(":")[0] if ":" in serial else ""
|
||
for d in device_pool.list_devices(): # 字典列表(list_configured 只有 serial)
|
||
if d.get("serial") == serial:
|
||
name = d.get("name") or ""
|
||
break
|
||
if not name:
|
||
# USB 设备的 serial 就是指纹:池里那条记录的 serial 是网络地址,
|
||
# 靠指纹才认得出来(否则大字页标题会显示一串裸序列号)
|
||
for d in device_pool.list_devices():
|
||
if d.get("fingerprint") and d["fingerprint"] == serial:
|
||
name = d.get("name") or ""
|
||
break
|
||
if not ip:
|
||
# USB 设备:平台不知道它的 IP,现问一下设备(只读命令,不影响设备)
|
||
out = _adb("-s", serial, "shell", "ip", "-4", "addr", "show", "wlan0")
|
||
import re as _re
|
||
m = _re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", out or "")
|
||
ip = m.group(1) if m else ""
|
||
# 平台地址:用操作者当前访问的地址;如果是 localhost,设备根本连不上 →
|
||
# 换成局域网地址(设备上显示 127.0.0.1 毫无意义)
|
||
server = request.host_url.rstrip("/")
|
||
if "127.0.0.1" in server or "localhost" in server:
|
||
from web.devices_api import _lan_ip
|
||
server = "http://%s:18050" % _lan_ip()
|
||
args = ["--es", "name", name or serial.split(":")[0],
|
||
"--es", "serial", serial,
|
||
"--es", "server", server]
|
||
if ip:
|
||
args += ["--es", "ip", ip]
|
||
# 本机账号台账(可选块):身份页顺带显示"这台登的是哪几个号"。
|
||
# **中文必须 base64**(§5.1:adb shell 直传中文/特殊字符会变形);
|
||
# `_adb` 是 argv 形式、不过 shell,base64 里的 +/= 安全 —— 将来谁把
|
||
# 这里改成 shell=True 就会静默坏掉,别改。
|
||
# 台账里没有这台设备时**整个 extra 不发**(设备端"没有这个 extra"=
|
||
# 本来就没登记,跟"发了但解不开"能区分开),原因随响应体的 warn 回给操作者。
|
||
warn = ""
|
||
try:
|
||
acc = ledger.agent_accounts(serial, device_name=name,
|
||
include_phone=bool(data.get("include_phone")))
|
||
if acc.get("accounts"):
|
||
b64, acc = _encode_accounts(acc)
|
||
if len(b64) + sum(len(a) for a in args) < _ARGS_CMD_MAX:
|
||
args += ["--es", "accounts_b64", b64]
|
||
_log.info("身份页附账号:%s 共 %s 个号(accounts_b64 %d 字节)",
|
||
serial, acc.get("shown"), len(b64))
|
||
else:
|
||
warn = "账号台账过大,未随身份页下发"
|
||
_log.warning("身份页未附账号:%s %s(%d 字节)", serial, warn, len(b64))
|
||
else:
|
||
warn = f"这台设备在账号台账里没有账号({acc.get('note') or '未匹配到设备名'})"
|
||
_log.warning("身份页未附账号:%s %s", serial, warn)
|
||
except Exception as e:
|
||
# 台账坏了不能把"显示身份"这个动作弄坏:跳过该 extra,只回告警
|
||
warn = f"账号台账读取失败:{e}"
|
||
_log.warning("身份页附账号失败 %s: %s", serial, e)
|
||
|
||
# 先点亮屏幕、解除锁屏:设备息屏时 am start 会把大字页开在锁屏**后面**,
|
||
# 人看到的还是一片黑(实测踩过)——这正是原来"定位"按钮里的"点亮屏幕"那一步
|
||
if not close:
|
||
try:
|
||
_adb("-s", serial, "shell", "input", "keyevent", "KEYCODE_WAKEUP")
|
||
_adb("-s", serial, "shell", "wm", "dismiss-keyguard")
|
||
except Exception as e:
|
||
_log.warning("点亮屏幕失败(不影响显示身份页): %s", e)
|
||
|
||
try:
|
||
out = _adb("-s", serial, "shell", "am", "start", "-n",
|
||
"com.example.deviceagent/.InfoActivity", *args) or ""
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": f"adb 执行失败: {e}"}), 500
|
||
|
||
low = out.lower()
|
||
if "unable to resolve intent" in low or "does not exist" in low or "error" in low:
|
||
return jsonify({"ok": False,
|
||
"error": "这台设备没装设备 Agent(com.example.deviceagent)——"
|
||
"可以从「应用管理 → 设备端应用商店」装,或用扫码配置"}), 400
|
||
_log.info("设备端 Agent %s: %s", "关闭身份页" if close else "打开身份页", serial)
|
||
resp = {"ok": True, "msg": "已关闭" if close else "已让设备显示身份信息"}
|
||
if not close and warn:
|
||
resp["warn"] = warn # 前端用 toast 提示(同二维码接口的 warn 惯例)
|
||
return jsonify(resp)
|
||
|
||
|
||
# accounts_b64 的预算:44 条 × ~90 字节 ≈ 4KB,base64 后 ≈ 5.3KB,余量充足。
|
||
# 再加一道**命令行总长**闸(Windows CreateProcess 上限 ~32767)。
|
||
ACCOUNTS_PAYLOAD_MAX = 20000
|
||
_ARGS_CMD_MAX = 28000
|
||
|
||
|
||
def _encode_accounts(acc):
|
||
"""把账号块压成 base64(超预算**按整条丢**,永不按字节切)。
|
||
|
||
按字节切会产出破损 JSON —— 设备端解析失败只能整块丢掉,
|
||
还不如少几条但能解开(`truncated`/`shown` 如实反映)。
|
||
返回 (base64 文本, 修正后的块)。
|
||
"""
|
||
acc = dict(acc or {})
|
||
rows = list(acc.get("accounts") or [])
|
||
total = acc.get("total", len(rows))
|
||
while True:
|
||
block = dict(acc, accounts=rows, shown=len(rows),
|
||
truncated=len(rows) < total)
|
||
b64 = base64.b64encode(
|
||
json.dumps(block, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
).decode("ascii")
|
||
if len(b64) <= ACCOUNTS_PAYLOAD_MAX or not rows:
|
||
return b64, block
|
||
rows = rows[:-1]
|
||
|
||
|
||
@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]})
|