Skip to main content

netget/server/mqtt/
mod.rs

1//! MQTT 3.1.1 and 5.0 broker with LLM-controlled responses
2//!
3//! Wire format is parsed by hand (see `packet` below): MQTT control packets are small enough
4//! that a dedicated broker crate would not earn its keep, and `rumqttd` owns its own
5//! routing/session model, which is exactly the part the LLM must own here.
6//!
7//! The version is the client's: a CONNECT with protocol level 5 makes the whole connection a
8//! v5 one — properties in every variable header, reason codes in every acknowledgement — and
9//! anything else is answered in 3.1.1 framing. [`v5`] holds the property codec and the reason
10//! code tables; the packet functions below take a `v5` flag rather than being duplicated.
11//!
12//! What the LLM decides:
13//! - whether a CONNECT is accepted, and with which return code (`mqtt_connack`)
14//! - the SUBACK granted-QoS vector for every SUBSCRIBE (`mqtt_suback`)
15//! - whether a QoS>0 PUBLISH is acknowledged (`mqtt_puback` / `mqtt_pubrec`)
16//! - every broker-originated PUBLISH, including which connected client receives it
17//!   (`mqtt_publish`, optionally targeted with `to_client_id`)
18//!
19//! What the broker does without asking (pure transport bookkeeping, no semantics):
20//! - PINGREQ -> PINGRESP
21//! - PUBREL -> PUBCOMP (second half of the QoS 2 handshake)
22//! - a default reply when the LLM/handler fails or returns nothing, so a client is
23//!   never left waiting on a response the spec says must arrive
24//!
25//! No storage: the broker keeps live socket handles for connected clients so a
26//! message can be delivered, and nothing else. There is no topic registry, no
27//! subscription table, no message queue and no retained-message store in Rust —
28//! subscriptions are reported to the model in the event and tracked in its memory.
29
30pub mod actions;
31pub mod v5;
32
33use crate::llm::action_helper::call_llm;
34use crate::llm::actions::protocol_trait::ActionResult;
35use crate::llm::ollama_client::OllamaClient;
36use crate::logging::emit::Log;
37use crate::protocol::Event;
38use crate::server::connection::ConnectionId;
39use crate::state::app_state::AppState;
40use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
41use actions::{
42    MqttProtocol, MQTT_CONNECT_EVENT, MQTT_PUBLISH_EVENT, MQTT_SUBSCRIBE_EVENT,
43    MQTT_UNSUBSCRIBE_EVENT,
44};
45use anyhow::Result;
46use std::net::SocketAddr;
47use std::sync::Arc;
48use std::time::Duration;
49use tokio::io::{AsyncReadExt, AsyncWriteExt};
50use tokio::net::{TcpListener, TcpStream};
51use tokio::sync::mpsc;
52use tracing::{debug, error, info, trace, warn};
53
54/// Largest control packet accepted from a client, in bytes.
55///
56/// The MQTT fixed header allows a 268 435 455 byte remaining length. Trusting it
57/// would let one client ask for a 256 MiB allocation per packet, so the value is
58/// capped and the connection is dropped instead.
59const DEFAULT_MAX_PACKET_SIZE: usize = 256 * 1024;
60
61/// Hard ceiling on the configurable `max_packet_size` startup parameter.
62pub const MAX_PACKET_SIZE_LIMIT: usize = 16 * 1024 * 1024;
63
64// ============================================================================
65// Packet type constants (MQTT 3.1.1 section 2.2.1)
66// ============================================================================
67
68pub const PKT_CONNECT: u8 = 1;
69pub const PKT_CONNACK: u8 = 2;
70pub const PKT_PUBLISH: u8 = 3;
71pub const PKT_PUBACK: u8 = 4;
72pub const PKT_PUBREC: u8 = 5;
73pub const PKT_PUBREL: u8 = 6;
74pub const PKT_PUBCOMP: u8 = 7;
75pub const PKT_SUBSCRIBE: u8 = 8;
76pub const PKT_SUBACK: u8 = 9;
77pub const PKT_UNSUBSCRIBE: u8 = 10;
78pub const PKT_UNSUBACK: u8 = 11;
79pub const PKT_PINGREQ: u8 = 12;
80pub const PKT_PINGRESP: u8 = 13;
81pub const PKT_DISCONNECT: u8 = 14;
82/// AUTH (MQTT 5.0 §3.15). Reserved in 3.1.1.
83pub const PKT_AUTH: u8 = 15;
84
85/// Topic aliases a v5 client may use towards this broker, advertised as Topic Alias Maximum
86/// in every v5 CONNACK (§3.2.2.3.8). An alias maps a 16-bit number to a topic name for the
87/// life of the connection, so the table is bounded by this number of entries.
88pub const TOPIC_ALIAS_MAX: u16 = 16;
89
90/// CONNACK return code 3, "Connection Refused, Server unavailable" (3.1.1 §3.2.2.3).
91///
92/// The spec requires the server to close the connection after any non-zero CONNACK, so this
93/// is a complete refusal rather than a warning the client can ignore.
94pub const CONNACK_SERVER_UNAVAILABLE: u8 = 3;
95
96/// SUBACK return code 0x80, "Failure" (3.1.1 §3.9.3). One per topic filter.
97pub const SUBACK_FAILURE: u8 = 0x80;
98
99/// Concurrent connections this broker admits before it starts refusing.
100///
101/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. An MQTT connection is a
102/// *session*, not a request: a subscriber holds its socket open for the life of the
103/// application, so unlike a request/response protocol this server's live-connection count is
104/// its client count rather than its arrival rate. That argues for keeping the house default
105/// rather than trimming it — 256 subscribed clients is a plausible small deployment, and the
106/// per-connection cost is bounded by `max_packet_size` either way.
107const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
108
109/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
110///
111/// CONNACK with return code 3, "Server unavailable" — the one code MQTT defines for "I am
112/// working, come back later", and the same one this broker already sends when a handler
113/// refuses a CONNECT. The bytes are [`build_connack`]`(`[`CONNACK_SERVER_UNAVAILABLE`]`,
114/// false)`, written out here because a `const` cannot call it.
115///
116/// **It is the 3.1.1 framing, because the peer's version is not yet known.** A v5 CONNACK
117/// carries an extra property-length field, so the two are not interchangeable, and the
118/// version is read from the CONNECT — which `accept_bounded` writes this before reading. A
119/// v5 client parses these four bytes as reason code 0x03, which is not a success code; it
120/// fails the connect either way, and the close that follows is the refusal.
121///
122/// 3.1.1 §3.2 requires the server to close the connection after a non-zero CONNACK, which is
123/// exactly what `accept_bounded` does next.
124const CONNECTION_CAP_REFUSAL: &[u8] = &[PKT_CONNACK << 4, 0x02, 0x00, CONNACK_SERVER_UNAVAILABLE];
125
126/// How long a connected peer may take to complete its CONNECT.
127///
128/// MQTT is client-speaks-first and 3.1.1 §3.1.4 says a server that receives no CONNECT "within
129/// a reasonable amount of time" SHOULD close. Every client sends CONNECT the moment its socket
130/// is up — including NetGet's own, whose `rumqttc` event loop writes it on its first poll inside
131/// `connect()` — so a peer still silent after 30 seconds has begun no session at all. Declared
132/// as the `first_byte_timeout_secs` startup parameter.
133pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
134
135/// How long a session whose client declared a Keep Alive of **0** may be silent.
136///
137/// A non-zero Keep Alive sets the bound itself — see [`session_idle_bound`] — but 0 turns the
138/// mechanism off (§3.1.2.10), and a bound that the peer can switch off in its own CONNECT is no
139/// bound at all: one byte of the CONNECT would hold a slot forever. So those sessions get this
140/// instead: fifteen minutes, `tcp`'s number, far longer than any client that chose 0 on purpose
141/// goes between packets in practice, and a clean reconnect for one that does. Declared as the
142/// `idle_timeout_secs` startup parameter, because the operator knows whether their subscribers
143/// are the long-silent kind.
144pub const IDLE_WITHOUT_KEEP_ALIVE: Duration = Duration::from_secs(900);
145
146/// The read deadlines a connection is served under, resolved from the startup parameters.
147#[derive(Clone, Copy, Debug)]
148pub struct MqttBounds {
149    /// Until CONNECT has been accepted.
150    pub connect: Duration,
151    /// After CONNECT, for a client that declared Keep Alive 0.
152    pub idle_without_keep_alive: Duration,
153}
154
155/// The silence a session is allowed once CONNECT has been accepted.
156///
157/// **One and a half times the client's own Keep Alive**, which is what 3.1.1 §3.1.2.10 requires
158/// of the server: "If the Keep Alive value is non-zero and the Server does not receive a Control
159/// Packet from the Client within one and a half times the Keep Alive time period, it MUST
160/// disconnect the Network Connection to the Client as if the network had failed." A client that
161/// has nothing to publish meets it with PINGREQ, so a healthy idle subscriber is never cut. A
162/// Keep Alive of 0 falls back to `fallback` (see [`IDLE_WITHOUT_KEEP_ALIVE`]).
163///
164/// The answer is not on this clock: every packet is dispatched inline, so the read is not polled
165/// while the model — or a human, through a `manual` rule — composes a reply.
166pub fn session_idle_bound(keep_alive_secs: u16, fallback: Duration) -> Duration {
167    if keep_alive_secs == 0 {
168        fallback
169    } else {
170        Duration::from_millis(u64::from(keep_alive_secs) * 1500)
171    }
172}
173
174/// MQTT broker
175pub struct MqttServer;
176
177impl MqttServer {
178    /// Spawn the MQTT broker.
179    ///
180    /// Bind failure is propagated to the caller (the server is never reported
181    /// `Running` on a port it does not hold).
182    pub async fn spawn_with_llm_actions(
183        listen_addr: SocketAddr,
184        llm_client: OllamaClient,
185        app_state: Arc<AppState>,
186        status_tx: mpsc::UnboundedSender<String>,
187        server_id: crate::state::ServerId,
188        startup_params: Option<crate::protocol::StartupParams>,
189    ) -> Result<SocketAddr> {
190        let max_packet_size = match startup_params.as_ref() {
191            Some(params) => match params.get_optional_u64("max_packet_size") {
192                Ok(Some(v)) => (v as usize).clamp(64, MAX_PACKET_SIZE_LIMIT),
193                Ok(None) => DEFAULT_MAX_PACKET_SIZE,
194                Err(e) => return Err(anyhow::anyhow!("MQTT startup parameter error: {}", e)),
195            },
196            None => DEFAULT_MAX_PACKET_SIZE,
197        };
198
199        // Both read bounds are the operator's to tune; the defaults are argued beside
200        // CONNECT_TIMEOUT and IDLE_WITHOUT_KEEP_ALIVE. A non-zero Keep Alive is the client's
201        // own, and is not overridable: 3.1.1 §3.1.2.10 makes 1.5x of it a MUST.
202        let secs = |name: &str| -> Result<Option<Duration>> {
203            match startup_params.as_ref() {
204                Some(params) => params
205                    .get_optional_u64(name)
206                    .map(|v| v.map(Duration::from_secs))
207                    .map_err(|e| anyhow::anyhow!("MQTT startup parameter error: {}", e)),
208                None => Ok(None),
209            }
210        };
211        let bounds = MqttBounds {
212            connect: secs("first_byte_timeout_secs")?.unwrap_or(CONNECT_TIMEOUT),
213            idle_without_keep_alive: secs("idle_timeout_secs")?.unwrap_or(IDLE_WITHOUT_KEEP_ALIVE),
214        };
215
216        let listener = TcpListener::bind(listen_addr).await?;
217        let local_addr = listener.local_addr()?;
218
219        Log::new(Some(&status_tx)).info(format!("MQTT broker listening on {}", local_addr));
220
221        let accept_state = app_state.clone();
222        let accept_status_tx = status_tx.clone();
223
224        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
225        let accept_handle = tokio::spawn(async move {
226            loop {
227                match crate::server::accept_bounded::accept_bounded(
228                    &listener,
229                    &limiter,
230                    CONNECTION_CAP_REFUSAL,
231                    "MQTT",
232                    Some(&accept_status_tx),
233                )
234                .await
235                {
236                    Ok((socket, peer_addr, permit)) => {
237                        Log::new(Some(&accept_status_tx))
238                            .debug(format!("MQTT connection from {}", peer_addr));
239
240                        let llm_client = llm_client.clone();
241                        let app_state = accept_state.clone();
242                        let status_tx = accept_status_tx.clone();
243
244                        // Tracked, not detached: stop_server must abort this task too.
245                        let task_owner = app_state.clone();
246                        task_owner
247                            .spawn_server_task(server_id, async move {
248                                // One MQTT connection is *two* tasks — this one and the writer
249                                // `handle_mqtt_connection` spawns — so the permit goes in as an
250                                // `Arc` held by both and the slot comes back when the later of
251                                // the two ends. Holding it here alone would release the slot
252                                // while a writer was still draining, which un-caps the broker.
253                                let permit = std::sync::Arc::new(permit);
254                                if let Err(e) = handle_mqtt_connection(
255                                    socket,
256                                    peer_addr,
257                                    local_addr,
258                                    llm_client,
259                                    app_state,
260                                    status_tx,
261                                    server_id,
262                                    max_packet_size,
263                                    permit,
264                                    bounds,
265                                )
266                                .await
267                                {
268                                    error!("MQTT connection error ({}): {}", peer_addr, e);
269                                }
270                            })
271                            .await;
272                    }
273                    Err(e) => {
274                        Log::new(Some(&accept_status_tx))
275                            .error(format!("MQTT accept error: {}", e));
276                        break;
277                    }
278                }
279            }
280        });
281
282        // Register the accept loop so stop_server can abort it and release the port.
283        // register_server_task keeps only one handle per server, so this is the only call.
284        app_state
285            .register_server_task(server_id, accept_handle)
286            .await;
287
288        let _ = status_tx.send("__UPDATE_UI__".to_string());
289        Ok(local_addr)
290    }
291}
292
293/// A parsed MQTT control packet: fixed-header type, its 4 flag bits, and the
294/// variable-header + payload bytes.
295struct RawPacket {
296    packet_type: u8,
297    flags: u8,
298    body: Vec<u8>,
299}
300
301/// What one connection knows about its own session.
302#[derive(Default)]
303struct Session {
304    /// Set by an accepted CONNECT; a second CONNECT is a protocol violation.
305    client_id: Option<String>,
306    /// The Keep Alive the client declared in its CONNECT, once one has been accepted.
307    keep_alive: Option<u16>,
308    /// The client connected with protocol level 5.
309    v5: bool,
310    /// v5 Topic Aliases the client has defined on this connection (§3.3.2.3.4), bounded by
311    /// [`TOPIC_ALIAS_MAX`].
312    aliases: std::collections::HashMap<u16, String>,
313}
314
315impl Session {
316    /// On a v5 connection, say why it is closing before it closes (§4.13.2: a server that
317    /// detects a malformed packet or a protocol error SHOULD send DISCONNECT with the reason).
318    /// 3.1.1 has no server DISCONNECT, so there the close itself is the whole message.
319    fn refuse(&self, out_tx: &mpsc::UnboundedSender<Vec<u8>>, reason: u8) {
320        if self.v5 {
321            let _ = out_tx.send(build_disconnect_v5(reason, &v5::PropertyWriter::new()));
322        }
323    }
324}
325
326#[allow(clippy::too_many_arguments)]
327async fn handle_mqtt_connection(
328    socket: TcpStream,
329    peer_addr: SocketAddr,
330    local_addr: SocketAddr,
331    llm_client: OllamaClient,
332    app_state: Arc<AppState>,
333    status_tx: mpsc::UnboundedSender<String>,
334    server_id: crate::state::ServerId,
335    max_packet_size: usize,
336    // This connection's slot in the accept loop's cap. Shared with the writer task below, so
337    // the slot is released by whichever of the two ends last.
338    permit: Arc<crate::server::accept_bounded::ConnectionPermit>,
339    bounds: MqttBounds,
340) -> Result<()> {
341    let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
342
343    let (mut read_half, write_half) = tokio::io::split(socket);
344
345    // The write half is shared between the channel-draining writer task and the peer
346    // command task. The writer task owns all outbound framing (below); the peer task
347    // only ever `shutdown()`s it, which is how the dashboard's "disconnect this peer"
348    // half-closes a connection from outside the read loop.
349    let write_half = Arc::new(tokio::sync::Mutex::new(write_half));
350
351    // All writes for this connection funnel through one channel so that packets
352    // produced by the read loop and packets produced by an action (possibly for a
353    // different connection) can never interleave mid-packet.
354    let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
355    let writer_status_tx = status_tx.clone();
356    let writer_write_half = write_half.clone();
357    let writer_state = app_state.clone();
358    let writer_permit = Arc::clone(&permit);
359    // The Maximum Packet Size a v5 client declared in its CONNECT, 0 when it set none. A
360    // server "MUST NOT send packets exceeding Maximum Packet Size to the Client" and discards
361    // a PUBLISH that would (§3.1.2.11.4), so the one place every outbound packet passes is
362    // where it is enforced.
363    let client_max_packet = Arc::new(std::sync::atomic::AtomicU32::new(0));
364    let writer_client_max = client_max_packet.clone();
365    let writer_handle = tokio::spawn(async move {
366        // The other half of this connection's cap slot. `finish_connection` waits at most 5
367        // seconds for this task, so on the path where it gives up and abandons the writer the
368        // slot is still held by the writer's own Arc until it really ends.
369        let _permit = writer_permit;
370        while let Some(bytes) = out_rx.recv().await {
371            let n = bytes.len();
372            let limit = writer_client_max.load(std::sync::atomic::Ordering::SeqCst) as usize;
373            if limit > 0 && n > limit {
374                Log::new(Some(&writer_status_tx)).warn(format!(
375                    "MQTT dropped a {n}-byte packet (type {}) for connection {connection_id}: \
376                     the client's Maximum Packet Size is {limit} \
377                     decision=dropped_over_client_max_packet_size",
378                    bytes.first().map(|b| b >> 4).unwrap_or(0)
379                ));
380                continue;
381            }
382            let mut guard = writer_write_half.lock().await;
383            if let Err(e) = guard.write_all(&bytes).await {
384                Log::new(Some(&writer_status_tx)).debug(format!("MQTT write failed: {}", e));
385                break;
386            }
387            let _ = guard.flush().await;
388            drop(guard);
389            // Live byte/packet counters for the dashboard rail. Every outbound MQTT
390            // packet crosses this channel, so this is the one place all writes are seen.
391            writer_state
392                .update_connection_stats(
393                    server_id,
394                    connection_id,
395                    None,
396                    Some(n as u64),
397                    None,
398                    Some(1),
399                )
400                .await;
401        }
402    });
403
404    let now = crate::utils::clock::Instant::now();
405    let conn_state = ConnectionState {
406        id: connection_id,
407        remote_addr: peer_addr,
408        local_addr,
409        bytes_sent: 0,
410        bytes_received: 0,
411        packets_sent: 0,
412        packets_received: 0,
413        last_activity: now,
414        status: ConnectionStatus::Active,
415        status_changed_at: now,
416        protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
417            "client_id": serde_json::Value::Null,
418        })),
419    };
420    app_state
421        .add_connection_to_server(server_id, conn_state)
422        .await;
423    let _ = status_tx.send("__UPDATE_UI__".to_string());
424
425    let protocol = Arc::new(MqttProtocol::for_connection(
426        server_id,
427        connection_id,
428        out_tx.clone(),
429        status_tx.clone(),
430    ));
431
432    // Peer injection: the dashboard's "message this peer" / "disconnect this peer" run an
433    // action against THIS connection through the same executor the model path uses. A
434    // messaging verb (e.g. mqtt_publish) writes through the connection's own out channel and
435    // reports Executed; a close ({"type":"close_connection"}) half-closes the shared write
436    // half. Registered before the first read so the operator can reach the connection while
437    // it is idle - the read loop blocks in read() without a select.
438    let peer_rx = crate::server::peer_support::register_peer_channel(
439        &app_state,
440        server_id,
441        connection_id.as_u32(),
442    )
443    .await;
444    crate::server::peer_support::spawn_peer_command_task(
445        peer_rx,
446        protocol.clone(),
447        app_state.clone(),
448        server_id,
449        connection_id.as_u32(),
450        write_half.clone(),
451        status_tx.clone(),
452    );
453
454    let mut session = Session::default();
455    let mut buffer: Vec<u8> = Vec::with_capacity(1024);
456    let mut chunk = vec![0u8; 4096];
457
458    let close_reason = loop {
459        // Before CONNECT: the connect bound. After it: 1.5x the client's own Keep Alive, or the
460        // fallback when it declared 0. The deadline wraps the read and nothing else — packets
461        // are dispatched inline below, so a model round-trip is never on this clock.
462        let bound = match session.keep_alive {
463            None => bounds.connect,
464            Some(ka) => session_idle_bound(ka, bounds.idle_without_keep_alive),
465        };
466        let read = match tokio::time::timeout(bound, read_half.read(&mut chunk)).await {
467            Ok(read) => read,
468            Err(_) => {
469                Log::new(Some(&status_tx)).info(format!(
470                    "MQTT connection {} from {} sent nothing for {}ms decision=idle_timeout                      ({}); closing",
471                    connection_id,
472                    peer_addr,
473                    bound.as_millis(),
474                    match session.keep_alive {
475                        None => "no CONNECT".to_string(),
476                        Some(0) => "keep-alive 0".to_string(),
477                        Some(ka) => format!("1.5x keep-alive {}s", ka),
478                    }
479                ));
480                break "idle deadline expired";
481            }
482        };
483        let n = match read {
484            Ok(0) => break "peer closed the connection",
485            Ok(n) => {
486                app_state
487                    .update_connection_stats(
488                        server_id,
489                        connection_id,
490                        Some(n as u64),
491                        None,
492                        Some(1),
493                        None,
494                    )
495                    .await;
496                n
497            }
498            Err(e) => {
499                debug!("MQTT read error from {}: {}", peer_addr, e);
500                break "read error";
501            }
502        };
503        buffer.extend_from_slice(chunk.get(..n).unwrap_or(&[]));
504
505        // Drain every complete packet currently in the buffer.
506        loop {
507            let packet = match take_packet(&mut buffer, max_packet_size) {
508                Ok(Some(p)) => p,
509                Ok(None) => break, // need more bytes
510                Err(e) => {
511                    Log::new(Some(&status_tx)).warn(format!(
512                        "MQTT protocol error from {} decision=protocol_error: {}",
513                        peer_addr, e
514                    ));
515                    // A malformed packet desynchronises the stream; the spec says close.
516                    session.refuse(&out_tx, e.reason_code());
517                    finish_connection(
518                        protocol,
519                        out_tx,
520                        writer_handle,
521                        &app_state,
522                        server_id,
523                        connection_id,
524                        &session.client_id,
525                        &status_tx,
526                    )
527                    .await;
528                    return Ok(());
529                }
530            };
531
532            trace!(
533                "MQTT packet type={} flags={:#x} len={} from {}",
534                packet.packet_type,
535                packet.flags,
536                packet.body.len(),
537                peer_addr
538            );
539
540            let keep_open = dispatch_packet(
541                packet,
542                &mut session,
543                &client_max_packet,
544                max_packet_size,
545                &llm_client,
546                &app_state,
547                &status_tx,
548                server_id,
549                connection_id,
550                protocol.clone(),
551                &out_tx,
552            )
553            .await;
554
555            if !keep_open {
556                finish_connection(
557                    protocol,
558                    out_tx,
559                    writer_handle,
560                    &app_state,
561                    server_id,
562                    connection_id,
563                    &session.client_id,
564                    &status_tx,
565                )
566                .await;
567                return Ok(());
568            }
569        }
570    };
571
572    debug!(
573        "MQTT connection {} closing: {}",
574        connection_id, close_reason
575    );
576    Log::new(Some(&status_tx)).info(format!(
577        "MQTT client {} disconnected ({})",
578        session.client_id.as_deref().unwrap_or("<unidentified>"),
579        close_reason
580    ));
581
582    finish_connection(
583        protocol,
584        out_tx,
585        writer_handle,
586        &app_state,
587        server_id,
588        connection_id,
589        &session.client_id,
590        &status_tx,
591    )
592    .await;
593    Ok(())
594}
595
596/// Shut one connection down: flush whatever is still queued, then release everything.
597///
598/// The ordering is the whole point. The writer task ends when its receiver sees every sender
599/// gone, and there are three: the local `out_tx`, a clone inside `MqttProtocol` (so actions can
600/// write to this connection) and a clone in the global client directory (so *another*
601/// connection's `mqtt_publish` can). Awaiting the writer while any of them is still alive waits
602/// forever - which it did: `drop(out_tx); writer_handle.await;` on all three exit paths left
603/// every MQTT connection task and its socket parked permanently, including on the ordinary
604/// client-disconnect path and on `close_this_connection`.
605///
606/// So: unregister from the directory, drop the protocol handle, drop the local sender, and only
607/// then wait for the queue to drain. The wait is bounded anyway - a sender leaked by some
608/// future change must not be able to wedge a connection task again.
609#[allow(clippy::too_many_arguments)]
610async fn finish_connection(
611    protocol: Arc<MqttProtocol>,
612    out_tx: mpsc::UnboundedSender<Vec<u8>>,
613    writer_handle: tokio::task::JoinHandle<()>,
614    app_state: &Arc<AppState>,
615    server_id: crate::state::ServerId,
616    connection_id: ConnectionId,
617    client_id: &Option<String>,
618    status_tx: &mpsc::UnboundedSender<String>,
619) {
620    // Drop the peer handle first: the peer command task holds an Arc<MqttProtocol>, which
621    // owns an out_tx clone, so the writer task below cannot finish until that task ends.
622    // Removing the handle closes its command channel and lets it wind down.
623    app_state
624        .remove_peer_handle(server_id, connection_id.as_u32())
625        .await;
626    cleanup(app_state, server_id, connection_id, client_id, status_tx).await;
627    drop(protocol);
628    drop(out_tx);
629    if tokio::time::timeout(std::time::Duration::from_secs(5), writer_handle)
630        .await
631        .is_err()
632    {
633        warn!(
634            "MQTT connection {}: writer did not finish within 5s, abandoning it",
635            connection_id
636        );
637    }
638}
639
640async fn cleanup(
641    app_state: &AppState,
642    server_id: crate::state::ServerId,
643    connection_id: ConnectionId,
644    client_id: &Option<String>,
645    status_tx: &mpsc::UnboundedSender<String>,
646) {
647    if let Some(id) = client_id {
648        actions::unregister_client(server_id, id);
649    }
650    app_state
651        .update_connection_status(server_id, connection_id, ConnectionStatus::Closed)
652        .await;
653    let _ = status_tx.send("__UPDATE_UI__".to_string());
654}
655
656/// Handle one control packet. Returns false when the connection must close.
657#[allow(clippy::too_many_arguments)]
658async fn dispatch_packet(
659    packet: RawPacket,
660    session: &mut Session,
661    client_max_packet: &std::sync::atomic::AtomicU32,
662    max_packet_size: usize,
663    llm_client: &OllamaClient,
664    app_state: &Arc<AppState>,
665    status_tx: &mpsc::UnboundedSender<String>,
666    server_id: crate::state::ServerId,
667    connection_id: ConnectionId,
668    protocol: Arc<MqttProtocol>,
669    out_tx: &mpsc::UnboundedSender<Vec<u8>>,
670) -> bool {
671    match packet.packet_type {
672        PKT_CONNECT => {
673            let connect = match parse_connect(&packet.body) {
674                Ok(c) => c,
675                Err(e) => {
676                    Log::new(Some(&status_tx)).warn(format!(
677                        "MQTT malformed CONNECT from client decision=protocol_error, closing: {e}"
678                    ));
679                    // A v5 client is told why in its own framing (§3.2.2.2: 0x81 Malformed
680                    // Packet / 0x82 Protocol Error); a 3.1.1 CONNACK has no code for it.
681                    if let Some(code) = e.v5_connack_code() {
682                        let _ =
683                            out_tx.send(build_connack_v5(code, false, &v5::PropertyWriter::new()));
684                    }
685                    return false;
686                }
687            };
688
689            if session.client_id.is_some() {
690                // 3.1.0-2: a second CONNECT on one connection is a protocol violation.
691                Log::new(Some(&status_tx))
692                    .warn("MQTT duplicate CONNECT decision=protocol_error, closing connection");
693                session.refuse(out_tx, 0x82);
694                return false;
695            }
696
697            session.v5 = connect.protocol_level == 5;
698            protocol.set_v5(session.v5);
699            // The client asked for enhanced authentication (§4.12), which this broker does not
700            // perform. Only an explicit `mqtt_connack` from the handler admits such a client.
701            let wants_enhanced_auth =
702                session.v5 && connect.properties.text(v5::AUTHENTICATION_METHOD).is_some();
703            let assigned = connect.client_id.is_empty();
704            let effective_id = if assigned {
705                format!("anon-{}", connection_id.as_u32())
706            } else {
707                connect.client_id.clone()
708            };
709            session.client_id = Some(effective_id.clone());
710            session.keep_alive = Some(connect.keep_alive);
711            if session.v5 {
712                if let Some(limit) = connect.properties.int(v5::MAXIMUM_PACKET_SIZE) {
713                    client_max_packet.store(limit, std::sync::atomic::Ordering::SeqCst);
714                }
715                // What every accepting v5 CONNACK carries without the handler having to say
716                // it: the id this broker assigned when the client sent none (§3.2.2.3.7 makes
717                // that a MUST), the largest packet it will accept, and the aliases it takes.
718                let mut auto = v5::PropertyWriter::new();
719                if assigned {
720                    auto.text(v5::ASSIGNED_CLIENT_IDENTIFIER, &effective_id);
721                }
722                auto.u32(v5::MAXIMUM_PACKET_SIZE, max_packet_size as u32)
723                    .u16(v5::TOPIC_ALIAS_MAXIMUM, TOPIC_ALIAS_MAX);
724                protocol.set_connack_properties(auto);
725            }
726            actions::register_client(server_id, &effective_id, out_tx.clone(), session.v5);
727            protocol.set_client_id(&effective_id);
728
729            app_state
730                .with_server_mut(server_id, |server| {
731                    if let Some(conn) = server.connections.get_mut(&connection_id) {
732                        conn.protocol_info = ProtocolConnectionInfo::new(serde_json::json!({
733                            "client_id": effective_id,
734                            "protocol_version": version_name(connect.protocol_level),
735                        }));
736                    }
737                })
738                .await;
739
740            info!(
741                "MQTT client '{}' connected (v{}, clean_session={}, keep_alive={}s)",
742                effective_id,
743                version_name(connect.protocol_level),
744                connect.clean_session,
745                connect.keep_alive
746            );
747            Log::new(Some(&status_tx)).info(format!(
748                "MQTT CONNECT from client '{}' (MQTT {}, user={}, clean_session={})",
749                effective_id,
750                version_name(connect.protocol_level),
751                connect.username.as_deref().unwrap_or("-"),
752                connect.clean_session
753            ));
754
755            let mut data = serde_json::json!({
756                "client_id": effective_id,
757                "username": connect.username,
758                "has_password": connect.has_password,
759                "clean_session": connect.clean_session,
760                "keep_alive": connect.keep_alive,
761                "protocol_name": connect.protocol_name,
762                "protocol_level": connect.protocol_level,
763                "protocol_version": version_name(connect.protocol_level),
764                "will_topic": connect.will_topic,
765                "will_message": connect.will_message,
766            });
767            if session.v5 {
768                data["properties"] = connect.properties.to_json();
769                data["will_properties"] = connect.will_properties.to_json();
770                data["assigned_client_id"] = serde_json::json!(assigned);
771            }
772            let event = Event::new(&MQTT_CONNECT_EVENT, data);
773
774            let outcome = run_llm(
775                llm_client,
776                app_state,
777                server_id,
778                connection_id,
779                &event,
780                protocol.as_ref(),
781                status_tx,
782                Some(PKT_CONNACK),
783            )
784            .await;
785
786            match outcome {
787                LlmOutcome::Closed => false,
788                // A refusing CONNACK ends the connection on the server's side too: 3.1.1
789                // §3.2.2.3 and 5.0 §3.2.2.2 both say the server MUST close after one.
790                LlmOutcome::Responded => !protocol.connack_refused(),
791                LlmOutcome::Silent if wants_enhanced_auth => {
792                    // The CONNECT names an Authentication Method and the handler said nothing.
793                    // Accepting would admit a client whose authentication never ran, so silence
794                    // refuses here: 0x8C Bad Authentication Method, and the server closes after
795                    // a refusing CONNACK (§3.2.2.2).
796                    Log::new(Some(&status_tx)).warn(format!(
797                        "MQTT CONNECT from '{}' names an authentication method and the handler \
798                         sent no mqtt_connack decision=fail_closed_auth_method, refusing with \
799                         CONNACK 0x8C",
800                        effective_id
801                    ));
802                    let _ = out_tx.send(build_connack_v5(0x8C, false, &v5::PropertyWriter::new()));
803                    false
804                }
805                LlmOutcome::Silent => {
806                    // CONNACK is mandatory (3.2). Accept by default rather than let the
807                    // client sit in its connect timeout. Safe only because the handler ran
808                    // and declined to decide - see `LlmOutcome::Failed`.
809                    //
810                    // Read that carefully before copying it: this is the one place in MQTT
811                    // where a *permissive* packet follows a `decision=model_silent`. The
812                    // session is accepted, credentials and all, because the model said
813                    // nothing - which is the OAuth2 shape, narrowed to the case where the
814                    // handler demonstrably ran and the client asked for no enhanced
815                    // authentication (the arm above refuses that one). The preceding
816                    // `decision=model_silent` line is what makes it auditable; see the
817                    // fail-open note in `src/server/mqtt/AGENTS.md`.
818                    Log::new(Some(&status_tx)).warn(format!(
819                        "MQTT no mqtt_connack from handler for '{}'; accepting by default \
820                         (CONNACK 0 — an accepted session nothing decided)",
821                        effective_id
822                    ));
823                    let _ = out_tx.send(if session.v5 {
824                        build_connack_v5(0, false, &protocol.connack_properties())
825                    } else {
826                        build_connack(0, false)
827                    });
828                    true
829                }
830                LlmOutcome::Failed(failure) => {
831                    // CONNACK return code 3 is "Server unavailable" (3.2.2.3). It refuses the
832                    // connection, and 3.2.2.3 requires the server to close afterwards - so a
833                    // client cannot proceed to PUBLISH or SUBSCRIBE on the strength of a
834                    // backend that never answered. Never code 0 here: that is an accepted
835                    // connection, and for a CONNECT carrying credentials it is an
836                    // authentication decision made by an outage.
837                    //
838                    // v5 can say which kind of outage: 0x89 Server Busy is the retryable one,
839                    // 0x88 Server Unavailable the other, and a Reason String carries the
840                    // category (never the error itself).
841                    Log::new(Some(&status_tx)).warn(format!(
842                        "MQTT refusing CONNECT from '{}' with CONNACK {} (server {}): \
843                         backend failed",
844                        effective_id,
845                        if session.v5 { "0x88/0x89" } else { "3" },
846                        if failure.is_overloaded() {
847                            "busy"
848                        } else {
849                            "unavailable"
850                        }
851                    ));
852                    let _ = out_tx.send(if session.v5 {
853                        let code = if failure.is_overloaded() { 0x89 } else { 0x88 };
854                        let mut props = v5::PropertyWriter::new();
855                        props.text(v5::REASON_STRING, failure.text());
856                        build_connack_v5(code, false, &props)
857                    } else {
858                        build_connack(CONNACK_SERVER_UNAVAILABLE, false)
859                    });
860                    false
861                }
862            }
863        }
864
865        PKT_PUBLISH => {
866            let qos = (packet.flags >> 1) & 0x03;
867            if qos > 2 {
868                Log::new(Some(&status_tx))
869                    .warn("MQTT PUBLISH with invalid QoS 3 decision=protocol_error, closing");
870                session.refuse(out_tx, 0x81);
871                return false;
872            }
873            let mut publish = match parse_publish(&packet.body, qos, session.v5) {
874                Ok(p) => p,
875                Err(e) => {
876                    Log::new(Some(&status_tx)).warn(format!(
877                        "MQTT malformed PUBLISH decision=protocol_error, closing connection: {e}"
878                    ));
879                    session.refuse(out_tx, e.reason_code());
880                    return false;
881                }
882            };
883
884            // Topic Alias (§3.3.2.3.4): a non-empty topic with an alias defines it, an empty
885            // topic with an alias uses it. The model always sees the topic name.
886            if let Some(alias) = publish.properties.int(v5::TOPIC_ALIAS) {
887                let alias = alias as u16;
888                if alias == 0 || alias > TOPIC_ALIAS_MAX {
889                    Log::new(Some(&status_tx)).warn(format!(
890                        "MQTT PUBLISH with topic alias {alias} outside 1..={TOPIC_ALIAS_MAX} \
891                         decision=protocol_error, closing"
892                    ));
893                    session.refuse(out_tx, 0x94);
894                    return false;
895                }
896                if publish.topic.is_empty() {
897                    match session.aliases.get(&alias) {
898                        Some(topic) => publish.topic = topic.clone(),
899                        None => {
900                            Log::new(Some(&status_tx)).warn(format!(
901                                "MQTT PUBLISH uses topic alias {alias}, which this connection \
902                                 never defined decision=protocol_error, closing"
903                            ));
904                            session.refuse(out_tx, 0x82);
905                            return false;
906                        }
907                    }
908                } else {
909                    session.aliases.insert(alias, publish.topic.clone());
910                }
911            } else if session.v5 && publish.topic.is_empty() {
912                Log::new(Some(&status_tx)).warn(
913                    "MQTT PUBLISH with an empty topic and no topic alias \
914                     decision=protocol_error, closing",
915                );
916                session.refuse(out_tx, 0x82);
917                return false;
918            }
919
920            let (payload, payload_is_text) = decode_payload(&publish.payload);
921            let name = session
922                .client_id
923                .clone()
924                .unwrap_or_else(|| "<no CONNECT>".into());
925
926            Log::new(Some(&status_tx)).debug(format!(
927                "MQTT PUBLISH from '{}' topic='{}' qos={} retain={} {} bytes",
928                name,
929                publish.topic,
930                qos,
931                (packet.flags & 0x01) == 1,
932                publish.payload.len()
933            ));
934
935            let mut data = serde_json::json!({
936                "client_id": name,
937                "topic": publish.topic,
938                "payload": payload,
939                "payload_is_text": payload_is_text,
940                "payload_size": publish.payload.len(),
941                "qos": qos,
942                "retain": (packet.flags & 0x01) == 1,
943                "duplicate": (packet.flags & 0x08) != 0,
944                "packet_id": publish.packet_id,
945                "connected_clients": actions::list_clients(server_id),
946            });
947            if session.v5 {
948                data["properties"] = publish.properties.to_json();
949            }
950            let event = Event::new(&MQTT_PUBLISH_EVENT, data);
951
952            let outcome = run_llm(
953                llm_client,
954                app_state,
955                server_id,
956                connection_id,
957                &event,
958                protocol.as_ref(),
959                status_tx,
960                match qos {
961                    1 => Some(PKT_PUBACK),
962                    2 => Some(PKT_PUBREC),
963                    _ => None,
964                },
965            )
966            .await;
967
968            match outcome {
969                LlmOutcome::Closed => false,
970                LlmOutcome::Responded => true,
971                LlmOutcome::Silent => {
972                    // QoS 0 needs no acknowledgement; QoS 1/2 do, and the packet
973                    // identifier must be echoed or the client retries forever. The two-byte
974                    // form is a valid v5 acknowledgement too: Reason Code 0x00 and no
975                    // properties may be omitted (§3.4.2.1).
976                    match qos {
977                        1 => {
978                            let _ = out_tx.send(build_ack(PKT_PUBACK, publish.packet_id));
979                        }
980                        2 => {
981                            let _ = out_tx.send(build_ack(PKT_PUBREC, publish.packet_id));
982                        }
983                        _ => {}
984                    }
985                    true
986                }
987                LlmOutcome::Failed(failure) => {
988                    // MQTT 3.1.1 has no failure code in PUBACK or PUBREC - the acknowledgement
989                    // is a two-byte packet identifier and nothing else - so there is no way to
990                    // say "received but not handled". Sending one anyway would tell the
991                    // publisher its message was taken, which is the one thing that must not
992                    // happen when nothing looked at it. The spec's only channel for a broker
993                    // error at this point is closing the connection, so that is what happens:
994                    // the publisher gets no acknowledgement, keeps the message, and retries
995                    // on reconnect exactly as QoS 1/2 promise.
996                    //
997                    // v5 does have failure codes in PUBACK, but a PUBACK of any kind completes
998                    // the exchange and the publisher discards the message - so it is still the
999                    // close, now preceded by a DISCONNECT saying why: 0x89 Server Busy when the
1000                    // backend is saturated, 0x80 Unspecified Error otherwise.
1001                    Log::new(Some(&status_tx)).warn(format!(
1002                        "MQTT closing connection: backend failed on PUBLISH (qos={}, id={}), \
1003                         acknowledging it would claim delivery",
1004                        qos, publish.packet_id
1005                    ));
1006                    if session.v5 {
1007                        let mut props = v5::PropertyWriter::new();
1008                        props.text(v5::REASON_STRING, failure.text());
1009                        let code = if failure.is_overloaded() { 0x89 } else { 0x80 };
1010                        let _ = out_tx.send(build_disconnect_v5(code, &props));
1011                    }
1012                    false
1013                }
1014            }
1015        }
1016
1017        PKT_PUBREL => {
1018            // Second half of the QoS 2 handshake: transport bookkeeping only, so it is
1019            // answered without consulting the model. The packet identifier is echoed.
1020            let packet_id = read_u16_at(&packet.body, 0).unwrap_or(0);
1021            trace!("MQTT PUBREL id={} -> PUBCOMP", packet_id);
1022            let _ = out_tx.send(build_ack(PKT_PUBCOMP, packet_id));
1023            true
1024        }
1025
1026        PKT_SUBSCRIBE => {
1027            let sub = match parse_subscribe(&packet.body, session.v5) {
1028                Ok(s) => s,
1029                Err(e) => {
1030                    Log::new(Some(&status_tx)).warn(format!(
1031                        "MQTT malformed SUBSCRIBE decision=protocol_error, closing connection: {e}"
1032                    ));
1033                    session.refuse(out_tx, e.reason_code());
1034                    return false;
1035                }
1036            };
1037            if sub.topics.is_empty() {
1038                Log::new(Some(&status_tx))
1039                    .warn("MQTT SUBSCRIBE with no topic filter decision=protocol_error, closing");
1040                session.refuse(out_tx, 0x82);
1041                return false;
1042            }
1043
1044            let name = session
1045                .client_id
1046                .clone()
1047                .unwrap_or_else(|| "<no CONNECT>".into());
1048            Log::new(Some(&status_tx)).info(format!(
1049                "MQTT SUBSCRIBE from '{}': {}",
1050                name,
1051                sub.topics
1052                    .iter()
1053                    .map(|(f, q)| format!("{} (qos {})", f, q))
1054                    .collect::<Vec<_>>()
1055                    .join(", ")
1056            ));
1057
1058            let topics: Vec<serde_json::Value> = sub
1059                .topics
1060                .iter()
1061                .zip(sub.options.iter())
1062                .map(|((filter, qos), opts)| {
1063                    let mut t = serde_json::json!({ "filter": filter, "qos": qos });
1064                    if session.v5 {
1065                        t["no_local"] = serde_json::json!(opts.no_local);
1066                        t["retain_as_published"] = serde_json::json!(opts.retain_as_published);
1067                        t["retain_handling"] = serde_json::json!(opts.retain_handling);
1068                    }
1069                    t
1070                })
1071                .collect();
1072
1073            let mut data = serde_json::json!({
1074                "client_id": name,
1075                "packet_id": sub.packet_id,
1076                "topics": topics,
1077                "answer_with": subscribe_answer_with(sub.packet_id, &sub.topics),
1078            });
1079            if session.v5 {
1080                data["properties"] = sub.properties.to_json();
1081            }
1082            let event = Event::new(&MQTT_SUBSCRIBE_EVENT, data);
1083
1084            let outcome = run_llm(
1085                llm_client,
1086                app_state,
1087                server_id,
1088                connection_id,
1089                &event,
1090                protocol.as_ref(),
1091                status_tx,
1092                Some(PKT_SUBACK),
1093            )
1094            .await;
1095
1096            match outcome {
1097                LlmOutcome::Closed => false,
1098                LlmOutcome::Responded => true,
1099                LlmOutcome::Silent => {
1100                    // SUBACK is mandatory (3.8.4) and must echo the packet identifier.
1101                    let granted: Vec<u8> = sub.topics.iter().map(|(_, q)| *q).collect();
1102                    Log::new(Some(&status_tx)).warn(format!(
1103                        "MQTT no mqtt_suback from handler; granting requested QoS to '{}'",
1104                        name
1105                    ));
1106                    let _ = out_tx.send(if session.v5 {
1107                        build_suback_v5(sub.packet_id, &granted, &v5::PropertyWriter::new())
1108                    } else {
1109                        build_suback(sub.packet_id, &granted)
1110                    });
1111                    true
1112                }
1113                LlmOutcome::Failed(failure) => {
1114                    // SUBACK carries a per-filter return code, and 0x80 is Failure (3.9.3).
1115                    // This is the one place in 3.1.1 where a refusal has a proper wire form,
1116                    // so use it rather than granting: granting a subscription is an access
1117                    // decision, and nothing decided it. v5's 0x80 is Unspecified Error, and
1118                    // its Reason String carries the category.
1119                    let refused = vec![SUBACK_FAILURE; sub.topics.len()];
1120                    Log::new(Some(&status_tx)).warn(format!(
1121                        "MQTT refusing all {} subscription(s) from '{}' with SUBACK 0x80: \
1122                         backend failed",
1123                        sub.topics.len(),
1124                        name
1125                    ));
1126                    let _ = out_tx.send(if session.v5 {
1127                        let mut props = v5::PropertyWriter::new();
1128                        props.text(v5::REASON_STRING, failure.text());
1129                        build_suback_v5(sub.packet_id, &refused, &props)
1130                    } else {
1131                        build_suback(sub.packet_id, &refused)
1132                    });
1133                    true
1134                }
1135            }
1136        }
1137
1138        PKT_UNSUBSCRIBE => {
1139            let unsub = match parse_unsubscribe(&packet.body, session.v5) {
1140                Ok(u) => u,
1141                Err(e) => {
1142                    Log::new(Some(&status_tx)).warn(format!(
1143                        "MQTT malformed UNSUBSCRIBE decision=protocol_error, closing connection: \
1144                         {e}"
1145                    ));
1146                    session.refuse(out_tx, e.reason_code());
1147                    return false;
1148                }
1149            };
1150
1151            let name = session
1152                .client_id
1153                .clone()
1154                .unwrap_or_else(|| "<no CONNECT>".into());
1155            Log::new(Some(&status_tx)).info(format!(
1156                "MQTT UNSUBSCRIBE from '{}': {}",
1157                name,
1158                unsub.topics.join(", ")
1159            ));
1160            // A v5 UNSUBACK carries one reason code per filter (§3.11.3), so the executor has
1161            // to know how many this one had.
1162            protocol.note_unsubscribe(unsub.packet_id, unsub.topics.len());
1163
1164            let mut data = serde_json::json!({
1165                "client_id": name,
1166                "packet_id": unsub.packet_id,
1167                "topics": unsub.topics,
1168            });
1169            if session.v5 {
1170                data["properties"] = unsub.properties.to_json();
1171            }
1172            let event = Event::new(&MQTT_UNSUBSCRIBE_EVENT, data);
1173
1174            let outcome = run_llm(
1175                llm_client,
1176                app_state,
1177                server_id,
1178                connection_id,
1179                &event,
1180                protocol.as_ref(),
1181                status_tx,
1182                Some(PKT_UNSUBACK),
1183            )
1184            .await;
1185
1186            match outcome {
1187                LlmOutcome::Closed => false,
1188                LlmOutcome::Responded => true,
1189                LlmOutcome::Silent => {
1190                    let _ = out_tx.send(if session.v5 {
1191                        build_unsuback_v5(
1192                            unsub.packet_id,
1193                            &vec![0x00; unsub.topics.len()],
1194                            &v5::PropertyWriter::new(),
1195                        )
1196                    } else {
1197                        build_ack(PKT_UNSUBACK, unsub.packet_id)
1198                    });
1199                    true
1200                }
1201                LlmOutcome::Failed(failure) if session.v5 => {
1202                    // v5 UNSUBACK has a failure code per filter, 0x80 Unspecified Error, which
1203                    // says the subscription was NOT removed. That is true, so it is the answer
1204                    // and the connection stays up.
1205                    Log::new(Some(&status_tx)).warn(format!(
1206                        "MQTT refusing UNSUBSCRIBE (id={}) from '{}' with UNSUBACK 0x80: \
1207                         backend failed",
1208                        unsub.packet_id, name
1209                    ));
1210                    let mut props = v5::PropertyWriter::new();
1211                    props.text(v5::REASON_STRING, failure.text());
1212                    let _ = out_tx.send(build_unsuback_v5(
1213                        unsub.packet_id,
1214                        &vec![0x80; unsub.topics.len()],
1215                        &props,
1216                    ));
1217                    true
1218                }
1219                LlmOutcome::Failed(_) => {
1220                    // Like PUBACK, UNSUBACK in 3.1.1 is a bare packet identifier with no
1221                    // failure code, and it means "the subscriptions are gone". Claiming that
1222                    // when nothing processed the request would leave the client believing it
1223                    // had unsubscribed. Close instead.
1224                    Log::new(Some(&status_tx)).warn(format!(
1225                        "MQTT closing connection: backend failed on UNSUBSCRIBE (id={}), \
1226                         acknowledging it would claim the subscriptions were removed",
1227                        unsub.packet_id
1228                    ));
1229                    false
1230                }
1231            }
1232        }
1233
1234        PKT_PINGREQ => {
1235            // Keep-alive only: no semantics for the model to decide.
1236            trace!("MQTT PINGREQ -> PINGRESP");
1237            let _ = out_tx.send(vec![PKT_PINGRESP << 4, 0x00]);
1238            true
1239        }
1240
1241        PKT_DISCONNECT => {
1242            // v5 DISCONNECT carries a reason code (absent means 0x00, §3.14.2.1) and may carry
1243            // a Reason String; both are reported, neither is answered.
1244            let (reason, detail) = if session.v5 {
1245                let reason = packet.body.first().copied().unwrap_or(0);
1246                let mut pos = 1usize;
1247                let text = if packet.body.len() > 1 {
1248                    v5::read_properties(&packet.body, &mut pos)
1249                        .ok()
1250                        .and_then(|p| p.text(v5::REASON_STRING).map(str::to_string))
1251                } else {
1252                    None
1253                };
1254                (Some(reason), text)
1255            } else {
1256                (None, None)
1257            };
1258            Log::new(Some(&status_tx)).info(format!(
1259                "MQTT client '{}' sent DISCONNECT{}{}",
1260                session.client_id.as_deref().unwrap_or("<unidentified>"),
1261                reason
1262                    .map(|r| format!(" reason=0x{r:02x}"))
1263                    .unwrap_or_default(),
1264                detail.map(|d| format!(" ({d})")).unwrap_or_default()
1265            ));
1266            false
1267        }
1268
1269        PKT_AUTH if session.v5 => {
1270            // Enhanced authentication (§4.12) is not implemented: a CONNECT naming an
1271            // Authentication Method is the handler's to refuse with reason 0x8C. An AUTH that
1272            // arrives anyway is a Protocol Error.
1273            Log::new(Some(&status_tx)).warn(
1274                "MQTT AUTH packet: enhanced authentication is not supported \
1275                 decision=protocol_error, closing",
1276            );
1277            session.refuse(out_tx, 0x82);
1278            false
1279        }
1280
1281        other => {
1282            // PUBACK/PUBREC/PUBCOMP/SUBACK from a client are acknowledgements of
1283            // broker-initiated traffic; nothing further is owed on the wire.
1284            trace!("MQTT ignoring inbound packet type {}", other);
1285            true
1286        }
1287    }
1288}
1289
1290/// "3.1.1" / "5.0" / "3.1" for a CONNECT protocol level.
1291pub fn version_name(level: u8) -> &'static str {
1292    match level {
1293        3 => "3.1",
1294        4 => "3.1.1",
1295        5 => "5.0",
1296        _ => "unknown",
1297    }
1298}
1299
1300/// What a SUBSCRIBE is answered with, spelled out for this one: the SUBACK, then the retained
1301/// messages.
1302///
1303/// The real-model eval (`mqtt/retained-message`, 0/5) found llama3.1:8b told "the topic holds
1304/// the retained reading 19.5" sending the SUBACK and nothing else, so the subscriber waited out
1305/// its timeout for a message that was never published. A broker that stores nothing can only
1306/// deliver a retained message if the model publishes it, and the model follows a per-request
1307/// hint far better than the general action list.
1308pub fn subscribe_answer_with(packet_id: u16, topics: &[(String, u8)]) -> String {
1309    let filters: Vec<&str> = topics.iter().map(|(f, _)| f.as_str()).collect();
1310    let granted: Vec<String> = topics.iter().map(|(_, q)| q.to_string()).collect();
1311    format!(
1312        "mqtt_suback with packet_id {packet_id} and granted_qos [{}] (one entry per filter; \
1313         128 refuses one). Then, for each topic matching {:?} that your instruction gives a \
1314         retained message or current value, mqtt_publish with that topic, the value as \
1315         payload and retain true - the subscriber is waiting for it. Publish nothing if the \
1316         instruction gives none.",
1317        granted.join(", "),
1318        filters
1319    )
1320}
1321
1322/// Outcome of handing an event to the handler chain (script -> static -> LLM).
1323enum LlmOutcome {
1324    /// The handler produced the packet the protocol owes this client.
1325    Responded,
1326    /// The handler ran and produced no such packet; the caller applies its protocol default.
1327    Silent,
1328    /// The handler asked to close the connection.
1329    Closed,
1330    /// The handler could not run at all - the LLM backend failed, timed out, or answered with
1331    /// something unusable.
1332    ///
1333    /// This must stay distinct from [`LlmOutcome::Silent`], and the reason is the most
1334    /// dangerous bug this module had: `Silent`'s CONNECT default is `build_connack(0, ...)`,
1335    /// which is *Connection Accepted*. Collapsing a backend failure into `Silent` meant an
1336    /// LLM outage authenticated every MQTT client that asked, credentials and all, and made a
1337    /// model's explicit refusal indistinguishable from the backend being down. The `Silent`
1338    /// defaults are permissive on purpose - they encode "the handler had nothing to say about
1339    /// a decision the spec requires me to make" - and that is only safe when the handler
1340    /// actually ran.
1341    ///
1342    /// There is no retryable/non-retryable split here because MQTT 3.1.1 has no wire form for
1343    /// one: CONNACK's only "come back later" code is 3 (server unavailable), SUBACK's only
1344    /// failure code is 0x80, and PUBACK/UNSUBACK have no code at all. The overload
1345    /// distinction is therefore logged rather than encoded — on a 3.1.1 connection. v5 has
1346    /// both (CONNACK 0x88/0x89, DISCONNECT 0x80/0x89), which is what the category is for.
1347    Failed(crate::utils::wire_failure::WireFailure),
1348}
1349
1350/// Hand an event to the handler chain and report whether the mandatory reply was
1351/// produced.
1352///
1353/// `required_reply` is the MQTT packet type the spec obliges the broker to send for
1354/// this event (`None` when nothing is owed, e.g. a QoS 0 PUBLISH). Checking the actual
1355/// packet type — rather than "did anything get written" — means a handler that
1356/// forwards a PUBLISH but forgets its PUBACK still gets the PUBACK default, instead of
1357/// leaving the publisher retrying forever.
1358#[allow(clippy::too_many_arguments)]
1359async fn run_llm(
1360    llm_client: &OllamaClient,
1361    app_state: &Arc<AppState>,
1362    server_id: crate::state::ServerId,
1363    connection_id: ConnectionId,
1364    event: &Event,
1365    protocol: &MqttProtocol,
1366    status_tx: &mpsc::UnboundedSender<String>,
1367    required_reply: Option<u8>,
1368) -> LlmOutcome {
1369    protocol.clear_written_types();
1370
1371    // call_llm dispatches script and static handlers first, so a deterministic
1372    // handler costs no model call.
1373    match call_llm(
1374        llm_client,
1375        app_state,
1376        server_id,
1377        Some(connection_id),
1378        event,
1379        protocol,
1380    )
1381    .await
1382    {
1383        Ok(result) => {
1384            // One grep-able line per event saying how it was decided. Every branch below
1385            // ends in a packet (or a close) chosen by a different actor, and the wire form
1386            // is frequently identical — a CONNACK 0 sent because the model said so and one
1387            // sent because it said nothing are the same four bytes.
1388            let client = event
1389                .data
1390                .get("client_id")
1391                .and_then(|v| v.as_str())
1392                .unwrap_or("<unidentified>");
1393            let log = Log::new(Some(&status_tx));
1394
1395            if result
1396                .protocol_results
1397                .iter()
1398                .any(|r| matches!(r, ActionResult::CloseConnection))
1399            {
1400                log.info(format!(
1401                    "MQTT {} from '{}' ({}) decision=model_reject (close_connection)",
1402                    event.event_type.id, client, connection_id
1403                ));
1404                return LlmOutcome::Closed;
1405            }
1406
1407            let answered = match required_reply {
1408                // Nothing is owed on the wire (a QoS 0 PUBLISH), so "answered" is simply
1409                // whether the handler produced any action at all.
1410                None => !result.raw_actions.is_empty(),
1411                Some(packet_type) => protocol.wrote_packet_type(packet_type),
1412            };
1413
1414            if answered {
1415                log.info(format!(
1416                    "MQTT {} from '{}' ({}) decision=model_answer",
1417                    event.event_type.id, client, connection_id
1418                ));
1419                LlmOutcome::Responded
1420            } else {
1421                // `actions=` matters: 0 is a handler with nothing to say, non-zero is a
1422                // handler that did something else and omitted the packet the spec requires.
1423                log.warn(format!(
1424                    "MQTT {} from '{}' ({}) decision=model_silent actions={} — the protocol \
1425                     default applies",
1426                    event.event_type.id,
1427                    client,
1428                    connection_id,
1429                    result.raw_actions.len()
1430                ));
1431                match required_reply {
1432                    None => LlmOutcome::Responded,
1433                    Some(_) => LlmOutcome::Silent,
1434                }
1435            }
1436        }
1437        Err(e) => {
1438            // `fail_closed_llm_*` is accurate for every caller of this function: each of the
1439            // four `LlmOutcome::Failed` arms refuses (CONNACK 3, SUBACK 0x80) or closes.
1440            // MQTT 3.1.1 has no retryable/permanent split to put on the wire, so the overload
1441            // distinction lives here and nowhere else.
1442            let decision = if crate::llm::is_overload_error(&e) {
1443                "fail_closed_llm_overloaded"
1444            } else {
1445                "fail_closed_llm_error"
1446            };
1447            let client = event
1448                .data
1449                .get("client_id")
1450                .and_then(|v| v.as_str())
1451                .unwrap_or("<unidentified>");
1452            Log::new(Some(&status_tx)).error(format!(
1453                "MQTT {} from '{}' ({}) decision={}: {}",
1454                event.event_type.id, client, connection_id, decision, e
1455            ));
1456            LlmOutcome::Failed(crate::utils::wire_failure::WireFailure::classify(&e))
1457        }
1458    }
1459}
1460
1461// ============================================================================
1462// Wire format: decoding
1463// ============================================================================
1464
1465/// Why a packet was refused, and the reason code a v5 client is told (§4.13).
1466#[derive(Debug, Clone)]
1467pub struct PacketError {
1468    /// 0x81 Malformed Packet, 0x82 Protocol Error or 0x95 Packet Too Large.
1469    pub reason: u8,
1470    pub message: String,
1471    /// The packet was a CONNECT that declared protocol level 5, so the refusal can be a v5
1472    /// CONNACK.
1473    pub v5_connect: bool,
1474}
1475
1476impl PacketError {
1477    fn malformed(message: impl Into<String>) -> Self {
1478        Self {
1479            reason: 0x81,
1480            message: message.into(),
1481            v5_connect: false,
1482        }
1483    }
1484
1485    fn protocol(message: impl Into<String>) -> Self {
1486        Self {
1487            reason: 0x82,
1488            message: message.into(),
1489            v5_connect: false,
1490        }
1491    }
1492
1493    pub fn reason_code(&self) -> u8 {
1494        self.reason
1495    }
1496
1497    /// The CONNACK reason code to refuse a malformed v5 CONNECT with, `None` for 3.1.1.
1498    pub fn v5_connack_code(&self) -> Option<u8> {
1499        self.v5_connect.then_some(self.reason)
1500    }
1501}
1502
1503impl std::fmt::Display for PacketError {
1504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1505        f.write_str(&self.message)
1506    }
1507}
1508
1509impl From<v5::PropertyError> for PacketError {
1510    fn from(e: v5::PropertyError) -> Self {
1511        match e {
1512            v5::PropertyError::Malformed(_) => PacketError::malformed(e.to_string()),
1513            v5::PropertyError::Duplicate(_) | v5::PropertyError::Protocol(_) => {
1514                PacketError::protocol(e.to_string())
1515            }
1516        }
1517    }
1518}
1519
1520/// Pull one complete control packet off the front of `buffer`.
1521///
1522/// Returns `Ok(None)` when more bytes are needed. Every index is bounds-checked;
1523/// no input can panic this function, and the declared remaining length is validated
1524/// against `max_packet_size` before any allocation.
1525fn take_packet(
1526    buffer: &mut Vec<u8>,
1527    max_packet_size: usize,
1528) -> std::result::Result<Option<RawPacket>, PacketError> {
1529    let Some(&first) = buffer.first() else {
1530        return Ok(None);
1531    };
1532    let packet_type = first >> 4;
1533    if packet_type == 0 {
1534        return Err(PacketError::malformed(format!(
1535            "invalid packet type {}",
1536            packet_type
1537        )));
1538    }
1539
1540    let (remaining, len_bytes) = match decode_remaining_length(&buffer[1..]) {
1541        Some(v) => v,
1542        None => {
1543            // Either incomplete, or a varint longer than the legal 4 bytes.
1544            if buffer.len() > 5 {
1545                return Err(PacketError::malformed("malformed remaining length"));
1546            }
1547            return Ok(None);
1548        }
1549    };
1550
1551    if remaining > max_packet_size {
1552        return Err(PacketError {
1553            reason: 0x95,
1554            message: format!(
1555                "packet of {} bytes exceeds max_packet_size {}",
1556                remaining, max_packet_size
1557            ),
1558            v5_connect: false,
1559        });
1560    }
1561
1562    let total = 1 + len_bytes + remaining;
1563    if buffer.len() < total {
1564        return Ok(None);
1565    }
1566
1567    let body = buffer[1 + len_bytes..total].to_vec();
1568    buffer.drain(..total);
1569
1570    Ok(Some(RawPacket {
1571        packet_type,
1572        flags: first & 0x0F,
1573        body,
1574    }))
1575}
1576
1577/// Decode the variable-length "remaining length" field (3.1.1 section 2.2.3).
1578/// Returns `(value, bytes_consumed)`, or `None` if incomplete or over 4 bytes.
1579fn decode_remaining_length(buf: &[u8]) -> Option<(usize, usize)> {
1580    let mut value: usize = 0;
1581    let mut multiplier: usize = 1;
1582    for i in 0..4 {
1583        let byte = *buf.get(i)?;
1584        value += (byte & 0x7F) as usize * multiplier;
1585        if byte & 0x80 == 0 {
1586            return Some((value, i + 1));
1587        }
1588        multiplier *= 128;
1589    }
1590    None
1591}
1592
1593fn read_u16_at(buf: &[u8], pos: usize) -> Option<u16> {
1594    let hi = *buf.get(pos)?;
1595    let lo = *buf.get(pos + 1)?;
1596    Some(u16::from_be_bytes([hi, lo]))
1597}
1598
1599/// Read a length-prefixed UTF-8 string, advancing `pos`.
1600fn read_string(buf: &[u8], pos: &mut usize) -> Option<String> {
1601    let len = read_u16_at(buf, *pos)? as usize;
1602    let start = pos.checked_add(2)?;
1603    let end = start.checked_add(len)?;
1604    let bytes = buf.get(start..end)?;
1605    *pos = end;
1606    Some(String::from_utf8_lossy(bytes).into_owned())
1607}
1608
1609/// Read a length-prefixed binary field, advancing `pos`.
1610fn read_bytes(buf: &[u8], pos: &mut usize) -> Option<Vec<u8>> {
1611    let len = read_u16_at(buf, *pos)? as usize;
1612    let start = pos.checked_add(2)?;
1613    let end = start.checked_add(len)?;
1614    let bytes = buf.get(start..end)?.to_vec();
1615    *pos = end;
1616    Some(bytes)
1617}
1618
1619/// Render a payload for the model: never raw bytes, never base64.
1620/// Non-UTF-8 payloads are reported lossily and flagged.
1621fn decode_payload(bytes: &[u8]) -> (String, bool) {
1622    match std::str::from_utf8(bytes) {
1623        Ok(s) => (s.to_string(), true),
1624        Err(_) => (String::from_utf8_lossy(bytes).into_owned(), false),
1625    }
1626}
1627
1628/// A decoded CONNECT (3.1.1 §3.1, 5.0 §3.1).
1629pub struct ConnectPacket {
1630    pub protocol_name: String,
1631    pub protocol_level: u8,
1632    pub clean_session: bool,
1633    pub keep_alive: u16,
1634    pub client_id: String,
1635    pub username: Option<String>,
1636    pub has_password: bool,
1637    pub will_topic: Option<String>,
1638    pub will_message: Option<String>,
1639    /// v5 only; empty on 3.1.1.
1640    pub properties: v5::Properties,
1641    /// v5 only: the Will Properties that precede the will topic.
1642    pub will_properties: v5::Properties,
1643}
1644
1645pub fn parse_connect(body: &[u8]) -> std::result::Result<ConnectPacket, PacketError> {
1646    let short = || PacketError::malformed("CONNECT is truncated");
1647    let mut pos = 0usize;
1648    let protocol_name = read_string(body, &mut pos).ok_or_else(short)?;
1649    let protocol_level = *body.get(pos).ok_or_else(short)?;
1650    pos += 1;
1651    let v5 = protocol_level == 5;
1652    let tag = |mut e: PacketError| {
1653        e.v5_connect = v5;
1654        e
1655    };
1656    let flags = *body.get(pos).ok_or_else(short).map_err(tag)?;
1657    pos += 1;
1658    let keep_alive = read_u16_at(body, pos).ok_or_else(short).map_err(tag)?;
1659    pos += 2;
1660
1661    let properties = if v5 {
1662        let props = v5::read_properties(body, &mut pos)
1663            .map_err(PacketError::from)
1664            .map_err(tag)?;
1665        v5::forbid(&props, &[v5::SUBSCRIPTION_IDENTIFIER], "CONNECT")
1666            .map_err(PacketError::from)
1667            .map_err(tag)?;
1668        // §3.1.2.11.4: "It is a Protocol Error ... for the value to be set to zero." A limit of
1669        // 0 would also leave every packet the broker sends this client unsendable.
1670        if props.int(v5::MAXIMUM_PACKET_SIZE) == Some(0) {
1671            return Err(tag(PacketError::protocol("maximum packet size 0")));
1672        }
1673        props
1674    } else {
1675        v5::Properties::default()
1676    };
1677
1678    let client_id = read_string(body, &mut pos).ok_or_else(short).map_err(tag)?;
1679
1680    let mut will_properties = v5::Properties::default();
1681    let (will_topic, will_message) = if flags & 0x04 != 0 {
1682        if v5 {
1683            will_properties = v5::read_properties(body, &mut pos)
1684                .map_err(PacketError::from)
1685                .map_err(tag)?;
1686            v5::forbid(
1687                &will_properties,
1688                &[v5::SUBSCRIPTION_IDENTIFIER],
1689                "Will Properties",
1690            )
1691            .map_err(PacketError::from)
1692            .map_err(tag)?;
1693        }
1694        let topic = read_string(body, &mut pos).ok_or_else(short).map_err(tag)?;
1695        let message = read_bytes(body, &mut pos).ok_or_else(short).map_err(tag)?;
1696        (Some(topic), Some(decode_payload(&message).0))
1697    } else {
1698        (None, None)
1699    };
1700
1701    let username = if flags & 0x80 != 0 {
1702        Some(read_string(body, &mut pos).ok_or_else(short).map_err(tag)?)
1703    } else {
1704        None
1705    };
1706    let has_password = flags & 0x40 != 0;
1707
1708    Ok(ConnectPacket {
1709        protocol_name,
1710        protocol_level,
1711        clean_session: flags & 0x02 != 0,
1712        keep_alive,
1713        client_id,
1714        username,
1715        has_password,
1716        will_topic,
1717        will_message,
1718        properties,
1719        will_properties,
1720    })
1721}
1722
1723/// A decoded PUBLISH. `topic` is empty when a v5 client used a Topic Alias alone.
1724pub struct PublishPacket {
1725    pub topic: String,
1726    pub packet_id: u16,
1727    pub payload: Vec<u8>,
1728    pub properties: v5::Properties,
1729}
1730
1731pub fn parse_publish(
1732    body: &[u8],
1733    qos: u8,
1734    v5: bool,
1735) -> std::result::Result<PublishPacket, PacketError> {
1736    let short = || PacketError::malformed("PUBLISH is truncated");
1737    let mut pos = 0usize;
1738    let topic = read_string(body, &mut pos).ok_or_else(short)?;
1739    let packet_id = if qos > 0 {
1740        let id = read_u16_at(body, pos).ok_or_else(short)?;
1741        pos += 2;
1742        id
1743    } else {
1744        0
1745    };
1746    let properties = if v5 {
1747        let props = v5::read_properties(body, &mut pos)?;
1748        // [MQTT-3.3.4-6]: a PUBLISH from a client MUST NOT carry a Subscription Identifier.
1749        v5::forbid(&props, &[v5::SUBSCRIPTION_IDENTIFIER], "a client's PUBLISH")?;
1750        props
1751    } else {
1752        v5::Properties::default()
1753    };
1754    let payload = body.get(pos..).ok_or_else(short)?.to_vec();
1755    Ok(PublishPacket {
1756        topic,
1757        packet_id,
1758        payload,
1759        properties,
1760    })
1761}
1762
1763/// v5 Subscription Options (§3.8.3.1). On 3.1.1 only the QoS bits exist and these are false/0.
1764#[derive(Clone, Copy, Debug, Default)]
1765pub struct SubscriptionOptions {
1766    pub no_local: bool,
1767    pub retain_as_published: bool,
1768    pub retain_handling: u8,
1769}
1770
1771pub struct SubscribePacket {
1772    pub packet_id: u16,
1773    pub topics: Vec<(String, u8)>,
1774    /// One per entry of `topics`.
1775    pub options: Vec<SubscriptionOptions>,
1776    pub properties: v5::Properties,
1777}
1778
1779pub fn parse_subscribe(body: &[u8], v5: bool) -> std::result::Result<SubscribePacket, PacketError> {
1780    let short = || PacketError::malformed("SUBSCRIBE is truncated");
1781    let mut pos = 0usize;
1782    let packet_id = read_u16_at(body, pos).ok_or_else(short)?;
1783    pos += 2;
1784    let properties = if v5 {
1785        // The one packet a client may put a Subscription Identifier in; `read_properties`
1786        // refuses a second one and the value 0 (§3.8.2.1.2) as Protocol Errors.
1787        v5::read_properties(body, &mut pos)?
1788    } else {
1789        v5::Properties::default()
1790    };
1791
1792    let mut topics = Vec::new();
1793    let mut options = Vec::new();
1794    while pos < body.len() {
1795        let filter = read_string(body, &mut pos).ok_or_else(short)?;
1796        let byte = *body.get(pos).ok_or_else(short)?;
1797        pos += 1;
1798        let qos = byte & 0x03;
1799        let mut opts = SubscriptionOptions::default();
1800        if v5 {
1801            // §3.8.3.1: QoS 3, Retain Handling 3 and the two reserved bits are all
1802            // Malformed Packet.
1803            if qos == 3 || (byte >> 4) & 0x03 == 3 || byte & 0xC0 != 0 {
1804                return Err(PacketError::malformed(format!(
1805                    "invalid subscription options byte 0x{byte:02x} for '{filter}'"
1806                )));
1807            }
1808            opts = SubscriptionOptions {
1809                no_local: byte & 0x04 != 0,
1810                retain_as_published: byte & 0x08 != 0,
1811                retain_handling: (byte >> 4) & 0x03,
1812            };
1813        }
1814        topics.push((filter, qos));
1815        options.push(opts);
1816    }
1817    Ok(SubscribePacket {
1818        packet_id,
1819        topics,
1820        options,
1821        properties,
1822    })
1823}
1824
1825pub struct UnsubscribePacket {
1826    pub packet_id: u16,
1827    pub topics: Vec<String>,
1828    pub properties: v5::Properties,
1829}
1830
1831pub fn parse_unsubscribe(
1832    body: &[u8],
1833    v5: bool,
1834) -> std::result::Result<UnsubscribePacket, PacketError> {
1835    let short = || PacketError::malformed("UNSUBSCRIBE is truncated");
1836    let mut pos = 0usize;
1837    let packet_id = read_u16_at(body, pos).ok_or_else(short)?;
1838    pos += 2;
1839    let properties = if v5 {
1840        let props = v5::read_properties(body, &mut pos)?;
1841        v5::forbid(&props, &[v5::SUBSCRIPTION_IDENTIFIER], "UNSUBSCRIBE")?;
1842        props
1843    } else {
1844        v5::Properties::default()
1845    };
1846
1847    let mut topics = Vec::new();
1848    while pos < body.len() {
1849        topics.push(read_string(body, &mut pos).ok_or_else(short)?);
1850    }
1851    Ok(UnsubscribePacket {
1852        packet_id,
1853        topics,
1854        properties,
1855    })
1856}
1857
1858// ============================================================================
1859// Wire format: encoding
1860// ============================================================================
1861
1862/// Encode the variable-length "remaining length" field.
1863pub fn encode_remaining_length(mut len: usize) -> Vec<u8> {
1864    let mut out = Vec::with_capacity(4);
1865    loop {
1866        let mut byte = (len % 128) as u8;
1867        len /= 128;
1868        if len > 0 {
1869            byte |= 0x80;
1870        }
1871        out.push(byte);
1872        if len == 0 || out.len() == 4 {
1873            break;
1874        }
1875    }
1876    out
1877}
1878
1879/// CONNACK (3.2). `return_code` 0 accepts; 1-5 are the specified refusals.
1880pub fn build_connack(return_code: u8, session_present: bool) -> Vec<u8> {
1881    vec![
1882        PKT_CONNACK << 4,
1883        0x02,
1884        if session_present { 0x01 } else { 0x00 },
1885        return_code,
1886    ]
1887}
1888
1889/// PUBACK / PUBREC / PUBCOMP / UNSUBACK: two-byte body echoing the packet identifier.
1890pub fn build_ack(packet_type: u8, packet_id: u16) -> Vec<u8> {
1891    let id = packet_id.to_be_bytes();
1892    let header = if packet_type == PKT_PUBREL {
1893        (packet_type << 4) | 0x02
1894    } else {
1895        packet_type << 4
1896    };
1897    vec![header, 0x02, id[0], id[1]]
1898}
1899
1900/// SUBACK (3.9): echoes the packet identifier and one return code per filter.
1901/// 0x80 means "subscription refused".
1902pub fn build_suback(packet_id: u16, granted_qos: &[u8]) -> Vec<u8> {
1903    let mut body = packet_id.to_be_bytes().to_vec();
1904    body.extend_from_slice(granted_qos);
1905    let mut out = vec![PKT_SUBACK << 4];
1906    out.extend_from_slice(&encode_remaining_length(body.len()));
1907    out.extend_from_slice(&body);
1908    out
1909}
1910
1911/// PUBLISH (3.3). `packet_id` is only present, and only required, for QoS > 0.
1912pub fn build_publish(topic: &str, payload: &str, qos: u8, retain: bool, packet_id: u16) -> Vec<u8> {
1913    let topic_bytes = topic.as_bytes();
1914    let payload_bytes = payload.as_bytes();
1915
1916    let mut body = Vec::with_capacity(2 + topic_bytes.len() + 2 + payload_bytes.len());
1917    body.extend_from_slice(&(topic_bytes.len() as u16).to_be_bytes());
1918    body.extend_from_slice(topic_bytes);
1919    if qos > 0 {
1920        body.extend_from_slice(&packet_id.to_be_bytes());
1921    }
1922    body.extend_from_slice(payload_bytes);
1923
1924    let mut flags = (qos & 0x03) << 1;
1925    if retain {
1926        flags |= 0x01;
1927    }
1928
1929    let mut out = vec![(PKT_PUBLISH << 4) | flags];
1930    out.extend_from_slice(&encode_remaining_length(body.len()));
1931    out.extend_from_slice(&body);
1932    out
1933}
1934
1935/// Fixed header + remaining length + body.
1936fn frame(first_byte: u8, body: &[u8]) -> Vec<u8> {
1937    let mut out = vec![first_byte];
1938    out.extend_from_slice(&encode_remaining_length(body.len()));
1939    out.extend_from_slice(body);
1940    out
1941}
1942
1943/// v5 CONNACK (5.0 §3.2): acknowledge flags, reason code, properties.
1944pub fn build_connack_v5(reason: u8, session_present: bool, props: &v5::PropertyWriter) -> Vec<u8> {
1945    let mut body = vec![u8::from(session_present), reason];
1946    body.extend_from_slice(&props.finish());
1947    frame(PKT_CONNACK << 4, &body)
1948}
1949
1950/// v5 PUBACK / PUBREC / PUBCOMP (5.0 §3.4–3.7). Reason Code 0x00 with no properties is the
1951/// two-byte 3.1.1 form, which §3.4.2.1 allows and every v5 client accepts.
1952pub fn build_ack_v5(
1953    packet_type: u8,
1954    packet_id: u16,
1955    reason: u8,
1956    props: &v5::PropertyWriter,
1957) -> Vec<u8> {
1958    if reason == 0 && props.is_empty() {
1959        return build_ack(packet_type, packet_id);
1960    }
1961    let mut body = packet_id.to_be_bytes().to_vec();
1962    body.push(reason);
1963    body.extend_from_slice(&props.finish());
1964    let first = if packet_type == PKT_PUBREL {
1965        (packet_type << 4) | 0x02
1966    } else {
1967        packet_type << 4
1968    };
1969    frame(first, &body)
1970}
1971
1972/// v5 SUBACK (5.0 §3.9): packet identifier, properties, one reason code per filter.
1973pub fn build_suback_v5(packet_id: u16, codes: &[u8], props: &v5::PropertyWriter) -> Vec<u8> {
1974    let mut body = packet_id.to_be_bytes().to_vec();
1975    body.extend_from_slice(&props.finish());
1976    body.extend_from_slice(codes);
1977    frame(PKT_SUBACK << 4, &body)
1978}
1979
1980/// v5 UNSUBACK (5.0 §3.11): packet identifier, properties, one reason code per filter. Unlike
1981/// 3.1.1's bare identifier, the payload is required.
1982pub fn build_unsuback_v5(packet_id: u16, codes: &[u8], props: &v5::PropertyWriter) -> Vec<u8> {
1983    let mut body = packet_id.to_be_bytes().to_vec();
1984    body.extend_from_slice(&props.finish());
1985    body.extend_from_slice(codes);
1986    frame(PKT_UNSUBACK << 4, &body)
1987}
1988
1989/// v5 DISCONNECT from the server (5.0 §3.14): reason code and properties.
1990pub fn build_disconnect_v5(reason: u8, props: &v5::PropertyWriter) -> Vec<u8> {
1991    let mut body = vec![reason];
1992    body.extend_from_slice(&props.finish());
1993    frame(PKT_DISCONNECT << 4, &body)
1994}
1995
1996/// v5 PUBLISH (5.0 §3.3): the 3.1.1 layout with a property block after the packet identifier.
1997pub fn build_publish_v5(
1998    topic: &str,
1999    payload: &[u8],
2000    qos: u8,
2001    retain: bool,
2002    packet_id: u16,
2003    props: &v5::PropertyWriter,
2004) -> Vec<u8> {
2005    let mut body = Vec::with_capacity(2 + topic.len() + 2 + 8 + payload.len());
2006    body.extend_from_slice(&(topic.len() as u16).to_be_bytes());
2007    body.extend_from_slice(topic.as_bytes());
2008    if qos > 0 {
2009        body.extend_from_slice(&packet_id.to_be_bytes());
2010    }
2011    body.extend_from_slice(&props.finish());
2012    body.extend_from_slice(payload);
2013    let mut flags = (qos & 0x03) << 1;
2014    if retain {
2015        flags |= 0x01;
2016    }
2017    frame((PKT_PUBLISH << 4) | flags, &body)
2018}
2019
2020/// A broker-originated PUBLISH before it is framed: framing depends on the recipient's version,
2021/// and one `mqtt_publish` to `"*"` can reach 3.1.1 and v5 clients at once.
2022#[derive(Clone, Debug)]
2023pub struct OutPublish {
2024    pub topic: String,
2025    pub payload: String,
2026    pub qos: u8,
2027    pub retain: bool,
2028    pub packet_id: u16,
2029    /// v5 properties. A 3.1.1 recipient has no field for them, so they are not sent there.
2030    pub properties: v5::PropertyWriter,
2031}
2032
2033impl OutPublish {
2034    pub fn encode(&self, v5: bool) -> Vec<u8> {
2035        if v5 {
2036            build_publish_v5(
2037                &self.topic,
2038                self.payload.as_bytes(),
2039                self.qos,
2040                self.retain,
2041                self.packet_id,
2042                &self.properties,
2043            )
2044        } else {
2045            build_publish(
2046                &self.topic,
2047                &self.payload,
2048                self.qos,
2049                self.retain,
2050                self.packet_id,
2051            )
2052        }
2053    }
2054}