diff --git a/gpui-app/src/aliyun.rs b/gpui-app/src/aliyun.rs index 7706a1a..a494a5c 100644 --- a/gpui-app/src/aliyun.rs +++ b/gpui-app/src/aliyun.rs @@ -1,18 +1,27 @@ -//! 阿里云 IoT 平台一键接入预设(对标官方 MQTTX 的阿里云模板)。 +//! 阿里云一键接入预设(两种鉴权模式对应两款产品)。 //! -//! Token 模式鉴权参数: +//! Token 模式(云消息队列 MQTT 版)鉴权参数: +//! - 接入点: `{instanceId}.mqtt.aliyuncs.com` //! - clientId: `{GroupId}@@@{DeviceId}` //! - username: `Signature|{AccessKeyId}|{InstanceId}` //! - password: base64(HMAC-SHA1(AccessKeySecret, clientId)) +//! +//! 一机一密模式(物联网平台 MQTT 直连,官方规范): +//! - 接入点: `{productKey}.iot-as-mqtt.{region}.aliyuncs.com` +//! - clientId: `{ProductKey}.{DeviceName}|securemode=2,signmethod=hmacsha256,timestamp={ts}|` +//! - username: `{DeviceName}&{ProductKey}` +//! - password: HMAC-SHA256(DeviceSecret, "clientId{ProductKey}.{DeviceName}deviceName{DeviceName}productKey{ProductKey}timestamp{ts}") use base64::Engine as _; use hmac::{Hmac, Mac}; use serde::{Deserialize, Serialize}; use sha1::Sha1; +use sha2::Sha256; use crate::model::{ConnectionConfig, ProtocolVersion, TransportKind}; type HmacSha1 = Hmac; +type HmacSha256 = Hmac; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] @@ -58,11 +67,13 @@ pub struct AliyunPreset { #[serde(default)] pub group_id: String, - // 共用 + // 共用:Token 模式下是 DeviceId;一机一密模式下是 DeviceName pub device_id: String, // 一机一密模式 #[serde(default)] + pub product_key: String, + #[serde(default)] pub device_secret: String, } @@ -79,11 +90,12 @@ impl AliyunPreset { access_key_secret: String::new(), group_id: String::new(), device_id: format!("device_{}", &uuid::Uuid::new_v4().simple().to_string()[..8]), + product_key: String::new(), device_secret: String::new(), } } - /// HMAC-SHA1(secret, client_id) 的 Base64 编码。 + /// HMAC-SHA1(secret, client_id) 的 Base64 编码(Token 模式签名)。 pub fn signature(secret: &str, client_id: &str) -> Result { let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).map_err(|e| format!("HMAC 初始化失败: {e}"))?; @@ -91,16 +103,35 @@ impl AliyunPreset { Ok(base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())) } - /// 接入点 host:`{instanceId}.mqtt.iothub.aliyuncs.com` - /// (实例级接入点在各区域通用;region 为空时同样可用) + /// HMAC-SHA256(secret, msg) 的小写 hex(一机一密签名)。 + fn hmac_sha256_hex(secret: &str, msg: &str) -> Result { + let mut mac = + HmacSha256::new_from_slice(secret.as_bytes()).map_err(|e| format!("HMAC 初始化失败: {e}"))?; + mac.update(msg.as_bytes()); + Ok(mac + .finalize() + .into_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect()) + } + + /// 接入点 host。 + /// Token 模式(云消息队列 MQTT 版):`{instanceId}.mqtt.aliyuncs.com`。 + /// 一机一密(物联网平台):`{productKey}.iot-as-mqtt.{region}.aliyuncs.com`, + /// region 留空时用默认区域 cn-shanghai。 pub fn host(&self) -> String { - if self.region.is_empty() { - format!("{}.mqtt.iothub.aliyuncs.com", self.instance_id) - } else { - format!( - "{}.mqtt.{}.aliyuncs.com", - self.instance_id, self.region - ) + match self.auth_mode { + AliyunAuthMode::DeviceCredential => { + let region = if self.region.is_empty() { + "cn-shanghai" + } else { + &self.region + }; + format!("{}.iot-as-mqtt.{}.aliyuncs.com", self.product_key, region) + } + // 实例级接入点不带 region 段;region 字段仅作 UI 备注保留 + AliyunAuthMode::Token => format!("{}.mqtt.aliyuncs.com", self.instance_id), } } @@ -108,7 +139,6 @@ impl AliyunPreset { pub fn to_connection(&self) -> Result { let mut conn = ConnectionConfig::new(); conn.name = self.name.clone(); - conn.host = self.host(); conn.port = TransportKind::Tcp.default_port(); conn.transport = TransportKind::Tcp; conn.protocol = ProtocolVersion::V311; @@ -122,6 +152,7 @@ impl AliyunPreset { { return Err("Group ID、AccessKey ID 与 AccessKey Secret 不能为空".into()); } + conn.host = self.host(); let client_id = format!("{}@@@{}", self.group_id, self.device_id); let password = Self::signature(&self.access_key_secret, &client_id)?; conn.client_id = client_id; @@ -130,15 +161,23 @@ impl AliyunPreset { conn.password = password; } AliyunAuthMode::DeviceCredential => { - if self.device_secret.is_empty() { - return Err("设备密钥 (DeviceSecret) 不能为空".into()); + if self.product_key.is_empty() || self.device_secret.is_empty() { + return Err("一机一密需要 ProductKey 与 DeviceSecret (设备密钥)".into()); } - // 一机一密三元组:clientId=设备名,username=设备名&实例,password=HMAC-SHA1(DeviceSecret, clientId) - let client_id = self.device_id.clone(); - let password = Self::signature(&self.device_secret, &client_id)?; - conn.client_id = client_id; - conn.username = format!("{}&{}", self.device_id, self.instance_id); - conn.password = password; + conn.host = self.host(); + // 物联网平台「一机一密」直连三元组(见模块文档),DeviceName 复用 device_id + let device_name = self.device_id.trim(); + let ts = chrono::Utc::now().timestamp_millis(); + conn.client_id = format!( + "{}.{}|securemode=2,signmethod=hmacsha256,timestamp={ts}|", + self.product_key, device_name + ); + conn.username = format!("{}&{}", device_name, self.product_key); + let content = format!( + "clientId{}.{}deviceName{}productKey{}timestamp{ts}", + self.product_key, device_name, device_name, self.product_key + ); + conn.password = Self::hmac_sha256_hex(&self.device_secret, &content)?; } } Ok(conn) diff --git a/gpui-app/src/ui/aliyun_dialog.rs b/gpui-app/src/ui/aliyun_dialog.rs index 26099d3..6a694af 100644 --- a/gpui-app/src/ui/aliyun_dialog.rs +++ b/gpui-app/src/ui/aliyun_dialog.rs @@ -35,6 +35,7 @@ struct AliyunDialog { access_key_secret: Entity, group_id: Entity, device_id: Entity, + product_key: Entity, device_secret: Entity, } @@ -77,6 +78,7 @@ impl AliyunDialog { }), group_id: text_field!(cx, window, &first.group_id, "GID-xxx"), device_id: text_field!(cx, window, &first.device_id, "设备 ID"), + product_key: text_field!(cx, window, &first.product_key, "一机一密:ProductKey"), device_secret: cx.new(|cx| { InputState::new(window, cx) .default_value(&first.device_secret) @@ -86,6 +88,71 @@ impl AliyunDialog { } } + /// 选中项回填表单。此前只做高亮,保存/连接读的是表单旧值, + /// 会把所选预设用另一条的数据覆盖,或用错误凭据生成连接。 + fn select_preset(&mut self, idx: usize, window: &mut Window, cx: &mut Context) { + let Some(p) = self.presets.get(idx).cloned() else { + return; + }; + self.selected = Some(idx); + self.name.update(cx, |s, cx| s.set_value(p.name, window, cx)); + self.group.update(cx, |s, cx| s.set_value(p.group, window, cx)); + self.instance_id + .update(cx, |s, cx| s.set_value(p.instance_id, window, cx)); + self.region + .update(cx, |s, cx| s.set_value(p.region, window, cx)); + let mode_idx = match p.auth_mode { + AliyunAuthMode::Token => 0, + AliyunAuthMode::DeviceCredential => 1, + }; + self.auth_mode.update(cx, |s, cx| { + s.set_selected_index( + Some(gpui_kit::component::IndexPath::new(mode_idx)), + window, + cx, + ) + }); + self.access_key_id + .update(cx, |s, cx| s.set_value(p.access_key_id, window, cx)); + self.access_key_secret + .update(cx, |s, cx| s.set_value(p.access_key_secret, window, cx)); + self.group_id + .update(cx, |s, cx| s.set_value(p.group_id, window, cx)); + self.device_id + .update(cx, |s, cx| s.set_value(p.device_id, window, cx)); + self.product_key + .update(cx, |s, cx| s.set_value(p.product_key, window, cx)); + self.device_secret + .update(cx, |s, cx| s.set_value(p.device_secret, window, cx)); + cx.notify(); + } + + /// 清空表单进入「新建」工作流。 + fn new_preset(&mut self, window: &mut Window, cx: &mut Context) { + let p = AliyunPreset::new(); + self.selected = None; + self.name.update(cx, |s, cx| s.set_value(p.name, window, cx)); + self.group.update(cx, |s, cx| s.set_value("", window, cx)); + self.instance_id + .update(cx, |s, cx| s.set_value("", window, cx)); + self.region.update(cx, |s, cx| s.set_value("", window, cx)); + self.auth_mode.update(cx, |s, cx| { + s.set_selected_index(Some(gpui_kit::component::IndexPath::new(0)), window, cx) + }); + self.access_key_id + .update(cx, |s, cx| s.set_value("", window, cx)); + self.access_key_secret + .update(cx, |s, cx| s.set_value("", window, cx)); + self.group_id.update(cx, |s, cx| s.set_value("", window, cx)); + self.device_id + .update(cx, |s, cx| s.set_value(p.device_id, window, cx)); + self.product_key + .update(cx, |s, cx| s.set_value("", window, cx)); + self.device_secret + .update(cx, |s, cx| s.set_value("", window, cx)); + cx.notify(); + } + fn collect(&self, cx: &App) -> AliyunPreset { let v = |e: &Entity| e.read(cx).value().to_string(); let id = self @@ -107,6 +174,7 @@ impl AliyunDialog { access_key_secret: v(&self.access_key_secret), group_id: v(&self.group_id), device_id: v(&self.device_id), + product_key: v(&self.product_key), device_secret: v(&self.device_secret), } } @@ -177,10 +245,8 @@ impl Render for AliyunDialog { .when(selected, |d| d.bg(cx.theme().secondary)) .cursor_pointer() .child(p.name.clone()) - .on_click(cx.listener(move |this, _, _, cx| { - // 选中仅做高亮;表单编辑的是“新建/另存”工作流 - this.selected = Some(i); - cx.notify(); + .on_click(cx.listener(move |this, _, window, cx| { + this.select_preset(i, window, cx); })) }), ) @@ -190,9 +256,8 @@ impl Render for AliyunDialog { .label("新建") .ghost() .small() - .on_click(cx.listener(|this, _, _, cx| { - this.selected = None; - cx.notify(); + .on_click(cx.listener(|this, _, window, cx| { + this.new_preset(window, cx); })), ), ) @@ -252,15 +317,27 @@ impl Render for AliyunDialog { )), ), ) - .child(field( - "DeviceSecret(一机一密)", - Input::new(&self.device_secret).mask_toggle(), - )) + .child( + h_flex() + .gap_2() + .child( + div().flex_1().min_w(px(0.)).child(field( + "ProductKey(一机一密)", + Input::new(&self.product_key), + )), + ) + .child( + div().flex_1().min_w(px(0.)).child(field( + "DeviceSecret(一机一密)", + Input::new(&self.device_secret).mask_toggle(), + )), + ), + ) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("Token 模式使用 MQTT 3.1.1,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名"), + .child("Token 模式(云消息队列 MQTT 版):接入点 {实例ID}.mqtt.aliyuncs.com,clientId={GroupId}@@@{设备ID},密码为 HMAC-SHA1 签名;一机一密(物联网平台):接入点 {ProductKey}.iot-as-mqtt.{地域}.aliyuncs.com,按官方三元组规范签名"), ) .child( h_flex()