From dad1af8b0bdfbfc586439815a5c5b5051c3ae472 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 13 Sep 2026 14:40:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E5=A4=87=E7=AB=AF=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=95=86=E5=BA=97=EF=BC=88=E5=B9=B3=E5=8F=B0=E4=BE=A7?= =?UTF-8?q?=EF=BC=89=E2=80=94=E2=80=94=20Agent=20=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=E6=8B=89=E6=B8=85=E5=8D=95/=E4=B8=8B=E8=BD=BD/=E5=AE=89?= =?UTF-8?q?=E8=A3=85=EF=BC=8C=E7=BB=95=E5=BC=80=20MIUI=20=E7=9A=84=20USB?= =?UTF-8?q?=20=E5=AE=89=E8=A3=85=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景:`adb install` 在 MIUI 上被「USB 安装」拦下(USER_RESTRICTED),必须人工点确认; 而**由设备上的 App 自己调系统安装器**走的是普通应用安装流程,不触发那道拦截。 平台侧实现(设备端 Agent APK 在另一个仓库,契约见 doc/DEVICE_AGENT.md): - web/device_agent_api.py(新): * 设备侧 `/api/device/agent/{bootstrap,apk/,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 关键业务) --- core/models.py | 20 +++ core/system_backup.py | 2 +- doc/API.md | 56 +++++++- doc/DATA_MODEL.md | 4 + doc/DEVICE_AGENT.md | 217 +++++++++++++++++++++++++++++ doc/README.md | 2 + static/admin/apps.js | 74 ++++++++++ static/admin/base.js | 1 + templates/admin/monitor.html | 28 ++++ web/__init__.py | 5 +- web/device_agent_api.py | 262 +++++++++++++++++++++++++++++++++++ 11 files changed, 668 insertions(+), 3 deletions(-) create mode 100644 doc/DEVICE_AGENT.md create mode 100644 web/device_agent_api.py diff --git a/core/models.py b/core/models.py index 9b706c0..b053c2b 100644 --- a/core/models.py +++ b/core/models.py @@ -349,6 +349,26 @@ class AgentAction(db.Model): updated_at = db.Column(db.String(20), default="") +class DeviceInstallLog(db.Model): + """设备端应用商店的下载/安装记录(设备上的 Agent 上报,平台侧展示)。 + + 平台**不主动**发起这条通道的安装:设备自己拉清单、自己下载、自己调 + PackageInstaller 安装——这样走的是普通应用安装流程,不会触发 MIUI 针对 + `adb install` 的「USB 安装」拦截(USER_RESTRICTED)。 + """ + __tablename__ = "device_install_log" + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + fingerprint = db.Column(db.String(120), default="") # ro.serialno,识别物理设备 + serial = db.Column(db.String(120), default="") # 上报时的地址(可能换过) + device_name = db.Column(db.String(80), default="") # 平台侧的名称(快照) + apk_id = db.Column(db.String(32), default="") + package_name = db.Column(db.String(200), default="") + version_name = db.Column(db.String(50), default="") + action = db.Column(db.String(20), default="") # download / install_ok / install_fail + message = db.Column(db.String(500), default="") # 失败原因等 + created_at = db.Column(db.String(20), default="") + + class AgentConversation(db.Model): """AI 控制台会话:整个消息序列以 JSON 存在一行里(单会话几十 KB,够用)。""" __tablename__ = "agent_conversation" diff --git a/core/system_backup.py b/core/system_backup.py index 68690d7..66c01dc 100644 --- a/core/system_backup.py +++ b/core/system_backup.py @@ -54,7 +54,7 @@ TABLE_LABELS = { "custom_action": "自定义动作", "apk_file": "APK 记录", "device": "设备池", "pending_device": "待连接设备", "agent_conversation": "AI 会话", "agent_experience": "经验库", "experience_audit": "经验巡检", - "agent_action": "动作库", + "agent_action": "动作库", "device_install_log": "设备端安装记录", } _STAGE_TTL = 1800 # 导入暂存有效期(秒) diff --git a/doc/API.md b/doc/API.md index d274531..bd165d3 100644 --- a/doc/API.md +++ b/doc/API.md @@ -219,6 +219,26 @@ | POST | `/api/system/backup/preview` | 上传备份校验预览(multipart,字段 `file`) | | POST | `/api/system/backup/apply` | 应用恢复(重启生效) | +### 2.11 device_agent(`web/device_agent_api.py`) + +**设备端专用**(无登录会话,靠 `X-Device-Token` 鉴权;平台未启用时统一 404): + +| 方法 | 路径 | 功能 | +|------|------|------| +| GET | `/api/device/agent/bootstrap` | 设备拉自己的身份 + 可安装应用清单 | +| GET | `/api/device/agent/apk/` | 下载 APK 文件 | +| POST | `/api/device/agent/report` | 上报下载/安装结果 | + +**管理端**(登录 + 应用管理权限): + +| 方法 | 路径 | 功能 | +|------|------|------| +| GET | `/api/agent-store/config` | 读配置(含设备令牌) | +| POST | `/api/agent-store/config` | 启用/停用、重置令牌 | +| GET | `/api/agent-store/logs` | 设备端安装记录 | + +> 完整协议、鉴权与版本约定见 [DEVICE_AGENT.md](DEVICE_AGENT.md)。 + --- ## 3. 认证与页面 @@ -656,7 +676,41 @@ multipart 上传 `.zip` 或 `.db`(字段 `file`)→ 校验并暂存: --- -## 14. 非 JSON 响应汇总 +## 14. 设备端应用商店 + +**设备侧**三个接口(`bootstrap` / `apk` / `report`)的完整协议、鉴权、错误码与版本约定 +见 [DEVICE_AGENT.md](DEVICE_AGENT.md)——那份文档同时是设备端 APK 仓库的对接契约。 + +**管理侧**(登录 + 应用管理权限): + +### GET /api/agent-store/config + +```json +{"ok": true, "enabled": false, "token": "…", "token_created_at": "2026-09-13 14:38:11", + "api_version": 1, "base_url_hint": "http://192.168.20.220:18050"} +``` + +`base_url_hint` 由请求的 Host 回填,直接可作为设备端要填的平台地址。 + +### POST /api/agent-store/config + +`{"enabled": true}` 启用(并自动生成令牌);`{"regenerate_token": true}` 重置令牌 +(旧令牌立即失效)。返回 `{"ok": true, "enabled": …, "token": …}`。 + +### GET /api/agent-store/logs?limit=50 + +```json +{"ok": true, "logs": [{"id": 1, "device_name": "A08", "serial": "192.168.20.100:5555", + "fingerprint": "…", "apk_id": "56b114aa", "package_name": "com.example.clipinject", + "version_name": "1.0", "action": "install_fail", "message": "MIUI 拦截未确认", + "created_at": "2026-09-13 14:40:00"}]} +``` + +`action` ∈ `download` / `install_ok` / `install_fail`;记录表最多保留 500 条(自动裁旧)。 + +--- + +## 15. 非 JSON 响应汇总 | 方法 | 路径 | 响应类型 | 说明 | |------|------|---------|------| diff --git a/doc/DATA_MODEL.md b/doc/DATA_MODEL.md index a7513dc..66ca311 100644 --- a/doc/DATA_MODEL.md +++ b/doc/DATA_MODEL.md @@ -43,6 +43,7 @@ | 10 | `agent_experience` | 经验库(任务级配方) | | 11 | `experience_audit` | 经验巡检结论 | | 12 | `agent_action` | 动作库(命名动作) | +| 13 | `device_install_log` | 设备端应用商店的下载/安装记录(设备上报,见 [DEVICE_AGENT.md](DEVICE_AGENT.md)) | > 2026-09-13 之前,`app_meta` 与 4 张 `agent_*` 表是各模块里的裸 `CREATE TABLE` > (不进模型层)。迁 MySQL 时那批 SQL 的 `AUTOINCREMENT`/`TEXT DEFAULT ''`/`TEXT PRIMARY KEY` @@ -232,6 +233,9 @@ UTF-8 等价于字节序)。 | `discovery_interval` | 扫描周期秒(10-3600) | 同上 | | `discovery_port` | adb 探测端口(1-65535) | 同上 | | `discovery_auto_claim` | 指纹匹配时自动认领(`"1"`/`"0"`,默认关) | 同上 | +| `agent_store_enabled` | 设备端应用商店开关(`"1"`/`"0"`,默认关) | 应用管理页 | +| `agent_device_token` | **设备端令牌**(Agent 调设备接口用;属凭据) | 同上(启用时自动生成,可重置) | +| `agent_device_token_at` | 令牌生成/重置时间 | 同上 | | `deployment_env` | **库环境标签**(`dev`/`prod`),启动时与 `.env` 比对 | `core/db_config.py`(首次连接)/ 迁移脚本 | | `deployment_id` | 库唯一标识(uuid),用于识别"这份备份来自哪个库" | 同上 | | `deployment_claimed_at` | 标签写入时间 | 同上 | diff --git a/doc/DEVICE_AGENT.md b/doc/DEVICE_AGENT.md new file mode 100644 index 0000000..d4471bf --- /dev/null +++ b/doc/DEVICE_AGENT.md @@ -0,0 +1,217 @@ +# 设备端 Agent 接口契约(DEVICE_AGENT) + +> 适用读者:写**设备端 Agent APK**(另一个仓库)的人,以及改平台侧这组接口的人。 +> 相关:[API.md](API.md)(管理端接口)、[DEPLOY.md](DEPLOY.md)(部署)、[ARCHITECTURE.md](ARCHITECTURE.md)。 + +**这份文档是两个仓库之间的契约。** 平台侧实现见 `web/device_agent_api.py`; +设备端实现见另一个仓库。**改协议必须同时改两边**(见 §6 版本约定)。 + +--- + +## 1. 为什么有这组接口 + +平台通过 **adb** 驱动设备(点击/输入/装应用……),但有三件事 adb 干不好,需要设备上装一个 App: + +| 需求 | adb 的困境 | Agent 的做法 | +|------|-----------|-------------| +| 写系统剪贴板 | Android 10+ 禁止后台/shell 写剪贴板;u2 的 `set_clipboard` 会"成功但内容被丢弃" | 启动一个**透明 Activity**(有焦点才能写),写完立即退出 | +| 安装 APK | MIUI 会拦 `adb install`(`USER_RESTRICTED`),要人工点「继续安装」 | **App 自己调系统安装器** → 走普通应用安装流程,**不触发那道 USB 拦截** | +| 显示设备身份 | 现在要把一个 HTML 大字页推给设备浏览器打开,依赖浏览器 | 全屏大字显示:设备名/IP/序列号/平台地址 | + +其中「装应用」是**设备主动来取**(拉清单 → 选 → 下载 → 装),所以平台开了一组 +**设备专用接口**;「写剪贴板」「显示身份」是**平台推指令**,仍走 adb(§5)。 + +--- + +## 2. 接入流程(设备端视角) + +``` +① 一次性:平台在「工具 → 应用管理 → 📱 设备端应用商店」启用,拿到 + 接入地址 base_url(如 http://192.168.20.220:18050)和设备令牌 token + —— 这两个值可以通过 adb 一次性推给设备(见 §5.3),不必在每台手机上手输 + +② 开机/定时:GET {base_url}/api/device/agent/bootstrap + 带上 X-Device-Token / X-Device-Fingerprint + → 拿到「我是谁」(平台分配的设备名)+「有哪些应用可以装」 + +③ 用户在手机上选一个应用: + GET {base_url}/api/device/agent/apk/{id} → 下载 APK 到应用私有目录 + +④ 调系统安装器安装(PackageInstaller / ACTION_VIEW + FileProvider) + —— 会弹系统安装确认框,用户在屏幕上点一下即可 + +⑤ 回报结果:POST {base_url}/api/device/agent/report + (download / install_ok / install_fail + 失败原因) + 平台在「设备端应用商店」面板能看到记录 +``` + +> Agent 需要 `INTERNET` 与 `REQUEST_INSTALL_PACKAGES` 权限;还需要用户在系统设置里 +> 给"安装未知应用"授权(这是 App 自装的**唯一**前置条件,比 MIUI 的 USB 拦截好过得多)。 + +--- + +## 3. 接口规格 + +所有接口: +- **不需要登录会话**,靠 `X-Device-Token` 鉴权 +- 请求/响应均为 UTF-8 JSON(下载接口除外) +- **功能默认关闭**:平台侧未启用时,全部返回 `404 {"ok":false,"error":"not found"}` + (不暴露接口是否存在) + +### 3.1 通用请求头 + +| 头 | 必填 | 说明 | +|----|------|------| +| `X-Device-Token` | ✅ | 平台生成的设备令牌 | +| `X-Device-Fingerprint` | 建议 | `ro.serialno`。**平台靠它认出"你是哪台设备"**(换 IP 也能认回原名) | +| `X-Device-Serial` | 可选 | 当前 adb 地址(`IP:5555`),指纹认不出时的兜底 | + +错误码: + +| 状态 | 含义 | 设备端该怎么办 | +|------|------|---------------| +| 404 | 平台未启用设备端商店(或接口不存在) | 提示管理员去平台开启;不要重试轰炸 | +| 401 | 令牌不对/缺失 | 提示重新配置令牌(管理员重置后要在设备上更新) | +| 500 | 平台内部错误 | 退避重试 | + +### 3.2 `GET /api/device/agent/bootstrap` + +拉设备身份 + 可安装应用清单。建议开机后拉一次 + 每次打开"商店"界面时拉一次。 + +**响应** +```json +{ + "ok": true, + "agent_api_version": 1, + "server_time": "2026-09-13 14:38:11", + "platform": { "url": "http://192.168.20.220:18050" }, + "device": { + "fingerprint": "woijo7v4sgnrhqb6", + "serial": "192.168.20.100:5555", + "name": "A08", + "known": true + }, + "apks": [ + { + "id": "56b114aa", + "display_name": "ClipInject", + "package_name": "com.example.clipinject", + "version_name": "1.0", + "version_code": 1, + "size": 11615, + "upload_time": "2026-09-09 13:49:06", + "download_path": "/api/device/agent/apk/56b114aa" + } + ] +} +``` + +- `device.name`:**平台给这台设备起的名字**(如 `A08`)—— 身份显示界面直接用它 +- `device.known=false`:这台设备还没登记进平台设备池(指纹没匹配上),`name` 为空; + 设备端应显示"未登记",不要假装自己有名字 +- `platform.url`:按**设备实际请求到的地址**回填,可直接用于拼下载地址 +- `apks[].download_path` 是相对路径,拼 `platform.url` 即完整 URL + +### 3.3 `GET /api/device/agent/apk/{apk_id}` + +下载 APK 文件本体(`application/vnd.android.package-archive`)。 + +- 成功:`200` + 文件流 +- `404`:id 不存在或平台侧文件已删(**注意与"未启用"的 404 区分:看响应体**, + 未启用时是 JSON `{"ok":false,...}`,文件缺失时也是 JSON —— 设备端拿到非 `application/...` 的响应就视为失败) + +### 3.4 `POST /api/device/agent/report` + +回报下载/安装结果,平台侧留档(面板可见)。 + +**请求体** +```json +{ + "apk_id": "56b114aa", + "action": "download | install_ok | install_fail", + "package_name": "com.example.clipinject", + "version_name": "1.0", + "message": "失败原因(install_fail 时必填,越具体越好)" +} +``` + +**响应**:`{"ok": true}`;`action` 非法 → `400`。 + +> **失败原因请写人话**:平台面板直接显示 `message`。 +> 例如「MIUI 拦截:用户未确认」「安装包解析失败」「存储空间不足」。 + +--- + +## 4. 平台侧配置(管理端) + +「工具 → 应用管理 → 📱 设备端应用商店」: + +| 操作 | 接口 | +|------|------| +| 读配置(含令牌) | `GET /api/agent-store/config` | +| 启用/停用 | `POST /api/agent-store/config` `{"enabled": true}` | +| 重置令牌 | `POST /api/agent-store/config` `{"regenerate_token": true}` | +| 安装记录 | `GET /api/agent-store/logs?limit=50` | + +- 三个接口都需要**登录 + 应用管理权限** +- **停用**不影响已装的 App,只是设备端拿不到清单(返回 404) +- **重置令牌**后旧令牌立即失效,所有设备要在 Agent 里更新 + +--- + +## 5. 平台 → 设备的指令(走 adb,不走网络) + +这些**没有 HTTP 接口**,由平台用 `adb shell am start` 拉起设备上的 Activity。 + +### 5.1 剪贴板注入(已在用,务必保持兼容) + +```bash +adb -s shell am start -n /.ClipActivity --es text_b64 +``` + +- **必须 base64**:adb shell 直传中文/特殊字符会变形 +- 必须用**透明 Activity**(有焦点才能写剪贴板),写完**立即退出** +- 平台判定失败的方式:`am start` 输出里有 `unable to resolve Intent` / `does not exist` + → 视为"设备未安装 Agent" + +### 5.2 设备身份显示(待设备端实现) + +```bash +adb -s shell am start -n /.InfoActivity \ + --es name "A08" --es ip "192.168.20.100" --es serial "192.168.20.100:5555" \ + --es server "http://192.168.20.220:18050" +# 关闭:--es close 1 +``` + +要求:全屏大字、再次调用可覆盖内容、`close` 可关闭、不驻留前台。 + +### 5.3 一次性配置下发(建议设备端实现) + +```bash +adb -s shell am start -n /.ConfigActivity \ + --es server "http://192.168.20.220:18050" --es token "<设备令牌>" +``` + +有了它,8 台设备不用一台台手输地址和令牌。 + +--- + +## 6. 版本与兼容 + +- `agent_api_version`(当前 **1**)在每次 `bootstrap` 响应里返回 +- **设备端**:启动时对比自己实现的版本,不一致要在日志里明确记下来(不要静默) +- **平台端**: + - 加字段 / 加接口 = 小版本,设备端忽略未知字段即可 + - 改已有字段语义 / 删字段 / 改鉴权方式 = **大版本**,两边必须同时改 +- 改了协议,**必须同时改这份文档的两个仓库版本**(本文件是平台侧那一份) + +--- + +## 7. 安全须知 + +- **令牌是凭据**:等于"能读平台的应用清单和 APK 文件"。别写进日志、别外传、 + 别提交到 git(平台侧存在 `app_meta.agent_device_token`,会随备份一起走) +- 平台侧这组接口**只读**:只能读清单和下载 APK,**不能**触发任务、不能改设备池、 + 不能读配置或密钥 +- 生产环境建议:令牌泄露时立即在面板点「重置令牌」 +- 设备端**不需要也不应该**拿 admin 账号密码 diff --git a/doc/README.md b/doc/README.md index aaf803f..10e742a 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,6 +18,7 @@ | [MCP_DESIGN.md](MCP_DESIGN.md) | **MCP 设计文档**:边界划分、错误码、白名单/审计设计、演进方向 | 平台开发者 | | [AI_CONSOLE.md](AI_CONSOLE.md) | **AI 控制台**:会话/SSE、经验库、动作库、巡检、Markdown 渲染、推理链、token 统计 | 使用者、平台开发者 | | [AI_TASK_GEN.md](AI_TASK_GEN.md) | **AI 建任务**:设计稿与里程碑(P0 未实现,属规划) | 平台开发者 | +| [DEVICE_AGENT.md](DEVICE_AGENT.md) | **设备端 Agent 接口契约**:应用商店的设备专用接口(清单/下载/上报)、adb 指令协议、版本约定 —— 与设备端 APK 仓库共享的契约 | 设备端开发者、平台开发者 | | [DEPLOY.md](DEPLOY.md) | **部署与运维**:环境准备、生产容器、数据备份导出/导入、故障排查 | 运维、部署者 | | [DEVELOPMENT.md](DEVELOPMENT.md) | **开发手册**:git 流程、技术红线、本地开发与调试、常见开发任务、文档同步约定 | 所有开发者 | | [STF_REMOVAL.md](STF_REMOVAL.md) | **历史记录**:摘除 OpenSTF 的迁移过程(阶段 0-3) | 追溯背景时参考 | @@ -56,6 +57,7 @@ | 任务类型 / 步骤 schema | [TASK_DEV.md](TASK_DEV.md) | | 常驻线程 / 进程装配 | [ARCHITECTURE.md](ARCHITECTURE.md) §线程与并发 | | MCP 工具 | [MCP.md](MCP.md) + [MCP_DESIGN.md](MCP_DESIGN.md) | +| 设备端 Agent(APK) | [DEVICE_AGENT.md](DEVICE_AGENT.md) | | 对外接入约定 | [staffdeck/KNOWLEDGE_BASE.md](staffdeck/KNOWLEDGE_BASE.md) | | 暂缓项 / 已知问题 | [backlog/TODO.md](backlog/TODO.md)(完成时移出并同步相关文档) | diff --git a/static/admin/apps.js b/static/admin/apps.js index 5d496da..c8f5efb 100644 --- a/static/admin/apps.js +++ b/static/admin/apps.js @@ -262,3 +262,77 @@ function closeInstallModal(){ } + +// ================== 设备端应用商店(Agent 直连安装) ================== +// 设备上的 Agent 拉 /api/device/agent/bootstrap 拿清单 → 自己下载 → 自己调系统安装器。 +// 因为安装由设备上的 App 发起,走普通应用安装流程,不会触发 MIUI 的「USB 安装」拦截。 +let _storeToken=''; + +async function loadAgentStore(){ + try{ + const cfg=await (await fetch('/api/agent-store/config',{headers:_csrfHeaders()})).json(); + if(cfg&&cfg.ok){ + _storeToken=cfg.token||''; + const en=document.getElementById('store-enabled'); + if(en)en.checked=!!cfg.enabled; + const u=document.getElementById('store-url'); + if(u)u.value=cfg.base_url_hint||''; + const t=document.getElementById('store-token'); + if(t)t.value=_storeToken||'(未生成,勾选启用后自动生成)'; + } + const d=await (await fetch('/api/agent-store/logs?limit=30',{headers:_csrfHeaders()})).json(); + renderStoreLogs((d&&d.logs)||[]); + }catch(e){ /* 面板没打开时静默 */ } +} + +function renderStoreLogs(logs){ + const tb=document.getElementById('tb-store-logs'); + if(!tb)return; + if(!logs.length){ + tb.innerHTML='暂无记录(设备还没从商店装过东西)'; + return; + } + const act={download:'⬇ 已下载',install_ok:'✅ 安装成功',install_fail:'❌ 安装失败'}; + tb.innerHTML=logs.map(l=>{ + const app=[l.display_name||l.package_name||l.apk_id, + l.version_name?('v'+l.version_name):''].filter(Boolean).join(' '); + const cls=l.action==='install_fail'?'style="color:#b42318"':(l.action==='install_ok'?'style="color:#067647"':''); + return ''+esc(l.created_at||'')+''+esc(l.device_name||l.serial||'-')+''+ + ''+esc(_cut(app,30))+''+ + ''+esc(act[l.action]||l.action||'')+''+ + ''+esc(_cut(l.message||'',60))+''; + }).join(''); +} + +async function toggleAgentStore(on){ + const r=await apiPost('/api/agent-store/config',{enabled:!!on}); + if(!r)return; + if(r.ok){ + showToast(on?'设备端商店已启用':'设备端商店已停用','success'); + loadAgentStore(); + }else{ + showToast(r.error||'设置失败','error'); + document.getElementById('store-enabled').checked=!on; + } +} + +async function regenStoreToken(){ + if(!confirm('重置设备令牌?\n\n所有已配置该令牌的设备会立即失效,需要在设备端 Agent 里重新填新令牌。'))return; + const r=await apiPost('/api/agent-store/config',{regenerate_token:true}); + if(r&&r.ok){showToast('令牌已重置','success');loadAgentStore();} + else if(r)showToast(r.error||'重置失败','error'); +} + +function copyStoreToken(){ + if(!_storeToken){showToast('还没有令牌(先启用设备端商店)','error');return;} + const done=()=>showToast('令牌已复制','success'); + if(navigator.clipboard&&navigator.clipboard.writeText){ + navigator.clipboard.writeText(_storeToken).then(done).catch(()=>{ + window.prompt('手动复制令牌:',_storeToken); + }); + }else{ + window.prompt('手动复制令牌:',_storeToken); + } +} + +function _cut(s,n){s=String(s==null?'':s);return s.length>n?(s.slice(0,n-1)+'…'):s;} diff --git a/static/admin/base.js b/static/admin/base.js index 481d3de..5bb4fd4 100644 --- a/static/admin/base.js +++ b/static/admin/base.js @@ -123,6 +123,7 @@ function showSubTab(tabId, name){ tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name)); tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name)); if(name==='groups' && typeof loadGroups==='function') loadGroups(); + if(name==='apks' && typeof loadAgentStore==='function') loadAgentStore(); if(name==='devpool' && typeof loadDevPool==='function'){ loadDevPool(); if(typeof loadDiscovery==='function'){ diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html index 0b6dba9..a385849 100644 --- a/templates/admin/monitor.html +++ b/templates/admin/monitor.html @@ -788,6 +788,34 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
+ + +
📱 设备端应用商店(Agent 直连安装)
+
+ 设备上装的 Agent 自己来拉这份应用清单 → 在手机上选一个 → 自己下载 → 自己调系统安装器安装。 + 因为安装由设备上的 App 发起,走的是普通应用安装流程, + 不会触发 MIUI 针对 adb 安装的「USB 安装」拦截(那正是 adb install 报 USER_RESTRICTED 的原因)。 + 适合"人就在机器旁边"的场景(本来就要点一次安装确认)。平台批量推送仍用上面的「安装」按钮。 +
接口契约见 doc/DEVICE_AGENT.md;令牌属于凭据,别外传。 +
+
+ + + + + + + +
+ + + + + + +
时间设备应用动作说明
暂无记录(设备还没从商店装过东西)
diff --git a/web/__init__.py b/web/__init__.py index d38fc73..58ca3ea 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -10,6 +10,7 @@ apks — 应用管理 tailscale — Tailscale 管理 system — 系统数据备份导出/导入 + device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置 """ from flask import Blueprint @@ -28,6 +29,8 @@ def register_blueprints(app): from .agent_api import bp as agent_bp _agent_mod.set_app(app) from .system_api import bp as system_bp + from .device_agent_api import bp as devagent_bp for bp in (auth_bp, monitor_bp, tasks_bp, admin_bp, tools_bp, - devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp): + devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp, + devagent_bp): app.register_blueprint(bp) diff --git a/web/device_agent_api.py b/web/device_agent_api.py new file mode 100644 index 0000000..357ac93 --- /dev/null +++ b/web/device_agent_api.py @@ -0,0 +1,262 @@ +"""设备端 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/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]})