netget/server/modbus/mod.rs
1//! Modbus TCP server.
2//!
3//! Owns framing and the connection state machine; the model owns the data. See
4//! `src/server/modbus/CLAUDE.md`.
5
6pub mod actions;
7pub mod codec;
8
9use anyhow::Result;
10use std::collections::HashMap;
11use std::net::SocketAddr;
12use std::sync::Arc;
13use tokio::io::{AsyncReadExt, AsyncWriteExt};
14use tokio::net::TcpStream;
15use tokio::sync::{mpsc, Mutex};
16use tracing::{debug, error, info, warn};
17
18use crate::llm::action_helper::call_llm;
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ActionResult;
21use crate::logging::emit::Log;
22use crate::protocol::Event;
23use crate::server::connection::ConnectionId;
24use crate::state::app_state::AppState;
25
26use actions::{
27 ModbusProtocol, MODBUS_READ_BITS_EVENT, MODBUS_READ_REGISTERS_EVENT,
28 MODBUS_WRITE_REQUEST_EVENT, RESULT_BITS, RESULT_EXCEPTION, RESULT_REGISTERS, RESULT_WRITE_ACK,
29};
30use codec::{ModbusRequest, MAX_ADU_LEN};
31
32/// Per-connection LLM processing state, mirroring `src/server/tcp/mod.rs`.
33#[derive(Debug, Clone, PartialEq)]
34enum ConnectionState {
35 /// No request in flight; ready to process.
36 Idle,
37 /// An LLM call is in flight; arriving bytes are queued into `buffer`.
38 Processing,
39 /// A partial ADU is buffered and we are waiting for the rest of it.
40 Accumulating,
41}
42
43struct ConnectionData {
44 state: ConnectionState,
45 /// Doubles as the framing accumulator and the queue for bytes that arrive mid-call.
46 buffer: Vec<u8>,
47 write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
48 /// Signalled by [`ModbusServer::close`] so the connection's reader stops too.
49 ///
50 /// `close` runs on a `handle_data` task, not on the reader, and shutting the write half
51 /// only half-closes the socket. Without this signal the reader goes on reading from a peer
52 /// the server has already dropped — holding the read half, and with it the connection-cap
53 /// permit, until the peer hangs up or stays quiet for the whole idle bound. A peer could
54 /// then occupy a slot by sending one malformed header and keeping its end open.
55 closed: Arc<tokio::sync::Notify>,
56}
57
58/// How much one connection may accumulate before we give up on it: eight ADUs' worth.
59///
60/// The buffer is both the framing accumulator and the queue for bytes that arrive while a
61/// request is with the model. It is enforced on *append*, in `handle_data`, which is the only
62/// code that grows the buffer — the framing loop only drains it. The placement is the point:
63/// while a request is with the model the framing loop does not run at all, so a bound checked
64/// there would leave the queue unbounded for the whole LLM call, which at the shipped
65/// `--llm-queue-timeout` of 120s is long enough for a peer to push gigabytes into it.
66///
67/// Outside a model call a peer cannot reach it: an incomplete ADU is at most `MAX_ADU_LEN - 1`
68/// octets, so only bytes queued behind an in-flight request add up to it.
69pub const MAX_BUFFERED: usize = MAX_ADU_LEN * 8;
70
71/// How long to wait for a peer's first bytes after it connects.
72///
73/// Modbus TCP is client-speaks-first — the server says nothing until it has a request — and a
74/// client sends its first PDU inside its own connect path (`tokio-modbus` and `mbpoll` both do).
75/// So a peer that has connected and sent nothing has begun no transaction at all, which is the
76/// state an unauthenticated flood lives in: Modbus has no authentication step of any kind.
77///
78/// This is the default; the `first_byte_timeout_secs` startup parameter overrides it.
79pub const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
80
81/// How long a peer that has already sent something may send nothing further.
82///
83/// Modbus TCP defines no keepalive, so there is no protocol interval to sit above; what there
84/// is, is polling. A SCADA master polls on a sub-second to few-second cycle, so any real client
85/// speaks orders of magnitude more often than this.
86///
87/// The number is set by *this* server rather than by Modbus: `handle_data` runs on its own task,
88/// so the reader keeps reading while a request is being answered, and a peer waiting for its own
89/// reply is silent on this socket for the whole of that work — including a `manual` rule parked
90/// for a human, 300 seconds by default. Ten minutes leaves that a factor of two. Real Modbus/TCP
91/// gateways commonly reap an idle connection at 60 seconds; being ten times more patient is the
92/// deliberate price of letting a human answer.
93///
94/// This is the default; the `idle_timeout_secs` startup parameter overrides it. An operator
95/// exposing the port to strangers with no human in the loop wants it near the gateway figure.
96pub const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
97
98/// Concurrent connections this server admits.
99///
100/// Each admitted connection may buffer [`MAX_BUFFERED`] bytes and holds a read task, a peer
101/// handle and an `AppState` row, so the cap is what turns those per-connection bounds into total
102/// ones. A real PLC admits single digits; 256 is the shared default and is still far below what
103/// an attacker needs for socket exhaustion to matter.
104pub const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
105
106/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes: **nothing**.
107///
108/// Modbus has no way to say it. Every server message is an exception or a data response, and both
109/// are replies — they carry the transaction identifier, unit id and function code of a request
110/// this peer has not sent. Inventing one means inventing a transaction, which is worse than
111/// silence for the same reason twenty protocols here are deliberately silent on an LLM failure.
112/// So the refusal is a plain close and the reason lives in the log, under the
113/// `decision=fail_closed_connection_cap` tag `accept_bounded` writes.
114const CONNECTION_CAP_REFUSAL: &[u8] = b"";
115
116/// Why a request is being answered the way it is.
117///
118/// The wire cannot carry the distinction: a model that deliberately refuses with exception
119/// 0x04 and a model that never answered at all both put `04` on the wire. So the log
120/// carries it instead, with stable tokens - `decision=fail_closed_` finds every request the
121/// model did not actually answer. Mirrors `src/server/radius/`, which is the reference for
122/// this split.
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum Decision {
125 /// The model supplied the values, or accepted the write.
126 ModelAnswer,
127 /// The model deliberately refused, with a Modbus exception of its choosing.
128 ModelReject,
129 /// The model answered a read with the addresses this device has, and at least one of the
130 /// addresses asked for is not among them: exception 2, decided by the model's own map.
131 ModelAddressAbsent,
132 /// The specification determined the answer; the model was never asked.
133 SpecReject,
134 /// Addressed to a unit id this device does not answer for.
135 UnitMismatch,
136 /// The LLM backend failed. Nobody decided anything.
137 FailClosedLlmError,
138 /// The model was asked and returned nothing this request could use.
139 FailClosedNoAction,
140 /// The model answered a different question: bits for a register read, a write ack for
141 /// a read, or the wrong number of values for the quantity requested.
142 FailClosedWrongShape,
143 /// The stream stopped being Modbus: a protocol id other than 0, or an MBAP length outside
144 /// `2..=254`. Not a request, so nothing is answered; the connection is closed.
145 FailClosedFramingError,
146 /// More than [`MAX_BUFFERED`] octets queued on one connection. Closed, not answered.
147 FailClosedBufferOverflow,
148}
149
150impl Decision {
151 fn as_str(self) -> &'static str {
152 match self {
153 Decision::ModelAnswer => "model_answer",
154 Decision::ModelReject => "model_reject",
155 Decision::ModelAddressAbsent => "model_address_absent",
156 Decision::SpecReject => "spec_reject",
157 Decision::UnitMismatch => "unit_mismatch",
158 Decision::FailClosedLlmError => "fail_closed_llm_error",
159 Decision::FailClosedNoAction => "fail_closed_no_action",
160 Decision::FailClosedWrongShape => "fail_closed_wrong_shape",
161 Decision::FailClosedFramingError => "fail_closed_framing_error",
162 Decision::FailClosedBufferOverflow => "fail_closed_buffer_overflow",
163 }
164 }
165
166 fn is_fail_closed(self) -> bool {
167 matches!(
168 self,
169 Decision::FailClosedLlmError
170 | Decision::FailClosedNoAction
171 | Decision::FailClosedWrongShape
172 | Decision::FailClosedFramingError
173 | Decision::FailClosedBufferOverflow
174 )
175 }
176}
177
178/// Modbus TCP server.
179pub struct ModbusServer;
180
181impl ModbusServer {
182 /// Bind, start accepting, and register the accept loop so `stop_server` releases the port.
183 ///
184 /// Returns `Err` if the socket cannot be bound, so `server_startup` records
185 /// `ServerStatus::Error` rather than a server that is not listening.
186 #[allow(clippy::too_many_arguments)]
187 pub async fn spawn_with_llm_actions(
188 listen_addr: SocketAddr,
189 llm_client: OllamaClient,
190 app_state: Arc<AppState>,
191 status_tx: mpsc::UnboundedSender<String>,
192 unit_id_filter: Option<u8>,
193 server_id: crate::state::ServerId,
194 first_byte_timeout: std::time::Duration,
195 idle_timeout: std::time::Duration,
196 ) -> Result<SocketAddr> {
197 let listener =
198 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
199 let local_addr = listener.local_addr()?;
200 Log::new(Some(&status_tx)).info(format!("Modbus server listening on {local_addr}"));
201
202 let connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>> =
203 Arc::new(Mutex::new(HashMap::new()));
204 let protocol = Arc::new(ModbusProtocol::new());
205
206 let task_registrar = app_state.clone();
207 let accept_handle = tokio::spawn(async move {
208 Log::new(Some(&status_tx)).info(format!("Modbus accept loop started on {local_addr}"));
209
210 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
211 loop {
212 match crate::server::accept_bounded::accept_bounded(
213 &listener,
214 &limiter,
215 CONNECTION_CAP_REFUSAL,
216 "Modbus",
217 Some(&status_tx),
218 )
219 .await
220 {
221 Ok((stream, remote_addr, permit)) => {
222 let connection_id =
223 ConnectionId::new(app_state.get_next_unified_id().await);
224 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
225 info!(
226 "Modbus accepted connection {} from {}",
227 connection_id, remote_addr
228 );
229
230 let (mut read_half, write_half) = tokio::io::split(stream);
231 let write_half = Arc::new(Mutex::new(write_half));
232
233 use crate::state::server::{
234 ConnectionState as ServerConnectionState, ConnectionStatus,
235 ProtocolConnectionInfo,
236 };
237 let now = crate::utils::clock::Instant::now();
238 app_state
239 .add_connection_to_server(
240 server_id,
241 ServerConnectionState {
242 id: connection_id,
243 remote_addr,
244 local_addr: local_addr_conn,
245 bytes_sent: 0,
246 bytes_received: 0,
247 packets_sent: 0,
248 packets_received: 0,
249 last_activity: now,
250 status: ConnectionStatus::Active,
251 status_changed_at: now,
252 protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
253 "state": "Idle",
254 "unit_id_filter": unit_id_filter,
255 })),
256 },
257 )
258 .await;
259 let _ = status_tx.send("__UPDATE_UI__".to_string());
260
261 // Register before spawning the reader, so a client that writes
262 // immediately after connect() cannot have its first frame dropped.
263 let closed = Arc::new(tokio::sync::Notify::new());
264 connections.lock().await.insert(
265 connection_id,
266 ConnectionData {
267 state: ConnectionState::Idle,
268 buffer: Vec::new(),
269 write_half: write_half.clone(),
270 closed: closed.clone(),
271 },
272 );
273
274 // Peer messaging: the dashboard's "disconnect this peer" injects
275 // `close_connection` into THIS connection through the same executor
276 // the LLM path uses. The four Modbus wire verbs are request-bound
277 // (they return `ActionResult::Custom` and are framed against the
278 // request's transaction id), so an injected one is reported as
279 // executed without writing — a Modbus server never initiates.
280 let peer_rx = crate::server::peer_support::register_peer_channel(
281 &app_state,
282 server_id,
283 connection_id.as_u32(),
284 )
285 .await;
286 crate::server::peer_support::spawn_peer_command_task(
287 peer_rx,
288 protocol.clone(),
289 app_state.clone(),
290 server_id,
291 connection_id.as_u32(),
292 write_half.clone(),
293 status_tx.clone(),
294 );
295
296 let llm_clone = llm_client.clone();
297 let state_clone = app_state.clone();
298 let status_clone = status_tx.clone();
299 let conns_clone = connections.clone();
300 let protocol_clone = protocol.clone();
301
302 // Tracked, not detached: stop_server must abort this task too.
303 let task_owner = app_state.clone();
304 task_owner
305 .spawn_server_task(server_id, async move {
306 // Held for the life of the connection: dropping it early
307 // releases the slot while the peer is still here, which
308 // silently un-caps the server.
309 let _permit = permit;
310 let mut read_buf = vec![0u8; 4096];
311 let log = Log::new(Some(&status_clone));
312 // "Has said nothing at all" and "has gone quiet mid-session"
313 // are different claims and get different deadlines.
314 let mut spoke_once = false;
315 loop {
316 let read_deadline = if spoke_once {
317 idle_timeout
318 } else {
319 first_byte_timeout
320 };
321 // The deadline wraps this read and nothing else. Modbus has
322 // no message a server may send unprompted, so a peer
323 // dropped for silence is closed without a byte — the
324 // reason is the log line.
325 let timed_read = tokio::select! {
326 // `close` has already torn the connection down; stop
327 // reading so the socket and the permit go with it.
328 _ = closed.notified() => break,
329 r = tokio::time::timeout(
330 read_deadline,
331 read_half.read(&mut read_buf),
332 ) => r,
333 };
334 let read_result = match timed_read {
335 Ok(v) => v,
336 Err(_) => {
337 log.info(format!(
338 "Modbus connection {connection_id} sent nothing \
339 for {}s; closing \
340 decision=fail_closed_idle_timeout",
341 read_deadline.as_secs()
342 ));
343 conns_clone.lock().await.remove(&connection_id);
344 state_clone
345 .remove_peer_handle(
346 server_id,
347 connection_id.as_u32(),
348 )
349 .await;
350 state_clone
351 .close_connection_on_server(
352 server_id,
353 connection_id,
354 )
355 .await;
356 let _ = status_clone.send("__UPDATE_UI__".to_string());
357 break;
358 }
359 };
360 match read_result {
361 Ok(0) => {
362 conns_clone.lock().await.remove(&connection_id);
363 state_clone
364 .remove_peer_handle(
365 server_id,
366 connection_id.as_u32(),
367 )
368 .await;
369 state_clone
370 .close_connection_on_server(
371 server_id,
372 connection_id,
373 )
374 .await;
375 log.info(format!(
376 "Modbus connection {connection_id} closed"
377 ));
378 let _ = status_clone.send("__UPDATE_UI__".to_string());
379 break;
380 }
381 Ok(n) => {
382 spoke_once = true;
383 let data = read_buf[..n].to_vec();
384 // Summary + full payload FileOnly: the modbus_* event
385 // templates render the equivalent line to the TUI.
386 log.debug(format!(
387 "Modbus received {} bytes on {}",
388 n, connection_id
389 ));
390 log.trace(format!(
391 "Modbus received (hex): {}",
392 hex::encode(&data)
393 ));
394 state_clone
395 .update_connection_stats(
396 server_id,
397 connection_id,
398 Some(n as u64),
399 None,
400 Some(1),
401 None,
402 )
403 .await;
404
405 let llm = llm_clone.clone();
406 let st = state_clone.clone();
407 let stx = status_clone.clone();
408 let cs = conns_clone.clone();
409 let pr = protocol_clone.clone();
410 // Tracked, not detached: stop_server must abort this task too.
411 let task_owner = state_clone.clone();
412 task_owner
413 .spawn_server_task(server_id, async move {
414 Self::handle_data(
415 connection_id,
416 server_id,
417 data,
418 unit_id_filter,
419 llm,
420 st,
421 stx,
422 cs,
423 pr,
424 )
425 .await;
426 })
427 .await;
428 }
429 Err(e) => {
430 error!("Modbus read error on {}: {}", connection_id, e);
431 conns_clone.lock().await.remove(&connection_id);
432 state_clone
433 .remove_peer_handle(
434 server_id,
435 connection_id.as_u32(),
436 )
437 .await;
438 state_clone
439 .close_connection_on_server(
440 server_id,
441 connection_id,
442 )
443 .await;
444 break;
445 }
446 }
447 }
448 })
449 .await;
450 }
451 Err(e) => {
452 error!("Modbus accept error: {}", e);
453 break;
454 }
455 }
456 }
457 });
458
459 task_registrar
460 .register_server_task(server_id, accept_handle)
461 .await;
462
463 Ok(local_addr)
464 }
465
466 /// Drive the per-connection state machine over newly arrived bytes.
467 #[allow(clippy::too_many_arguments)]
468 async fn handle_data(
469 connection_id: ConnectionId,
470 server_id: crate::state::ServerId,
471 data: Vec<u8>,
472 unit_id_filter: Option<u8>,
473 llm_client: OllamaClient,
474 app_state: Arc<AppState>,
475 status_tx: mpsc::UnboundedSender<String>,
476 connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
477 protocol: Arc<ModbusProtocol>,
478 ) {
479 let log = Log::new(Some(&status_tx));
480
481 // Append, and bail out early if a request is already in flight: the in-flight
482 // invocation will pick these bytes up when it loops.
483 {
484 let mut conns = connections.lock().await;
485 let Some(conn) = conns.get_mut(&connection_id) else {
486 return; // Connection closed while we waited for the lock
487 };
488 conn.buffer.extend_from_slice(&data);
489
490 // Enforced here as well as in the framing loop below: while a request is with
491 // the model this function is the *only* code touching the buffer, and it
492 // returns early, so a loop-only check would let a flooding peer grow the queue
493 // without bound for the whole duration of the call.
494 if conn.buffer.len() > MAX_BUFFERED {
495 error!(
496 "Modbus {} queued {} bytes, past the {} one connection may buffer; closing \
497 decision={}",
498 connection_id,
499 conn.buffer.len(),
500 MAX_BUFFERED,
501 Decision::FailClosedBufferOverflow.as_str()
502 );
503 conn.buffer.clear();
504 drop(conns);
505 Self::close(
506 connection_id,
507 server_id,
508 &app_state,
509 &connections,
510 &status_tx,
511 )
512 .await;
513 return;
514 }
515
516 if conn.state == ConnectionState::Processing {
517 log.debug(format!(
518 "Queued {} bytes for Modbus {connection_id}",
519 data.len()
520 ));
521 return;
522 }
523 conn.state = ConnectionState::Processing;
524 }
525
526 loop {
527 // Pull one complete ADU out of the accumulator.
528 let parsed = {
529 let mut conns = connections.lock().await;
530 let Some(conn) = conns.get_mut(&connection_id) else {
531 return;
532 };
533
534 // No MAX_BUFFERED check here: this loop only drains the buffer, and the append
535 // above is the one place it grows.
536 match codec::try_parse_adu(&conn.buffer) {
537 Ok(Some((adu, consumed))) => {
538 conn.buffer.drain(..consumed);
539 Ok(Some(adu))
540 }
541 Ok(None) => {
542 // Nothing more to do: park in Accumulating (or Idle when the
543 // accumulator is empty) and let the next read wake us.
544 conn.state = if conn.buffer.is_empty() {
545 ConnectionState::Idle
546 } else {
547 ConnectionState::Accumulating
548 };
549 Ok(None)
550 }
551 Err(e) => Err(e),
552 }
553 };
554
555 let adu = match parsed {
556 Ok(Some(adu)) => adu,
557 Ok(None) => return,
558 Err(e) => {
559 // Neither error is answerable: we no longer know where the next frame
560 // starts, so the only honest signal is to close.
561 log.error(format!(
562 "Modbus framing error on {connection_id}: {e}; closing decision={}",
563 Decision::FailClosedFramingError.as_str()
564 ));
565 Self::close(
566 connection_id,
567 server_id,
568 &app_state,
569 &connections,
570 &status_tx,
571 )
572 .await;
573 return;
574 }
575 };
576
577 let function_code = adu.pdu.first().copied().unwrap_or(0);
578
579 // Unit routing, when the operator pinned this server to one unit id.
580 if let Some(expected) = unit_id_filter {
581 if adu.unit_id != expected {
582 warn!(
583 "Modbus {} addressed unit {} but this device answers for unit {} \
584 decision={}",
585 connection_id,
586 adu.unit_id,
587 expected,
588 Decision::UnitMismatch.as_str()
589 );
590 let pdu =
591 codec::encode_exception(function_code, codec::EXC_GATEWAY_TARGET_FAILED);
592 Self::send_pdu(
593 connection_id,
594 server_id,
595 adu.transaction_id,
596 adu.unit_id,
597 &pdu,
598 &app_state,
599 &connections,
600 &status_tx,
601 )
602 .await;
603 continue;
604 }
605 }
606
607 // Spec-determined failures are answered here, with no model round-trip: an
608 // unknown function code is always 0x01, a zero quantity always 0x03.
609 let request = match codec::parse_request(&adu.pdu) {
610 Ok(r) => r,
611 Err(exception_code) => {
612 debug!(
613 "Modbus {} rejecting fc={:#04x} with exception {:#04x} ({}) decision={}",
614 connection_id,
615 function_code,
616 exception_code,
617 codec::exception_name(exception_code),
618 Decision::SpecReject.as_str()
619 );
620 let pdu = codec::encode_exception(function_code, exception_code);
621 Self::send_pdu(
622 connection_id,
623 server_id,
624 adu.transaction_id,
625 adu.unit_id,
626 &pdu,
627 &app_state,
628 &connections,
629 &status_tx,
630 )
631 .await;
632 continue;
633 }
634 };
635
636 let (event_type, mut event_data) = Self::build_event(&adu, &request);
637 event_data["unit_id"] = serde_json::json!(adu.unit_id);
638 let event = Event::new(event_type, event_data);
639
640 let (decision, pdu) = match call_llm(
641 &llm_client,
642 &app_state,
643 server_id,
644 Some(connection_id),
645 &event,
646 protocol.as_ref(),
647 )
648 .await
649 {
650 Ok(execution_result) => {
651 for msg in execution_result.messages {
652 let _ = status_tx.send(msg);
653 }
654 Self::pdu_from_results(
655 connection_id,
656 &request,
657 &execution_result.protocol_results,
658 )
659 }
660 Err(e) => {
661 // Non-fatal: the client still gets a device-failure exception (wire
662 // fallback), so this is WARN not ERROR. The error text stays in the
663 // log; the wire gets only the exception code.
664 log.warn(format!("Modbus LLM error on {connection_id}: {e}"));
665 // Fail closed and say so on the wire. Writing nothing would leave the
666 // client blocked until its own timeout with no diagnostic.
667 (
668 Decision::FailClosedLlmError,
669 codec::encode_exception(
670 request.function_code(),
671 codec::EXC_SERVER_DEVICE_FAILURE,
672 ),
673 )
674 }
675 };
676
677 // Log the decision before writing it, and make the fail-closed paths loud.
678 // On this protocol a fabricated value has physical consequences, so an
679 // operator has to be able to tell "the device said so" from "nobody
680 // answered" - and the wire cannot: both can be exception 0x04.
681 let summary = format!(
682 "Modbus {} {} unit={} @{} x{} decision={}",
683 connection_id,
684 request.function_name(),
685 adu.unit_id,
686 request.start_address(),
687 request.quantity(),
688 decision.as_str()
689 );
690 if decision.is_fail_closed() {
691 log.error(format!(
692 "{summary} (answered with an exception because no usable answer was \
693 produced)"
694 ));
695 } else {
696 log.debug(&summary);
697 }
698
699 Self::send_pdu(
700 connection_id,
701 server_id,
702 adu.transaction_id,
703 adu.unit_id,
704 &pdu,
705 &app_state,
706 &connections,
707 &status_tx,
708 )
709 .await;
710 }
711 }
712
713 /// Choose the event type and build its data from a decoded request.
714 fn build_event(
715 adu: &codec::Adu,
716 request: &ModbusRequest,
717 ) -> (&'static crate::protocol::EventType, serde_json::Value) {
718 let mut data = serde_json::json!({
719 "unit_id": adu.unit_id,
720 "function_code": request.function_code(),
721 "function": request.function_name(),
722 "start_address": request.start_address(),
723 "quantity": request.quantity(),
724 "answer_with": actions::answer_with_for_request(request),
725 });
726
727 match request {
728 ModbusRequest::ReadCoils { .. } => {
729 data["bit_type"] = serde_json::json!("coil");
730 (&MODBUS_READ_BITS_EVENT, data)
731 }
732 ModbusRequest::ReadDiscreteInputs { .. } => {
733 data["bit_type"] = serde_json::json!("discrete_input");
734 (&MODBUS_READ_BITS_EVENT, data)
735 }
736 ModbusRequest::ReadHoldingRegisters { .. } => {
737 data["register_type"] = serde_json::json!("holding");
738 (&MODBUS_READ_REGISTERS_EVENT, data)
739 }
740 ModbusRequest::ReadInputRegisters { .. } => {
741 data["register_type"] = serde_json::json!("input");
742 (&MODBUS_READ_REGISTERS_EVENT, data)
743 }
744 ModbusRequest::WriteSingleCoil { value, .. } => {
745 data["coil_values"] = serde_json::json!([value]);
746 (&MODBUS_WRITE_REQUEST_EVENT, data)
747 }
748 ModbusRequest::WriteMultipleCoils { values, .. } => {
749 data["coil_values"] = serde_json::json!(values);
750 (&MODBUS_WRITE_REQUEST_EVENT, data)
751 }
752 ModbusRequest::WriteSingleRegister { value, .. } => {
753 data["register_values"] = serde_json::json!([value]);
754 (&MODBUS_WRITE_REQUEST_EVENT, data)
755 }
756 ModbusRequest::WriteMultipleRegisters { values, .. } => {
757 data["register_values"] = serde_json::json!(values);
758 (&MODBUS_WRITE_REQUEST_EVENT, data)
759 }
760 }
761 }
762
763 /// Turn the model's structured answer into the PDU to put on the wire.
764 ///
765 /// Fails closed: anything that is not a usable answer to *this* request becomes a
766 /// `server device failure` exception rather than a plausible-looking default. A
767 /// silent LLM and a model that answered the wrong question are both faults, and the
768 /// client is told so.
769 fn pdu_from_results(
770 connection_id: ConnectionId,
771 request: &ModbusRequest,
772 results: &[ActionResult],
773 ) -> (Decision, Vec<u8>) {
774 let fc = request.function_code();
775
776 for result in results {
777 let ActionResult::Custom { name, data } = result else {
778 continue;
779 };
780
781 match name.as_str() {
782 RESULT_EXCEPTION => {
783 // `execute_action` has already checked this against the exception
784 // codes the specification defines, so the fallback is unreachable
785 // today. It is written as a fail-closed default rather than a cast so
786 // it stays correct if another producer of this result ever appears:
787 // `n as u8` would silently turn 0x104 into 0x04.
788 let code = data
789 .get("exception_code")
790 .and_then(|v| v.as_u64())
791 .filter(|n| *n <= u8::MAX as u64)
792 .map(|n| n as u8)
793 .unwrap_or(codec::EXC_SERVER_DEVICE_FAILURE);
794 return (Decision::ModelReject, codec::encode_exception(fc, code));
795 }
796 RESULT_BITS => {
797 if !request.is_bit_read() {
798 error!(
799 "Modbus {}: send_modbus_bits answered a {} request; bits only \
800 answer function codes 1 and 2",
801 connection_id,
802 request.function_name()
803 );
804 return (
805 Decision::FailClosedWrongShape,
806 codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
807 );
808 }
809 let values: Vec<bool> = if let Some(map) = data.get("bits") {
810 match Self::read_from_map(connection_id, request, map, |v| v.as_bool()) {
811 Ok(values) => values,
812 Err(absent) => return absent,
813 }
814 } else {
815 data.get("values")
816 .and_then(|v| v.as_array())
817 .map(|a| a.iter().filter_map(|v| v.as_bool()).collect())
818 .unwrap_or_default()
819 };
820 if values.len() != request.quantity() as usize {
821 error!(
822 "Modbus {}: model returned {} bit value(s) for a request of {}; \
823 answering with a device failure rather than a truncated frame",
824 connection_id,
825 values.len(),
826 request.quantity()
827 );
828 return (
829 Decision::FailClosedWrongShape,
830 codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
831 );
832 }
833 // Unreachable while the count check above holds — `parse_request` caps a
834 // coil read at 2000 bits — but the codec is what owns the bound, so its
835 // refusal is answered rather than unwrapped.
836 return match codec::encode_bits_response(fc, &values) {
837 Ok(pdu) => (Decision::ModelAnswer, pdu),
838 Err(e) => {
839 error!(
840 "Modbus {}: cannot encode a bit response: {}",
841 connection_id, e
842 );
843 (
844 Decision::FailClosedWrongShape,
845 codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
846 )
847 }
848 };
849 }
850 RESULT_REGISTERS => {
851 if !request.is_register_read() {
852 error!(
853 "Modbus {}: send_modbus_registers answered a {} request; registers \
854 only answer function codes 3 and 4",
855 connection_id,
856 request.function_name()
857 );
858 return (
859 Decision::FailClosedWrongShape,
860 codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
861 );
862 }
863 // Filtered, never cast. `n as u16` would turn a model's 65736 into
864 // 200 and put it on the wire as a plant reading, which on this
865 // protocol is a fabricated measurement with physical consequences.
866 // Anything out of range is dropped here, which makes the count wrong,
867 // which the check below turns into an exception.
868 let in_range = |v: &serde_json::Value| {
869 v.as_u64()
870 .filter(|n| *n <= u16::MAX as u64)
871 .map(|n| n as u16)
872 };
873 let values: Vec<u16> = if let Some(map) = data.get("registers") {
874 match Self::read_from_map(connection_id, request, map, in_range) {
875 Ok(values) => values,
876 Err(absent) => return absent,
877 }
878 } else {
879 data.get("values")
880 .and_then(|v| v.as_array())
881 .map(|a| a.iter().filter_map(in_range).collect())
882 .unwrap_or_default()
883 };
884 if values.len() != request.quantity() as usize {
885 error!(
886 "Modbus {}: model returned {} register value(s) for a request of \
887 {}; answering with a device failure rather than a truncated frame",
888 connection_id,
889 values.len(),
890 request.quantity()
891 );
892 return (
893 Decision::FailClosedWrongShape,
894 codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
895 );
896 }
897 // Unreachable while the count check above holds — `parse_request` caps a
898 // register read at 125 — but the codec is what owns the bound.
899 return match codec::encode_registers_response(fc, &values) {
900 Ok(pdu) => (Decision::ModelAnswer, pdu),
901 Err(e) => {
902 error!(
903 "Modbus {}: cannot encode a register response: {}",
904 connection_id, e
905 );
906 (
907 Decision::FailClosedWrongShape,
908 codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
909 )
910 }
911 };
912 }
913 RESULT_WRITE_ACK => {
914 if !request.is_write() {
915 error!(
916 "Modbus {}: send_modbus_write_ack answered a {} request, which is \
917 a read",
918 connection_id,
919 request.function_name()
920 );
921 return (
922 Decision::FailClosedWrongShape,
923 codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
924 );
925 }
926 return (Decision::ModelAnswer, codec::encode_write_ack(request));
927 }
928 _ => {}
929 }
930 }
931
932 error!(
933 "Modbus {}: no usable action returned for {}; answering with exception 0x04 \
934 (server device failure)",
935 connection_id,
936 request.function_name()
937 );
938 (
939 Decision::FailClosedNoAction,
940 codec::encode_exception(fc, codec::EXC_SERVER_DEVICE_FAILURE),
941 )
942 }
943
944 /// The requested window of an address-keyed answer, in address order.
945 ///
946 /// The model answers a read with the addresses the device has; a requested address with no
947 /// entry is one the device does not have, which Modbus answers with exception 2 (illegal
948 /// data address) for the whole request. `Err` carries that answer, ready to send. A value
949 /// `read` cannot use is left out, so the count check after this refuses the answer as the
950 /// wrong shape rather than sending a value the model did not give.
951 fn read_from_map<T>(
952 connection_id: ConnectionId,
953 request: &ModbusRequest,
954 map: &serde_json::Value,
955 read: impl Fn(&serde_json::Value) -> Option<T>,
956 ) -> std::result::Result<Vec<T>, (Decision, Vec<u8>)> {
957 let start = u32::from(request.start_address());
958 let end = start + u32::from(request.quantity());
959 let mut values = Vec::with_capacity(request.quantity() as usize);
960 for address in start..end {
961 match map.get(address.to_string()) {
962 Some(value) => values.extend(read(value)),
963 None => {
964 debug!(
965 "Modbus {}: address {} of {} is not among the addresses the model gave; \
966 answering exception 2 (illegal data address)",
967 connection_id,
968 address,
969 request.function_name()
970 );
971 return Err((
972 Decision::ModelAddressAbsent,
973 codec::encode_exception(
974 request.function_code(),
975 codec::EXC_ILLEGAL_DATA_ADDRESS,
976 ),
977 ));
978 }
979 }
980 }
981 Ok(values)
982 }
983
984 /// Frame a PDU and write it, updating counters and the dual logs.
985 #[allow(clippy::too_many_arguments)]
986 async fn send_pdu(
987 connection_id: ConnectionId,
988 server_id: crate::state::ServerId,
989 transaction_id: u16,
990 unit_id: u8,
991 pdu: &[u8],
992 app_state: &Arc<AppState>,
993 connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
994 status_tx: &mpsc::UnboundedSender<String>,
995 ) {
996 // A PDU the framer refuses is dropped rather than sent in a frame whose MBAP length
997 // field disagrees with it. Every PDU reaching here is built by this module's own
998 // encoders, which are bounded, so this is a ratchet rather than a live path.
999 let adu = match codec::encode_adu(transaction_id, unit_id, pdu) {
1000 Ok(adu) => adu,
1001 Err(e) => {
1002 error!(
1003 "Modbus {} decision=fail_closed_encode: refusing to frame a {}-octet PDU: {}",
1004 connection_id,
1005 pdu.len(),
1006 e
1007 );
1008 return;
1009 }
1010 };
1011
1012 let write_half = {
1013 let conns = connections.lock().await;
1014 conns.get(&connection_id).map(|c| c.write_half.clone())
1015 };
1016 let Some(write_half) = write_half else {
1017 return;
1018 };
1019
1020 let mut write = write_half.lock().await;
1021 if let Err(e) = write.write_all(&adu).await {
1022 error!("Modbus write failed on {}: {}", connection_id, e);
1023 return;
1024 }
1025 if let Err(e) = write.flush().await {
1026 error!("Modbus flush failed on {}: {}", connection_id, e);
1027 return;
1028 }
1029 drop(write);
1030
1031 app_state
1032 .update_connection_stats(
1033 server_id,
1034 connection_id,
1035 None,
1036 Some(adu.len() as u64),
1037 None,
1038 Some(1),
1039 )
1040 .await;
1041
1042 // Summary + full payload FileOnly: the send_modbus_* action template already
1043 // reports the send to the TUI.
1044 let log = Log::new(Some(status_tx));
1045 log.debug(format!(
1046 "Modbus sent {} bytes to {} (txid={}, fc={:#04x})",
1047 adu.len(),
1048 connection_id,
1049 transaction_id,
1050 pdu.first().copied().unwrap_or(0)
1051 ));
1052 log.trace(format!("Modbus sent (hex): {}", hex::encode(&adu)));
1053 }
1054
1055 /// Drop a connection from both the local map and `AppState`.
1056 async fn close(
1057 connection_id: ConnectionId,
1058 server_id: crate::state::ServerId,
1059 app_state: &Arc<AppState>,
1060 connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
1061 status_tx: &mpsc::UnboundedSender<String>,
1062 ) {
1063 let removed = {
1064 let mut conns = connections.lock().await;
1065 conns.remove(&connection_id)
1066 };
1067 let write_half = removed.map(|c| {
1068 // `notify_one` stores a permit when the reader is not parked on `notified()` at this
1069 // instant, so the wake-up cannot be lost to a read that is mid-flight.
1070 c.closed.notify_one();
1071 c.write_half
1072 });
1073 if let Some(write_half) = write_half {
1074 let mut w = write_half.lock().await;
1075 let _ = w.shutdown().await;
1076 }
1077 app_state
1078 .remove_peer_handle(server_id, connection_id.as_u32())
1079 .await;
1080 app_state
1081 .close_connection_on_server(server_id, connection_id)
1082 .await;
1083 Log::new(Some(status_tx)).info(format!("Modbus connection {connection_id} closed"));
1084 let _ = status_tx.send("__UPDATE_UI__".to_string());
1085 }
1086}