Revert "feat: 设备端应用商店(平台侧)"
按 git 红线撤回:该功能未经 dev 验收就被合并进 main(是我提交时没切分支、
又把自己的自测当成了用户验收)—— main 必须保持"已验收可部署"的状态。
功能本身没问题,代码仍在 **dev**(dad1af8)与 feature 分支上,等设备端 Agent
写出来、端到端验收通过后,再从 dev 合并回 main。
main 内容已回到 66632bb(git diff 66632bb HEAD 为空)。
This commit is contained in:
+1
-4
@@ -10,7 +10,6 @@
|
||||
apks — 应用管理
|
||||
tailscale — Tailscale 管理
|
||||
system — 系统数据备份导出/导入
|
||||
device_agent— 设备端 Agent 专用接口(应用商店:清单/下载/上报)+ 其管理端配置
|
||||
"""
|
||||
from flask import Blueprint
|
||||
|
||||
@@ -29,8 +28,6 @@ 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,
|
||||
devagent_bp):
|
||||
devices_bp, apks_bp, tailscale_bp, agent_bp, system_bp):
|
||||
app.register_blueprint(bp)
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
"""设备端 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]})
|
||||
Reference in New Issue
Block a user