Skip to main content

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}