fix: 阿里云接入点域名、一机一密签名规范与预设回填
- Token 模式(云消息队列 MQTT 版)接入点修正为 {实例ID}.mqtt.aliyuncs.com,
原先拼成物联网平台 iothub 域名,连接必失败;region 不参与该产品域名
- 一机一密模式按物联网平台官方直连规范重写:clientId 带
securemode=2,signmethod=hmacsha256,timestamp 后缀,password 为
HMAC-SHA256 拼接串签名,接入点 {ProductKey}.iot-as-mqtt.{region}.aliyuncs.com
(新增 product_key 字段与输入框)
- 对话框选中预设即回填表单:原先只做高亮,保存/连接读的是表单旧值,
会把所选预设用另一条的数据覆盖(含密钥)或用错误凭据生成连接;
「新建」同步清空表单
This commit is contained in:
+61
-22
@@ -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<Sha1>;
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
#[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<String, String> {
|
||||
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<String, String> {
|
||||
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<ConnectionConfig, String> {
|
||||
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)
|
||||
|
||||
@@ -35,6 +35,7 @@ struct AliyunDialog {
|
||||
access_key_secret: Entity<InputState>,
|
||||
group_id: Entity<InputState>,
|
||||
device_id: Entity<InputState>,
|
||||
product_key: Entity<InputState>,
|
||||
device_secret: Entity<InputState>,
|
||||
}
|
||||
|
||||
@@ -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<Self>) {
|
||||
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<Self>) {
|
||||
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<InputState>| 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()
|
||||
|
||||
Reference in New Issue
Block a user