12451 61adcdeaf9 fix(security)+fix(api)+docs: 越权面修复、登录门控与状态语义对齐、文档全面校对
- 越权面(全部 before/after 实测可复现,10 条):cabinets/filter 的 project_id/organization_id
  参数分支越权(客户可拿对方向柜全字段)、organization-tree 返回全量组织树、
  cabin-boards/compartments/cabinets 三个 options 无归属校验、batteries 无归属(改为经
  charge_orders→cabinets→projects 间接归属)、feedback 跨组织读且能改他人状态、
  GET /downloads/{id} 漏 user_id 过滤(跨用户元数据泄露)、
  POST /downloads/cleanup 全局删除所有组织导出(改客户侧只清自己)、
  /api/nodes/* 仅需登录(客户可读内网 IP 并注销节点)→ 收紧 role_level>=2;
  /api/devices/stats 同样收紧;新增共享闸门 auth::require_super_admin
- TCP 登录门控:下行投递改为"登录前白名单"(PRE_LOGIN_ALLOWED_ACTS),与上行同一规则
- 60 秒超时对齐协议:挂起项 TTL 120→60 + 值内嵌 deadline 精确判窗 + 超时 WARN(兼容老格式)
- 下发前在线判据改连接级认证标记 device:authed:{imei}(消除"管理端显示成功、设备收不到")
  ⚠️ device-server 与 api-server 必须同批发布
- 协议对齐:auth_str 登记校验提到限流之前、build_spec Topology 分支 clamp(15/8)、
  行号引用改小节引用、修 3 处错引小节
- 状态语义:compartments.status 只表占用,充满/故障由通道状态派生;柜级 status1/status2
  与开门按钮(柜级独立动作不联动);列注释对齐(cabinets.status 补 2=禁用、
  charge_orders.status 标注 2 保留未使用)
- 假绿单测修复:超时 SQL 抽常量 + 纯函数孪生体双向断言(方向写反必红)
- RSSI 两端统一为同源纯函数 5 档(小程序显示随之变化)+ test/rssi_signal.test.mjs
- nginx:扫码入口 abstract 限流(zone=scan_abstract_zone 30r/m burst=20)+ 主配置仓库镜像
- 文档:AGENTS.md 新增「踩过的坑」7 条教训 + 部署注意事项 11 条 + 文档口径优先级;
  docs/API.md 接口总表 80 条与 mod.rs 逐条一致、Redis 契约按代码重写、9 处事实错误修正;
  权威协议文档 v1.1.1(协议行为未变,仅修订文档自身的错误/矛盾/缺失)
2026-09-20 03:23:00 +08:00

541 lines
21 KiB
Rust
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.

//! 设备指令下发 — 通过 Redis 队列向设备发送指令
//!
//! API 服务不直接持有 TCP 连接,而是通过 Redis LIST `device:cmd:{imei}`
//! 推送指令,由设备服务消费并发送到设备。指令响应通过 `device:reply:{imei}`
//! 队列返回,API 服务通过 `PendingCommands` 匹配 msg_id 完成异步等待。
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::sync::{oneshot, Mutex};
use crate::error::AppError;
use crate::middleware::auth::{self, CurrentUser};
use crate::protocol::ServerResponse;
/// msg_id → oneshot Sender 映射表
#[derive(Clone)]
pub struct PendingCommands {
inner: Arc<Mutex<HashMap<u32, oneshot::Sender<Value>>>>,
}
impl PendingCommands {
pub fn new() -> Self {
Self { inner: Arc::new(Mutex::new(HashMap::new())) }
}
/// 注册等待中的指令
#[allow(dead_code)]
pub async fn insert(&self, msg_id: u32, tx: oneshot::Sender<Value>) {
self.inner.lock().await.insert(msg_id, tx);
}
/// 移除并返回指定 msg_id 的 sender(消费一次)
pub async fn remove(&self, msg_id: u32) -> Option<oneshot::Sender<Value>> {
self.inner.lock().await.remove(&msg_id)
}
}
/// 设备服务节点信息
#[derive(Debug, Clone)]
pub struct NodeInfo {
pub node_id: String,
pub ip: String,
pub tcp_port: u16,
/// RFC3339 格式的最后心跳时间,从 Redis 读取
pub last_heartbeat: Option<String>,
}
/// 节点注册表(Redis 存储,API 服务从 Redis 读取节点信息)
///
/// 设备服务通过 HSET 写入 `node:{node_id}` hash,
/// API 服务通过此模块读取。Redis 是节点信息的单一数据源。
#[derive(Clone)]
pub struct NodeRegistry {
redis: redis::aio::ConnectionManager,
}
impl NodeRegistry {
pub fn new(redis: redis::aio::ConnectionManager) -> Self {
Self { redis }
}
fn node_key(node_id: &str) -> String {
format!("node:{}", node_id)
}
/// 注册节点(写入 Redis HSET)
pub async fn register(&self, node_id: &str, ip: &str, tcp_port: u16) {
let key = Self::node_key(node_id);
let now = chrono::Utc::now().to_rfc3339();
let mut conn = self.redis.clone();
let _: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg("node_id").arg(node_id)
.arg("ip").arg(ip)
.arg("tcp_port").arg(tcp_port.to_string())
.arg("status").arg("online")
.arg("last_heartbeat").arg(&now)
.query_async(&mut conn)
.await;
// ponytail: HSET 不支持 EX,用 EXPIRE 设置 TTL
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(300_u64)
.query_async(&mut conn)
.await;
}
/// 移除节点(从 Redis 删除)
pub async fn remove(&self, node_id: &str) {
let key = Self::node_key(node_id);
let mut conn = self.redis.clone();
let _: Result<(), _> = redis::cmd("DEL")
.arg(&key)
.query_async(&mut conn)
.await;
}
/// 更新心跳时间戳
pub async fn heartbeat(&self, node_id: &str) -> bool {
let key = Self::node_key(node_id);
let mut conn = self.redis.clone();
let result: Result<(), _> = redis::cmd("HSET")
.arg(&key)
.arg("last_heartbeat")
.arg(chrono::Utc::now().to_rfc3339())
.query_async(&mut conn)
.await;
// 心跳同时刷新 TTL
let _: Result<(), _> = redis::cmd("EXPIRE")
.arg(&key)
.arg(300_u64)
.query_async(&mut conn)
.await;
result.is_ok()
}
/// 列出所有节点(从 Redis 读取,使用 SCAN 替代 KEYS 避免阻塞)
pub async fn list(&self) -> Vec<NodeInfo> {
let mut conn = self.redis.clone();
let keys = self.scan_keys(&mut conn, "node:*").await;
let mut nodes = Vec::with_capacity(keys.len());
for key in keys {
let mut conn = self.redis.clone();
let info: HashMap<String, String> = redis::cmd("HGETALL")
.arg(&key)
.query_async(&mut conn)
.await
.unwrap_or_default();
let node_id = info.get("node_id").cloned().unwrap_or_default();
if node_id.is_empty() {
continue; // ponytail: 跳过空记录
}
nodes.push(NodeInfo {
node_id,
ip: info.get("ip").cloned().unwrap_or_default(),
tcp_port: info.get("tcp_port").and_then(|p| p.parse().ok()).unwrap_or(0),
last_heartbeat: info.get("last_heartbeat").cloned(),
});
}
nodes
}
/// 使用 SCAN 命令安全地获取所有匹配模式的键(避免 KEYS 阻塞)
async fn scan_keys(&self, conn: &mut redis::aio::ConnectionManager, pattern: &str) -> Vec<String> {
let mut keys = Vec::new();
let mut cursor = 0i64;
loop {
let (next_cursor, batch): (i64, Vec<String>) = redis::cmd("SCAN")
.arg(cursor)
.arg("MATCH")
.arg(pattern)
.arg("COUNT")
.arg(100)
.query_async(conn)
.await
.unwrap_or_default();
keys.extend(batch);
if next_cursor == 0 {
break;
}
cursor = next_cursor;
}
keys
}
/// 检查设备是否**可下发**(即:是否存在一条**已 `login` 成功**的连接)。
///
/// # 为什么不是查 `device:{imei}`
/// 该键在**验签之前**就会被写(设备一发 `auth_str`/`login` 就写),断连后还能存活 300 秒 ⇒
/// 用它判在线会得到"管理端显示已下发成功、设备实际收不到"这种最难排查的故障。
///
/// 改用 **连接级认证标记** `device:authed:{imei}`(与 `device-server` 的**跨服务契约**,
/// 见 `device-server/src/tcp/connection.rs::auth_marker_key`):
/// - **写入**:向该连接投递 `login` 成功应答(含 `spec` + `suc=1`)时;
/// - **刷新**:该连接每次上行 / 每次下发;
/// - **删除**:连接断开、或被同一 IMEI 的新连接接管时(新连接须重新 `login`)。
///
/// 它是"**可下发**"的必要条件,不是充分条件:`device-server` 侧还有第二道门控
/// (未认证连接一律不投递,见 `PRE_LOGIN_ALLOWED_ACTS`)。
pub async fn is_device_online(&self, imei: &str) -> bool {
let key = auth_marker_key(imei);
let mut conn = self.redis.clone();
let exists: Result<bool, _> = redis::cmd("EXISTS")
.arg(&key)
.query_async(&mut conn)
.await;
exists.unwrap_or(false)
}
}
/// 连接级认证标记的键前缀 —— **必须与 `device-server` 的 `auth_marker_key` 完全一致**
/// (跨服务契约;两侧各有常量/键格式断言守住,见 `commands.rs` 与 `connection.rs` 的单测)。
const AUTH_MARKER_PREFIX: &str = "device:authed:";
/// 连接级认证标记的键。
fn auth_marker_key(imei: &str) -> String {
format!("{}{}", AUTH_MARKER_PREFIX, imei)
}
/// 应用共享状态
#[derive(Clone)]
#[allow(dead_code)]
pub struct AppState {
pub redis: redis::aio::ConnectionManager,
pub mysql: sqlx::MySqlPool,
pub pending_commands: PendingCommands,
pub nodes: NodeRegistry,
pub redis_url: String,
/// JWT 签名密钥 — 启动阶段从环境变量读取,缺失则 panic(禁止硬编码回退)
pub jwt_secret: Arc<str>,
}
/// 指令下发请求体
#[derive(Debug, Deserialize)]
pub struct CommandRequest {
pub act: String,
#[serde(default)]
pub msg_id: u32,
#[serde(flatten)]
pub params: Option<Value>,
}
/// 内部路由字段名:推入 `device:commands` 时附加,值 = 目标设备 IMEI。
///
/// 用途:device-server 据此决定把指令发给哪条连接;**下发前摘除,设备侧不可见**。
/// 为什么需要它:协议定稿后 `id` 只表示**动作目标**(柜级省略 / 一段=单板 / 两段=仓体级),
/// **不再含 IMEI**,所以设备身份必须另用内部字段传递。
pub const ROUTE_DEV_ID_FIELD: &str = "_dev_id";
/// 设备指令结构(序列化后推入 Redis)
///
/// 协议格式(v1.1):`id` 只表示**动作目标**,**不含 IMEI**(设备身份由 TCP 连接确定)
/// - **柜级**指令(open/pow_on/pow_off/reset 整柜):**不带 `id`**(字段省略)
/// - **单仓控板**指令(reset 单板):`仓控板`(如 `"01"`)
/// - **仓体级**指令(on/off):`仓控板-通道`(如 `"01-3"`)
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DeviceCommand {
pub act: String,
pub msg_id: u32,
/// 动作目标(柜级为 `None`),序列化为 "id"
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// 扩展参数(充电参数等)
#[serde(flatten)]
pub params: Option<Value>,
}
/// 向设备发送指令(通过 Redis 队列)
pub async fn send_device_command(
redis: &redis::aio::ConnectionManager,
dev_id: &str,
cmd: &DeviceCommand,
) -> Result<(), String> {
let mut json_val =
serde_json::to_value(cmd).map_err(|e| format!("指令序列化失败: {}", e))?;
// 内部路由字段:告知 device-server 这条指令发给哪台设备(下发前摘除)
json_val[ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id);
let json_str = json_val.to_string();
let mut conn = redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| format!("Redis 推入失败: {}", e))?;
Ok(())
}
/// 向设备发送响应(通过 Redis 队列)
#[allow(dead_code)]
pub async fn send_device_response(
redis: &redis::aio::ConnectionManager,
_dev_id: &str,
resp: &ServerResponse,
) -> Result<(), String> {
let json_str = resp.to_json();
let mut conn = redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| format!("Redis 推入失败: {}", e))?;
Ok(())
}
/// 本接口放行的**柜级/仓体级指令白名单**。
///
/// 为什么必须有白名单:`act` 是请求体里的自由字符串。原先不加限制 + 不做归属校验,
/// 任何持有 `device:operate` 的低权限用户都能对**全平台任意柜机**下发协议里的
/// **任何**动作(开门 / 断电 / 重启),属于越权高危操作(C2)。
///
/// 只放行管理端确有需要的动作:
/// - `open` / `pow_on` / `pow_off`:**柜级**(协议不带 `id`)
/// - `reset`:柜级;单仓控板时由调用方显式传 `id = "01"`
/// - `on` / `off`:**仓体级**,调用方必须显式传 `id = "01-3"`(协议要求)
const CABINET_COMMAND_ACTS: &[&str] = &["on", "off", "open", "pow_on", "pow_off", "reset"];
/// 协议 `id` 的合法形态校验:`id` 只表示**动作目标** ——
/// 一段 = 单仓控板(`"01"`)/ 两段 = 仓体级(`"01-3"`),每段 1~2 位数字。
///
/// 协议定稿后 `id` **不含 IMEI**(设备身份由 TCP 连接确定),因此 15 位 IMEI 一律非法。
fn is_valid_command_target(id: &str) -> bool {
/// 单段:1~2 位数字(仓控板 `"1"`/`"01"`,通道 `"3"`)
fn segment_ok(seg: &str) -> bool {
!seg.is_empty() && seg.len() <= 2 && seg.bytes().all(|b| b.is_ascii_digit())
}
let mut parts = id.split('-');
match (parts.next(), parts.next(), parts.next()) {
(Some(a), None, _) => segment_ok(a),
(Some(a), Some(b), None) => segment_ok(a) && segment_ok(b),
// 空串、三段及以上(含 IMEI 这类无分隔的长数字)一律拒绝
_ => false,
}
}
/// 从请求体参数里取出协议 `id`(动作目标),并从 `params` 中**摘除**。
///
/// - 取不到(柜级指令本就不带 `id`)→ `None`
/// - 由调用方显式给出:单板 `reset` → `"01"`;仓体级 `on`/`off` → `"01-3"`
/// - **绝不允许再用 IMEI 当 `id`**:下行路由靠内部字段
/// [`ROUTE_DEV_ID_FIELD`](`_dev_id`),协议 `id` 与设备身份无关
///
/// 为什么必须摘除:`DeviceCommand::params` 是 `#[serde(flatten)]`,
/// 若 `params` 里仍带 `id`,序列化时会与结构体显式 `id` 字段互相覆盖,
/// 可能把未校验的值(甚至非字符串)写进下行报文。
fn take_command_target(params: &mut Option<Value>) -> Result<Option<String>, AppError> {
let raw = params
.as_mut()
.and_then(|p| p.as_object_mut())
.and_then(|obj| obj.remove("id"));
match raw {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => {
if !is_valid_command_target(&s) {
return Err(AppError::BadRequest(format!(
"指令目标 id 非法: {}(应形如 \"01\" 或 \"01-3\",且不含 IMEI)",
s
)));
}
Ok(Some(s))
}
Some(_) => Err(AppError::BadRequest("指令目标 id 必须是字符串".into())),
}
}
/// POST /api/devices/:dev_id/command — 向指定设备下发指令
///
/// 安全链路(C2 加固,缺一不可):
/// 1. `dev_id`(IMEI) 反查 `cabinets.id`,查不到 → `NotFound`
/// 2. **组织归属校验** `verify_cabinet_ownership`(与 `routes/cabinets.rs` 同一套隔离规则;
/// 总管理员 role_level=2 豁免)
/// 3. 设备禁用校验
/// 4. `act` 白名单(见 [`CABINET_COMMAND_ACTS`])
/// 5. 设备必须在线(与 `/api/h5/charge/start` 一致,复用 `NodeRegistry::is_device_online`)
/// 6. 协议 `id` 只表示动作目标、由调用方显式给出,**绝不再填 IMEI**
pub async fn send_command(
user: CurrentUser,
State(state): State<AppState>,
Path(dev_id): Path<String>,
Json(mut req): Json<CommandRequest>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device:operate")?;
// 1. IMEI → 柜子ID(归属校验必须落在 cabinets.id 上,故先反查)
let cabinet_id: Option<i64> = sqlx::query_scalar("SELECT id FROM cabinets WHERE imei = ?")
.bind(&dev_id)
.fetch_optional(&state.mysql)
.await
.map_err(AppError::Database)?;
let cabinet_id =
cabinet_id.ok_or_else(|| AppError::NotFound(format!("设备 {} 不存在", dev_id)))?;
// 2. 组织数据隔离:非总管理员只能操作本组织项目下的柜子
crate::routes::scope_guard::verify_cabinet_ownership(&state.mysql, &user, cabinet_id).await?;
// 3. 设备禁用校验
let disabled: bool = sqlx::query_scalar(
r#"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2"#,
)
.bind(cabinet_id)
.fetch_one(&state.mysql)
.await
.map_err(AppError::Database)?;
if disabled {
return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into()));
}
// 4. `act` 白名单
if !CABINET_COMMAND_ACTS.contains(&req.act.as_str()) {
return Err(AppError::BadRequest(format!(
"不支持的指令 act={},本接口仅允许: {}",
req.act,
CABINET_COMMAND_ACTS.join("/")
)));
}
// 5. 设备必须在线(平台对不在线的设备一律不投递)
if !state.nodes.is_device_online(&dev_id).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 6. 协议 `id`(动作目标):只从请求体显式取,**不再把 IMEI 塞进去**
let id = take_command_target(&mut req.params)?;
// 仓体级动作(on/off)必须带目标,否则设备侧无法定位仓体(协议要求 `id` = 仓控板-通道)
if matches!(req.act.as_str(), "on" | "off") && id.is_none() {
return Err(AppError::BadRequest(format!(
"指令 act={} 必须显式给出目标 id(仓控板-通道,如 \"01-3\")",
req.act
)));
}
let cmd = DeviceCommand { act: req.act, msg_id: req.msg_id, id, params: req.params };
send_device_command(&state.redis, &dev_id, &cmd)
.await
.map_err(AppError::Internal)?;
tracing::info!(
"[command] 已推入队列 dev_id={} cabinet_id={} act={} id={:?}",
dev_id,
cabinet_id,
cmd.act,
cmd.id
);
Ok(Json(json!({ "suc": 1 })))
}
/// GET /api/devices/stats — 在线设备统计(**仅总管理员**)
///
/// 返回的是**全平台**设备服务节点数(`node_count`),属内部设施信息:
/// 原先只校验 `device:view`,任何客户账号登录后都能读到"我们有多少台设备服务节点"。
/// 与 `/api/nodes/*` 同一口径,收紧为 `role_level >= 2`(内部管理面,不对客户开放)。
pub async fn device_stats(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Value>, AppError> {
auth::require_super_admin(&user)?;
let nodes = state.nodes.list().await;
Ok(Json(json!({
"node_count": nodes.len(),
})))
}
// ---------------------------------------------------------------------------
// 单元测试:C2 协议 `id`(动作目标)的校验与摘除
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// 合法形态:一段 = 单仓控板(`"01"`)、两段 = 仓体级(`"01-3"`),每段 1~2 位数字
#[test]
fn test_is_valid_command_target_accepts_board_and_compartment() {
for ok in ["01", "1", "6", "01-3", "1-3", "12-34"] {
assert!(is_valid_command_target(ok), "{} 应为合法目标", ok);
}
}
/// IMEI(15 位纯数字)与其它畸形值一律非法 —— 协议 `id` **不含 IMEI**
#[test]
fn test_is_valid_command_target_rejects_imei_and_malformed() {
for bad in ["864720562557021", "", "-", "01-", "-3", "01-3-2", "0a", "123", "01 -3"] {
assert!(!is_valid_command_target(bad), "{} 应为非法目标", bad);
}
}
/// 显式给出 `id` 时取出,并把它从 `params` 中摘除
/// (否则 `#[serde(flatten)]` 会在序列化时覆盖结构体的显式 `id` 字段)
#[test]
fn test_take_command_target_extracts_and_removes_id() {
let mut params = Some(json!({ "id": "01-3", "duration": 60 }));
let id = take_command_target(&mut params).unwrap();
assert_eq!(id.as_deref(), Some("01-3"));
let obj = params.as_ref().and_then(|p| p.as_object()).unwrap();
assert!(!obj.contains_key("id"), "id 必须从 params 摘除");
assert_eq!(obj["duration"], 60, "其它参数必须原样保留");
}
/// 柜级指令不带 `id` → `None`(**绝不回填 IMEI**)
#[test]
fn test_take_command_target_absent_returns_none() {
assert_eq!(take_command_target(&mut None).unwrap(), None);
assert_eq!(take_command_target(&mut Some(json!({ "id": null }))).unwrap(), None);
assert_eq!(take_command_target(&mut Some(json!({ "board": 1 }))).unwrap(), None);
}
/// 非法目标(IMEI / 非字符串)必须报错,不得下发
#[test]
fn test_take_command_target_rejects_invalid_id() {
let mut imei = Some(json!({ "id": "864720562557021" }));
assert!(matches!(take_command_target(&mut imei), Err(AppError::BadRequest(_))));
let mut numeric = Some(json!({ "id": 3 }));
assert!(matches!(take_command_target(&mut numeric), Err(AppError::BadRequest(_))));
}
/// **跨服务契约**:连接级认证标记的键必须与 `device-server` 的 `auth_marker_key`
/// 逐字一致(那边同样有 `test_auth_marker_key_format_contract` 守门)——
/// 不一致的后果是"所有设备都被判为不在线、指令全下发不出去"。
#[test]
fn test_auth_marker_key_matches_device_server_contract() {
assert_eq!(AUTH_MARKER_PREFIX, "device:authed:");
assert_eq!(auth_marker_key("864720562557021"), "device:authed:864720562557021");
// 不能退化成旧的在线键(那正是"假在线"的根因)
assert_ne!(auth_marker_key("864720562557021"), "device:864720562557021");
}
/// `/api/devices/stats`(全平台节点数)属内部设施面:只有总管理员可读
#[test]
fn test_device_stats_requires_super_admin() {
let make = |role_level: i32| CurrentUser {
user_id: 1,
phone: "13800138000".into(),
permissions: std::collections::HashSet::new(),
role_level,
organization_id: Some(7),
scope: crate::middleware::auth::UserScope::default(),
};
assert!(auth::require_super_admin(&make(2)).is_ok(), "总管理员放行");
assert!(auth::require_super_admin(&make(1)).is_err(), "企业管理员必须被拒");
assert!(auth::require_super_admin(&make(0)).is_err(), "操作员必须被拒");
}
}