Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/modbusmaster-egui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,11 @@ impl MasterApp {

proj.connections.push(MasterConnectionSave {
label: s.label.clone(),
tcp: TcpSpec { host, port },
tcp: TcpSpec {
host,
port,
tls: None,
},
slave_id: s.slave_id,
timeout_ms,
poll,
Expand Down
172 changes: 154 additions & 18 deletions crates/modbussim-egui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ use modbussim_core::log_collector::LogCollector;
use modbussim_core::log_entry::LogEntry;
use modbussim_core::register::{decode_value, DataType, Endian, RegisterDef, RegisterType};
use modbussim_core::slave::{ConnectionState, SlaveConnection, SlaveDevice};
use modbussim_core::transport::Transport;
use modbussim_core::transport::{SlaveTlsConfig, Transport};
use modbussim_ui_shared::format::{format_u16, U16Format};
use modbussim_ui_shared::icons;
use modbussim_ui_shared::log_panel::{self, LogPanelAction, LogPanelState};
use modbussim_ui_shared::project::{
deserialize_slave, serialize_slave, SlaveConnectionSave, SlaveDeviceSave, SlaveProject, TcpSpec,
deserialize_slave, serialize_slave, SlaveConnectionSave, SlaveDeviceSave, SlaveProject,
TcpSpec, TlsSpec,
};
use modbussim_ui_shared::theme::{self, Flavor};
use modbussim_ui_shared::ui as uikit;
Expand Down Expand Up @@ -281,6 +282,15 @@ pub struct SlaveApp {
new_host: String,
new_port: String,
show_new_tcp_dialog: bool,
// —— 新建连接的 TLS 表单字段 ——
new_use_tls: bool,
/// PEM cert 路径(与 pkcs12_file 互斥;同时填则 PKCS#12 优先)
new_cert_file: String,
new_key_file: String,
new_ca_file: String,
new_require_client_cert: bool,
new_pkcs12_file: String,
new_pkcs12_password: String,
/// 删除连接二次确认状态:(conn_id, 首次点击时刻)。
/// 3 秒内同一连接再次点删除按钮 → 真删;否则按钮 label 自动恢复。
pending_delete: Option<(String, std::time::Instant)>,
Expand Down Expand Up @@ -499,6 +509,13 @@ impl SlaveApp {
new_host: "0.0.0.0".to_string(),
new_port: "5502".to_string(),
show_new_tcp_dialog: false,
new_use_tls: false,
new_cert_file: String::new(),
new_key_file: String::new(),
new_ca_file: String::new(),
new_require_client_cert: false,
new_pkcs12_file: String::new(),
new_pkcs12_password: String::new(),
pending_delete: None,
last_error: None,
conn_snapshot: Vec::new(),
Expand Down Expand Up @@ -1065,17 +1082,38 @@ impl SlaveApp {
fn allocate_connection(&self) -> (String, String) {
let n = self.next_conn_seq.fetch_add(1, Ordering::Relaxed);
let id = format!("slave_{}", n);
let label = format!("TCP {}:{}", self.new_host.trim(), self.new_port.trim());
let proto = if self.new_use_tls { "TLS" } else { "TCP" };
let label = format!(
"{} {}:{}",
proto,
self.new_host.trim(),
self.new_port.trim()
);
(id, label)
}

fn spawn_create_tcp(&self, id: String, label: String, host: String, port: u16) {
fn spawn_create_tcp(
&self,
id: String,
label: String,
host: String,
port: u16,
tls: Option<SlaveTlsConfig>,
) {
let connections = self.connections.clone();
let tx = self.events_tx.clone();
self.rt.spawn(async move {
let log_collector = Arc::new(LogCollector::new());
let connection = SlaveConnection::new(Transport::Tcp { host, port })
.with_log_collector(log_collector.clone());
let transport = if tls.is_some() {
Transport::TcpTls { host, port }
} else {
Transport::Tcp { host, port }
};
let mut connection =
SlaveConnection::new(transport).with_log_collector(log_collector.clone());
if let Some(cfg) = tls {
connection = connection.with_tls_config(cfg);
}
let device = SlaveDevice::with_default_registers(1, "从站 1", 20000);
let device_snap = DeviceSnapshot {
slave_id: device.slave_id,
Expand Down Expand Up @@ -1110,8 +1148,29 @@ impl SlaveApp {
return;
}
};
let tls = if self.new_use_tls {
let has_pem =
!self.new_cert_file.trim().is_empty() && !self.new_key_file.trim().is_empty();
let has_pkcs12 = !self.new_pkcs12_file.trim().is_empty();
if !has_pem && !has_pkcs12 {
self.last_error =
Some("启用 TLS 需要填写 cert+key(PEM)或 pkcs12 文件路径".to_string());
return;
}
Some(SlaveTlsConfig {
enabled: true,
cert_file: self.new_cert_file.trim().to_string(),
key_file: self.new_key_file.trim().to_string(),
ca_file: self.new_ca_file.trim().to_string(),
require_client_cert: self.new_require_client_cert,
pkcs12_file: self.new_pkcs12_file.trim().to_string(),
pkcs12_password: self.new_pkcs12_password.clone(),
})
} else {
None
};
let (id, label) = self.allocate_connection();
self.spawn_create_tcp(id, label, host, port);
self.spawn_create_tcp(id, label, host, port, tls);
ctx.request_repaint();
}

Expand Down Expand Up @@ -1291,19 +1350,38 @@ impl SlaveApp {
fn build_project(&self) -> SlaveProject {
let mut proj = SlaveProject::new();
for snap in &self.conn_snapshot {
let (host, port) = self
// 同时取出 host/port 与可选 TLS 配置,单次 try_read 完成
let (host, port, tls) = self
.connections
.try_read()
.ok()
.and_then(|list| {
list.iter().find(|e| e.id == snap.id).and_then(|e| {
e.connection.try_read().ok().map(|c| match &c.transport {
Transport::Tcp { host, port } => (host.clone(), *port),
_ => ("0.0.0.0".to_string(), 502),
e.connection.try_read().ok().map(|c| {
let (h, p) = match &c.transport {
Transport::Tcp { host, port }
| Transport::TcpTls { host, port } => (host.clone(), *port),
_ => ("0.0.0.0".to_string(), 502),
};
let tls = if matches!(c.transport, Transport::TcpTls { .. })
&& c.tls_config.enabled
{
Some(TlsSpec {
cert_file: c.tls_config.cert_file.clone(),
key_file: c.tls_config.key_file.clone(),
ca_file: c.tls_config.ca_file.clone(),
require_client_cert: c.tls_config.require_client_cert,
pkcs12_file: c.tls_config.pkcs12_file.clone(),
pkcs12_password: c.tls_config.pkcs12_password.clone(),
})
} else {
None
};
(h, p, tls)
})
})
})
.unwrap_or_else(|| ("0.0.0.0".to_string(), 502));
.unwrap_or_else(|| ("0.0.0.0".to_string(), 502, None));

let devices: Vec<SlaveDeviceSave> = snap
.devices
Expand All @@ -1321,7 +1399,7 @@ impl SlaveApp {

proj.connections.push(SlaveConnectionSave {
label: snap.label.clone(),
tcp: TcpSpec { host, port },
tcp: TcpSpec { host, port, tls },
devices,
});
}
Expand Down Expand Up @@ -1394,11 +1472,30 @@ impl SlaveApp {
let id = format!("slave_{}", next_seq.fetch_add(1, Ordering::Relaxed));
let label = c.label.clone();
let log_collector = Arc::new(LogCollector::new());
let connection = SlaveConnection::new(Transport::Tcp {
host: c.tcp.host.clone(),
port: c.tcp.port,
})
.with_log_collector(log_collector.clone());
let transport = if c.tcp.tls.is_some() {
Transport::TcpTls {
host: c.tcp.host.clone(),
port: c.tcp.port,
}
} else {
Transport::Tcp {
host: c.tcp.host.clone(),
port: c.tcp.port,
}
};
let mut connection =
SlaveConnection::new(transport).with_log_collector(log_collector.clone());
if let Some(tls) = c.tcp.tls.as_ref() {
connection = connection.with_tls_config(SlaveTlsConfig {
enabled: true,
cert_file: tls.cert_file.clone(),
key_file: tls.key_file.clone(),
ca_file: tls.ca_file.clone(),
require_client_cert: tls.require_client_cert,
pkcs12_file: tls.pkcs12_file.clone(),
pkcs12_password: tls.pkcs12_password.clone(),
});
}

let mut device_snapshots = Vec::new();
for ds in &c.devices {
Expand Down Expand Up @@ -3420,6 +3517,45 @@ impl eframe::App for SlaveApp {
ui.end_row();
});
ui.add_space(4.0);
ui.checkbox(&mut self.new_use_tls, "启用 TLS");
if self.new_use_tls {
ui.add_space(2.0);
egui::Grid::new("new_tcp_tls_form")
.num_columns(2)
.spacing([8.0, 4.0])
.show(ui, |ui| {
ui.label("Cert (PEM)");
ui.text_edit_singleline(&mut self.new_cert_file);
ui.end_row();
ui.label("Key (PEM)");
ui.text_edit_singleline(&mut self.new_key_file);
ui.end_row();
ui.label("PKCS#12");
ui.text_edit_singleline(&mut self.new_pkcs12_file);
ui.end_row();
ui.label("PKCS#12 密码");
ui.add(
egui::TextEdit::singleline(
&mut self.new_pkcs12_password,
)
.password(true),
);
ui.end_row();
ui.label("CA (可选)");
ui.text_edit_singleline(&mut self.new_ca_file);
ui.end_row();
});
ui.checkbox(
&mut self.new_require_client_cert,
"要求客户端证书 (mTLS)",
);
theme::text::crumb(
ui,
self.flavor,
"PEM (cert+key) 与 PKCS#12 二选一;两者都填则 PKCS#12 优先",
);
ui.add_space(4.0);
}
ui.horizontal(|ui| {
if uikit::primary_button(ui, self.flavor, "创建").clicked()
{
Expand Down
59 changes: 59 additions & 0 deletions crates/modbussim-ui-shared/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,27 @@ pub enum EguiProjectType {
pub struct TcpSpec {
pub host: String,
pub port: u16,
/// 可选 TLS 配置;旧项目文件无此字段时按 None(明文 TCP)解析。
#[serde(default)]
pub tls: Option<TlsSpec>,
}

/// 子站 TLS 持久化字段,与 `modbussim_core::transport::SlaveTlsConfig`
/// 字段一一对应。任何字段缺失视为空字符串/false,便于演进。
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TlsSpec {
#[serde(default)]
pub cert_file: String,
#[serde(default)]
pub key_file: String,
#[serde(default)]
pub ca_file: String,
#[serde(default)]
pub require_client_cert: bool,
#[serde(default)]
pub pkcs12_file: String,
#[serde(default)]
pub pkcs12_password: String,
}

// --- Slave ---
Expand Down Expand Up @@ -165,6 +186,7 @@ mod tests {
tcp: TcpSpec {
host: "0.0.0.0".into(),
port: 502,
tls: None,
},
devices: vec![SlaveDeviceSave {
slave_id: 1,
Expand All @@ -186,6 +208,7 @@ mod tests {
tcp: TcpSpec {
host: "127.0.0.1".into(),
port: 5502,
tls: None,
},
slave_id: 1,
timeout_ms: 3000,
Expand All @@ -201,6 +224,42 @@ mod tests {
assert_eq!(q.connections[0].poll.as_ref().unwrap().qty, 10);
}

#[test]
fn slave_tls_roundtrip_and_legacy_compat() {
let mut p = SlaveProject::new();
p.connections.push(SlaveConnectionSave {
label: "TLS 0.0.0.0:8502".into(),
tcp: TcpSpec {
host: "0.0.0.0".into(),
port: 8502,
tls: Some(TlsSpec {
cert_file: "/etc/cert.pem".into(),
key_file: "/etc/key.pem".into(),
ca_file: String::new(),
require_client_cert: false,
pkcs12_file: String::new(),
pkcs12_password: String::new(),
}),
},
devices: Vec::new(),
});
let json = serialize_slave(&p).unwrap();
let q = deserialize_slave(&json).unwrap();
let tls = q.connections[0].tcp.tls.clone();
assert_eq!(tls.unwrap().cert_file, "/etc/cert.pem");

// 旧文件(无 tls 字段)应当照常解析、tls=None
let legacy = r#"{
"schema_version": 2,
"type": "slave",
"connections": [
{"label":"L","tcp":{"host":"0.0.0.0","port":502},"devices":[]}
]
}"#;
let parsed = deserialize_slave(legacy).unwrap();
assert!(parsed.connections[0].tcp.tls.is_none());
}

#[test]
fn wrong_type_rejected() {
let master = MasterProject::new();
Expand Down
Loading
Loading