Skip to main content

netget/server/coap/
mod.rs

1//! CoAP (RFC 7252) server over UDP.
2//!
3//! Owns the message layer — types, message ids, tokens, and the mechanical replies the
4//! specification leaves no room to decide — while the model owns the resource layer. See
5//! `src/server/coap/CLAUDE.md`.
6
7pub mod actions;
8pub mod blockwise;
9pub mod codec;
10pub mod observe;
11
12use anyhow::Result;
13use std::net::SocketAddr;
14use std::sync::atomic::{AtomicU16, Ordering};
15use std::sync::{Arc, Mutex};
16use std::time::Duration;
17use tokio::net::UdpSocket;
18use tokio::sync::mpsc;
19use tracing::{debug, error, info, warn};
20
21use crate::llm::action_helper::call_llm;
22use crate::llm::ollama_client::OllamaClient;
23use crate::llm::ActionResult;
24use crate::logging::emit::Log;
25use crate::protocol::Event;
26use crate::server::connection::ConnectionId;
27use crate::state::app_state::AppState;
28use crate::utils::clock::Instant;
29use crate::{console_debug, console_trace};
30
31use actions::{CoapProtocol, COAP_REQUEST_EVENT, RESULT_IGNORE, RESULT_RESET, RESULT_RESPONSE};
32use blockwise::{Representation, ResourceKey, Transfers, UploadKey, UploadStep, Uploads};
33use codec::{BlockValue, CoapMessage, MessageType};
34use observe::{Observer, Observers, Registered};
35
36/// RFC 7252 §4.8 ACK_TIMEOUT, the default wait before a Confirmable notification is resent.
37/// The `notification_ack_timeout_ms` startup parameter overrides it.
38pub const DEFAULT_NOTIFICATION_ACK_TIMEOUT_MS: u64 = 2000;
39
40/// RFC 7252 §4.8 MAX_RETRANSMIT: how often an unacknowledged Confirmable notification is
41/// resent before the observer is presumed gone and removed (RFC 7641 §4.5).
42pub const NOTIFICATION_MAX_RETRANSMIT: u32 = 4;
43
44/// CoAP server.
45pub struct CoapServer;
46
47/// The running server, as the actions reach it.
48///
49/// Registered with [`AppState::register_server_handle`] so `coap_notify` — an action the model
50/// may issue at any time, not only while answering a request — can find the socket and the
51/// observers. Every lock here is a `std::sync::Mutex` and none is held across an `.await`.
52pub struct CoapServerHandle {
53    socket: Arc<UdpSocket>,
54    server_id: crate::state::ServerId,
55    status_tx: mpsc::UnboundedSender<String>,
56    /// Message ids for messages the server originates: NON replies and notifications.
57    next_message_id: AtomicU16,
58    observers: Mutex<Observers>,
59    transfers: Mutex<Transfers>,
60    uploads: Mutex<Uploads>,
61    ack_timeout: Duration,
62}
63
64/// What [`CoapServerHandle::notify`] did.
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct NotifyReport {
67    /// Observers a notification was sent to.
68    pub sent: usize,
69    /// Observers whose observation the notification ended (a non-2.xx code, RFC 7641 §4.2).
70    pub ended: usize,
71}
72
73impl CoapServerHandle {
74    fn fresh_message_id(&self) -> u16 {
75        self.next_message_id.fetch_add(1, Ordering::Relaxed)
76    }
77
78    /// Every registration, for the dashboard and for tests. Expired ones are removed first.
79    pub fn observers(&self) -> Vec<Observer> {
80        self.expire_observers();
81        lock(&self.observers).all()
82    }
83
84    /// Confirmable notifications still waiting for their ACK.
85    pub fn pending_notifications(&self) -> usize {
86        lock(&self.observers).pending_len()
87    }
88
89    /// Remove every registration unconfirmed for `observe::OBSERVER_MAX_AGE`, logging each.
90    fn expire_observers(&self) {
91        let expired = lock(&self.observers).expire(Instant::now());
92        for observer in expired {
93            info!(
94                "CoAP observer {} of {} unconfirmed for {} s; observation removed decision={}",
95                observer.peer,
96                observer.path,
97                observe::OBSERVER_MAX_AGE.as_secs(),
98                Decision::ObserverExpired.as_str()
99            );
100        }
101    }
102
103    /// Representations cached for Block2 continuations.
104    pub fn cached_transfers(&self) -> usize {
105        lock(&self.transfers).len()
106    }
107
108    /// Block1 uploads in progress.
109    pub fn uploads_in_progress(&self) -> usize {
110        lock(&self.uploads).len()
111    }
112
113    /// Push `rep` to every observer of `path` (RFC 7641 §4.2).
114    ///
115    /// A 2.xx goes out with the next Observe sequence number and keeps the observation; any
116    /// other code goes out *without* Observe and ends it, which is how the RFC says "this
117    /// resource is gone". A representation over one block is sent as its first Block2 block
118    /// and cached, so the client fetches the rest with ordinary GETs (RFC 7959 §2.6).
119    ///
120    /// `confirmable` is what the caller asked for; an observer unconfirmed for half of
121    /// `observe::OBSERVER_MAX_AGE` gets a Confirmable notification regardless (RFC 7641 §4.5),
122    /// so a live client renews its registration by acknowledging it and a dead or spoofed one
123    /// is removed when the retransmissions run out.
124    pub async fn notify(
125        &self,
126        app_state: &AppState,
127        path: &str,
128        rep: Representation,
129        confirmable: bool,
130    ) -> NotifyReport {
131        let rep = Arc::new(rep);
132        self.expire_observers();
133        let observers = lock(&self.observers).of_path(path);
134        let mut report = NotifyReport::default();
135        for observer in observers {
136            let keeps = codec::code_class(rep.code) == 2;
137            let confirmable = confirmable
138                || (keeps
139                    && lock(&self.observers).needs_confirmation(
140                        observer.peer,
141                        &observer.token,
142                        Instant::now(),
143                    ));
144            let mid = self.fresh_message_id();
145            let mut message = CoapMessage {
146                mtype: if confirmable {
147                    MessageType::Confirmable
148                } else {
149                    MessageType::NonConfirmable
150                },
151                code: rep.code,
152                message_id: mid,
153                token: observer.token.clone(),
154                options: Vec::new(),
155                payload: Vec::new(),
156            };
157            if keeps {
158                let seq = lock(&self.observers).next_sequence();
159                message
160                    .options
161                    .push((codec::OPT_OBSERVE, codec::uint_option(seq)));
162            }
163            let key = ResourceKey {
164                peer: observer.peer,
165                path: observer.path.clone(),
166                query: observer.query.clone(),
167            };
168            let szx = observer.block_szx.unwrap_or(codec::MAX_SZX);
169            self.fill_representation(&mut message, &rep, key, None, szx);
170
171            {
172                let mut obs = lock(&self.observers);
173                // Pending only when a retransmission task will wait for it.
174                obs.record(observer.peer, mid, &observer.token, confirmable && keeps);
175                if !keeps {
176                    obs.deregister(observer.peer, &observer.token);
177                    report.ended += 1;
178                }
179            }
180            info!(
181                "CoAP notification {} {} to {} (mid {mid}) decision={}",
182                codec::code_to_string(rep.code),
183                path,
184                observer.peer,
185                if keeps {
186                    Decision::Notified.as_str()
187                } else {
188                    Decision::ObservationEnded.as_str()
189                }
190            );
191            let sent = self
192                .send(&message, observer.peer, observer.connection_id, app_state)
193                .await;
194            if confirmable && keeps {
195                if sent {
196                    self.retransmit_later(app_state, observer, message).await;
197                } else {
198                    lock(&self.observers).settle(observer.peer, mid);
199                }
200            }
201            report.sent += usize::from(sent);
202        }
203        report
204    }
205
206    /// Resend a Confirmable notification until it is acknowledged; give up after
207    /// [`NOTIFICATION_MAX_RETRANSMIT`] and remove the observer (RFC 7641 §4.5).
208    async fn retransmit_later(
209        &self,
210        app_state: &AppState,
211        observer: Observer,
212        message: CoapMessage,
213    ) {
214        let Some(handle) = app_state
215            .server_handle::<CoapServerHandle>(self.server_id)
216            .await
217        else {
218            lock(&self.observers).settle(observer.peer, message.message_id);
219            return;
220        };
221        let state = app_state.clone();
222        let mut interval = self.ack_timeout.mul_f64(1.0 + rand::random::<f64>() * 0.5);
223        app_state
224            .spawn_server_task(self.server_id, async move {
225                for attempt in 0..=NOTIFICATION_MAX_RETRANSMIT {
226                    tokio::time::sleep(interval).await;
227                    // Not pending means acknowledged, reset, superseded by a newer
228                    // Confirmable notification, or its observer is gone. Pending entries are
229                    // never evicted, so this is never true merely because time passed.
230                    if !lock(&handle.observers).awaiting_ack(observer.peer, message.message_id) {
231                        return;
232                    }
233                    if attempt == NOTIFICATION_MAX_RETRANSMIT {
234                        break;
235                    }
236                    debug!(
237                        "CoAP notification mid {} to {} unacknowledged; retransmission {}",
238                        message.message_id,
239                        observer.peer,
240                        attempt + 1
241                    );
242                    handle
243                        .send(&message, observer.peer, observer.connection_id, &state)
244                        .await;
245                    interval *= 2;
246                }
247                {
248                    let mut obs = lock(&handle.observers);
249                    obs.settle(observer.peer, message.message_id);
250                    obs.deregister(observer.peer, &observer.token);
251                }
252                warn!(
253                    "CoAP observer {} of {} never acknowledged notification mid {} after {} \
254                     retransmissions; observation removed decision={}",
255                    observer.peer,
256                    observer.path,
257                    message.message_id,
258                    NOTIFICATION_MAX_RETRANSMIT,
259                    Decision::ObserverTimeout.as_str()
260                );
261            })
262            .await;
263    }
264
265    /// Put `rep` into `message`: the whole payload when it fits one block and nobody asked for
266    /// blocks, otherwise block `wanted` (or 0) with Block2, Size2 on the first block and the
267    /// ETag. A representation with blocks after this one is cached under `key`.
268    ///
269    /// Returns false when `wanted` starts past the end, and leaves `message` untouched so the
270    /// caller can answer 4.02.
271    fn fill_representation(
272        &self,
273        message: &mut CoapMessage,
274        rep: &Arc<Representation>,
275        key: ResourceKey,
276        wanted: Option<BlockValue>,
277        szx: u8,
278    ) -> bool {
279        let size = codec::szx_size(szx);
280        if wanted.is_none() && rep.payload.len() <= size {
281            if let Some(cf) = rep.content_format {
282                message
283                    .options
284                    .push((codec::OPT_CONTENT_FORMAT, codec::uint_option(u32::from(cf))));
285            }
286            message.payload = rep.payload.clone();
287            return true;
288        }
289        let block = BlockValue {
290            num: wanted.map_or(0, |b| b.num),
291            more: false,
292            szx,
293        };
294        let Some((slice, more)) = rep.block(block) else {
295            return false;
296        };
297        message.options.push((codec::OPT_ETAG, rep.etag.clone()));
298        if let Some(cf) = rep.content_format {
299            message
300                .options
301                .push((codec::OPT_CONTENT_FORMAT, codec::uint_option(u32::from(cf))));
302        }
303        message
304            .options
305            .push((codec::OPT_BLOCK2, BlockValue { more, ..block }.encode()));
306        if block.num == 0 {
307            message.options.push((
308                codec::OPT_SIZE2,
309                codec::uint_option(rep.payload.len() as u32),
310            ));
311        }
312        message.payload = slice.to_vec();
313        let mut transfers = lock(&self.transfers);
314        if more {
315            // A block served from the cache was charged by `Transfers::get`, and inserting the
316            // same representation again leaves its entry as it is.
317            if let Some(evicted) = transfers.insert(key, rep.clone(), slice.len(), Instant::now()) {
318                debug!(
319                    "CoAP Block2 cache full; evicted {}{} for {}",
320                    evicted.path,
321                    evicted.query.map(|q| format!("?{q}")).unwrap_or_default(),
322                    evicted.peer
323                );
324            }
325        } else {
326            transfers.remove(&key);
327        }
328        true
329    }
330
331    /// Encode and send one message, updating counters and the dual logs. False when nothing
332    /// went out.
333    async fn send(
334        &self,
335        message: &CoapMessage,
336        peer_addr: SocketAddr,
337        connection_id: ConnectionId,
338        app_state: &AppState,
339    ) -> bool {
340        // A message the codec refuses is never patched up and sent anyway: a CoAP reply whose
341        // token was shortened to fit matches nothing at the client, which is a silent drop
342        // dressed as an answer.
343        let bytes = match message.encode() {
344            Ok(bytes) => bytes,
345            Err(e) => {
346                error!(
347                    "CoAP decision=fail_closed_encode: refusing to send {} to {}: {}",
348                    codec::code_to_string(message.code),
349                    peer_addr,
350                    e
351                );
352                let _ = self.status_tx.send(format!(
353                    "✗ CoAP refused to encode a reply to {peer_addr}: {e}"
354                ));
355                return false;
356            }
357        };
358        if let Err(e) = self.socket.send_to(&bytes, peer_addr).await {
359            error!("CoAP send to {} failed: {}", peer_addr, e);
360            let _ = self
361                .status_tx
362                .send(format!("✗ CoAP send to {peer_addr} failed: {e}"));
363            return false;
364        }
365
366        app_state
367            .update_connection_stats(
368                self.server_id,
369                connection_id,
370                None,
371                Some(bytes.len() as u64),
372                None,
373                Some(1),
374            )
375            .await;
376
377        let log = Log::new(Some(&self.status_tx));
378        log.debug(format!(
379            "CoAP sent {} {} ({} bytes) to {peer_addr}",
380            message.mtype.as_str(),
381            codec::code_to_string(message.code),
382            bytes.len()
383        ));
384        log.trace(format!("CoAP sent (hex): {}", hex::encode(&bytes)));
385        let _ = self
386            .status_tx
387            .send(format!("→ CoAP response to {peer_addr}"));
388        true
389    }
390}
391
392/// A poisoned lock is still the table: a panic elsewhere must not make every later request
393/// panic too.
394fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
395    m.lock().unwrap_or_else(|p| p.into_inner())
396}
397
398impl CoapServer {
399    /// Bind the UDP socket and start the receive loop.
400    ///
401    /// Returns `Err` when the socket cannot be bound, so `server_startup` records
402    /// `ServerStatus::Error` instead of a server that never received anything.
403    pub async fn spawn_with_llm_actions(
404        listen_addr: SocketAddr,
405        llm_client: OllamaClient,
406        app_state: Arc<AppState>,
407        status_tx: mpsc::UnboundedSender<String>,
408        server_id: crate::state::ServerId,
409        notification_ack_timeout: Duration,
410    ) -> Result<SocketAddr> {
411        let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
412        let local_addr = socket.local_addr()?;
413        Log::new(Some(&status_tx)).info(format!("CoAP server listening on {local_addr}"));
414
415        let protocol = Arc::new(CoapProtocol::new());
416        let handle = Arc::new(CoapServerHandle {
417            socket: socket.clone(),
418            server_id,
419            status_tx: status_tx.clone(),
420            next_message_id: AtomicU16::new(rand::random::<u16>()),
421            observers: Mutex::new(Observers::default()),
422            transfers: Mutex::new(Transfers::default()),
423            uploads: Mutex::new(Uploads::default()),
424            ack_timeout: notification_ack_timeout,
425        });
426        app_state
427            .register_server_handle(server_id, handle.clone())
428            .await;
429
430        let task_registrar = app_state.clone();
431        let recv_handle = tokio::spawn(async move {
432            Log::new(Some(&status_tx)).info(format!("CoAP receive loop started on {local_addr}"));
433
434            // Deliberately larger than `codec::MAX_MESSAGE_LEN` (1152), and that is the
435            // whole point of the number rather than headroom for its own sake: `recv_from`
436            // silently discards whatever does not fit, so a buffer sized *at* the bound
437            // would deliver a 5000-byte datagram as a legal-looking 1152-byte one and the
438            // guard below could never fire. With 2048 anything over the bound is seen to
439            // be over it.
440            let mut buffer = vec![0u8; 2048];
441
442            loop {
443                match socket.recv_from(&mut buffer).await {
444                    Ok((n, peer_addr)) => {
445                        let data = buffer[..n].to_vec();
446                        let connection_id =
447                            ConnectionId::new(app_state.get_next_unified_id().await);
448
449                        use crate::state::server::{
450                            ConnectionState as ServerConnectionState, ConnectionStatus,
451                            ProtocolConnectionInfo,
452                        };
453                        let now = crate::utils::clock::Instant::now();
454                        app_state
455                            .add_connection_to_server(
456                                server_id,
457                                ServerConnectionState {
458                                    id: connection_id,
459                                    remote_addr: peer_addr,
460                                    local_addr,
461                                    bytes_sent: 0,
462                                    bytes_received: n as u64,
463                                    packets_sent: 0,
464                                    packets_received: 1,
465                                    last_activity: now,
466                                    status: ConnectionStatus::Active,
467                                    status_changed_at: now,
468                                    protocol_info: ProtocolConnectionInfo::empty(),
469                                },
470                            )
471                            .await;
472                        let _ = status_tx.send("__UPDATE_UI__".to_string());
473
474                        console_debug!(status_tx, "CoAP received {} bytes from {}", n, peer_addr);
475                        console_trace!(status_tx, "CoAP received (hex): {}", hex::encode(&data));
476
477                        let llm = llm_client.clone();
478                        let st = app_state.clone();
479                        let proto = protocol.clone();
480                        let shared = handle.clone();
481
482                        // Tracked, not detached: stop_server must abort this task too.
483                        let task_owner = app_state.clone();
484                        task_owner
485                            .spawn_server_task(server_id, async move {
486                                Self::handle_datagram(
487                                    data,
488                                    peer_addr,
489                                    connection_id,
490                                    llm,
491                                    st,
492                                    shared,
493                                    proto,
494                                )
495                                .await;
496                            })
497                            .await;
498                    }
499                    Err(e) => {
500                        error!("CoAP receive error: {}", e);
501                        break;
502                    }
503                }
504            }
505        });
506
507        task_registrar
508            .register_server_task(server_id, recv_handle)
509            .await;
510
511        Ok(local_addr)
512    }
513
514    async fn handle_datagram(
515        data: Vec<u8>,
516        peer_addr: SocketAddr,
517        connection_id: ConnectionId,
518        llm_client: OllamaClient,
519        app_state: Arc<AppState>,
520        shared: Arc<CoapServerHandle>,
521        protocol: Arc<CoapProtocol>,
522    ) {
523        let server_id = shared.server_id;
524        let status_tx = shared.status_tx.clone();
525        let send = |message: CoapMessage| {
526            let shared = shared.clone();
527            let app_state = app_state.clone();
528            async move {
529                shared
530                    .send(&message, peer_addr, connection_id, &app_state)
531                    .await;
532            }
533        };
534
535        // --- The inbound size bound, before anything is decoded or asked -------------
536        //
537        // RFC 7252 §4.6 puts MAX_MESSAGE_SIZE at 1152 bytes when the path MTU is unknown, and
538        // the legal way past it — Block1, whose largest block is 1024 bytes — fits under it.
539        // So a larger datagram is refused in CoAP's own vocabulary (4.13 Request Entity Too
540        // Large, §5.9.2.9) rather than decoded, and the model is never asked about it: an
541        // oversize request must not become a prompt.
542        if data.len() > codec::MAX_MESSAGE_LEN {
543            warn!(
544                "CoAP refusing a {}-byte datagram from {} (limit {}) decision={}",
545                data.len(),
546                peer_addr,
547                codec::MAX_MESSAGE_LEN,
548                Decision::RefusedTooLarge.as_str()
549            );
550            let _ = status_tx.send(format!(
551                "✗ CoAP refused a {}-byte datagram from {peer_addr}: over the {}-byte limit",
552                data.len(),
553                codec::MAX_MESSAGE_LEN
554            ));
555
556            // The refusal still has to be matchable, so it carries the request's own
557            // message id and token — read from the fixed-offset prefix, which needs no
558            // option walk. Without that echo a client discards it and the refusal is
559            // indistinguishable from the silence it exists to avoid.
560            if let Some(prefix) = codec::message_prefix(&data) {
561                let reply = if codec::method_name(prefix.code).is_some() {
562                    let fresh = shared.fresh_message_id();
563                    Some(codec::response_to_prefix(
564                        &prefix,
565                        fresh,
566                        codec::CODE_REQUEST_ENTITY_TOO_LARGE,
567                    ))
568                } else if prefix.mtype == MessageType::Confirmable {
569                    // Not a request, so 4.13 would be a category error; RFC 7252 §4.2
570                    // rejects a Confirmable message that cannot be processed with a Reset.
571                    Some(codec::reset_for(prefix.message_id))
572                } else {
573                    None
574                };
575                if let Some(reply) = reply {
576                    send(reply).await;
577                }
578            }
579            return;
580        }
581
582        let request = match CoapMessage::decode(&data) {
583            Ok(m) => m,
584            Err(e) => {
585                warn!("CoAP malformed message from {}: {}", peer_addr, e);
586                let _ = status_tx.send(format!("✗ CoAP malformed message from {peer_addr}: {e}"));
587
588                // RFC 7252 §4.2: a Confirmable message that cannot be processed is
589                // rejected with a Reset. The message id lives in bytes 2-3, which we
590                // still have whenever the datagram reached the header length at all.
591                if data.len() >= codec::HEADER_LEN
592                    && MessageType::from_bits(data[0] >> 4) == MessageType::Confirmable
593                {
594                    let message_id = u16::from_be_bytes([data[2], data[3]]);
595                    send(codec::reset_for(message_id)).await;
596                }
597                return;
598            }
599        };
600
601        // --- Message-layer traffic the specification answers on its own -------------
602
603        if request.is_empty_message() {
604            match request.mtype {
605                // RFC 7252 §4.3 CoAP Ping: an empty Confirmable message is answered with
606                // a Reset. This is not a decision, so it does not reach the model.
607                MessageType::Confirmable => {
608                    debug!(
609                        "CoAP ping from {}; replying RST decision={}",
610                        peer_addr,
611                        Decision::SpecReply.as_str()
612                    );
613                    send(codec::reset_for(request.message_id)).await;
614                }
615                // An ACK for a Confirmable notification: stop retransmitting it.
616                MessageType::Acknowledgement => {
617                    if lock(&shared.observers).acknowledge(
618                        peer_addr,
619                        request.message_id,
620                        Instant::now(),
621                    ) {
622                        debug!(
623                            "CoAP notification mid {} acknowledged by {}",
624                            request.message_id, peer_addr
625                        );
626                    }
627                }
628                // A RST answering a notification: the client has forgotten the observation
629                // (RFC 7641 §3.6), so the server forgets it too.
630                MessageType::Reset => {
631                    let removed = lock(&shared.observers).reset(peer_addr, request.message_id);
632                    match removed {
633                        Some(observer) => info!(
634                            "CoAP observer {} of {} answered notification mid {} with RST; \
635                             observation removed decision={}",
636                            peer_addr,
637                            observer.path,
638                            request.message_id,
639                            Decision::ObserverReset.as_str()
640                        ),
641                        None => debug!(
642                            "CoAP RST from {} for mid {} matches no notification",
643                            peer_addr, request.message_id
644                        ),
645                    }
646                }
647                MessageType::NonConfirmable => {
648                    debug!("CoAP empty NON message from {} ignored", peer_addr);
649                }
650            }
651            return;
652        }
653
654        if !request.is_request() {
655            // A response code arriving at a server, or a method this version of CoAP does
656            // not define. RFC 7252 §5.8 defines only GET/POST/PUT/DELETE.
657            if codec::code_class(request.code) == 0 {
658                warn!(
659                    "CoAP unrecognised method {} from {}; replying 4.05 decision={}",
660                    codec::code_to_string(request.code),
661                    peer_addr,
662                    Decision::SpecReply.as_str()
663                );
664                let fresh = shared.fresh_message_id();
665                send(codec::response_to(
666                    &request,
667                    fresh,
668                    codec::CODE_METHOD_NOT_ALLOWED,
669                ))
670                .await;
671            } else {
672                debug!(
673                    "CoAP {} message with response code {} from {} ignored; this is a server",
674                    request.mtype.as_str(),
675                    codec::code_to_string(request.code),
676                    peer_addr
677                );
678            }
679            return;
680        }
681
682        let method = codec::method_name(request.code).unwrap_or("GET");
683        let path = request.uri_path();
684        let resource = ResourceKey {
685            peer: peer_addr,
686            path: path.clone(),
687            query: request.uri_query(),
688        };
689
690        // A reply the specification determines, with no model involved.
691        let spec_reply = |code: u8, decision: &'static str, why: String| {
692            let fresh = shared.fresh_message_id();
693            let response = codec::response_to(&request, fresh, code);
694            info!(
695                "CoAP {method} {path} from {peer_addr}: {} {why} decision={decision}",
696                codec::code_to_string(code)
697            );
698            response
699        };
700
701        // --- Block-wise options (RFC 7959) -----------------------------------------
702
703        let block2 = match request.block2() {
704            None => None,
705            Some(Some(b)) => Some(b),
706            Some(None) => {
707                let reply = spec_reply(
708                    codec::CODE_BAD_OPTION,
709                    Decision::SpecReply.as_str(),
710                    "unreadable Block2 option".into(),
711                );
712                send(reply).await;
713                return;
714            }
715        };
716
717        // Block1: the request body arrives in pieces. Every piece but the last is answered
718        // 2.31 Continue here; the last one carries the whole body on to the model.
719        let mut payload = request.payload.clone();
720        let mut upload: Option<(u32, BlockValue)> = None;
721        match request.block1() {
722            None => {}
723            Some(None) => {
724                let reply = spec_reply(
725                    codec::CODE_BAD_OPTION,
726                    Decision::SpecReply.as_str(),
727                    "unreadable Block1 option".into(),
728                );
729                send(reply).await;
730                return;
731            }
732            Some(Some(block)) => {
733                let key = UploadKey {
734                    resource: resource.clone(),
735                    method: request.code,
736                };
737                let size1 = request.option_uint(codec::OPT_SIZE1);
738                let step = lock(&shared.uploads).accept(
739                    key,
740                    block,
741                    &request.payload,
742                    size1,
743                    Instant::now(),
744                );
745                match step {
746                    UploadStep::Continue(echo) => {
747                        let mut reply = spec_reply(
748                            codec::CODE_CONTINUE,
749                            Decision::UploadContinue.as_str(),
750                            format!("block {} received", echo.num),
751                        );
752                        reply.options.push((codec::OPT_BLOCK1, echo.encode()));
753                        send(reply).await;
754                        return;
755                    }
756                    UploadStep::Refuse {
757                        code,
758                        size1,
759                        decision,
760                        reason,
761                    } => {
762                        let mut reply = spec_reply(code, decision, reason);
763                        if let Some(max) = size1 {
764                            reply
765                                .options
766                                .push((codec::OPT_SIZE1, codec::uint_option(max as u32)));
767                        }
768                        send(reply).await;
769                        return;
770                    }
771                    UploadStep::Complete { body, blocks, last } => {
772                        payload = body;
773                        upload = Some((blocks, last));
774                    }
775                }
776            }
777        }
778
779        // A later Block2 block of a representation the model already gave: served from the
780        // cache. A miss falls through to the model, whose answer is sliced the same way. So
781        // does a block that would take the entry past its serve budget
782        // (`blockwise::TRANSFER_SERVE_FACTOR`): the cache answers a spoofable address without
783        // the model, so what one answer can be made to send is bounded.
784        if let Some(block) = block2.filter(|b| b.num > 0) {
785            let cached = lock(&shared.transfers).get(&resource, block, Instant::now());
786            if let Some(rep) = cached {
787                let fresh = shared.fresh_message_id();
788                let mut response = codec::response_to(&request, fresh, rep.code);
789                if shared.fill_representation(&mut response, &rep, resource, Some(block), block.szx)
790                {
791                    debug!(
792                        "CoAP {method} {path} block {} from {peer_addr} served from cache \
793                         decision={}",
794                        block.num,
795                        Decision::BlockCache.as_str()
796                    );
797                    send(response).await;
798                } else {
799                    let reply = spec_reply(
800                        codec::CODE_BAD_OPTION,
801                        Decision::SpecReply.as_str(),
802                        format!("block {} is past the end of the representation", block.num),
803                    );
804                    send(reply).await;
805                }
806                return;
807            }
808        }
809
810        // Observe (RFC 7641): 0 asks to register, 1 to deregister. Only a GET observes, and a
811        // Block2 continuation never re-registers (RFC 7959 §2.6).
812        let observe = match (request.code, request.observe()) {
813            (codec::CODE_GET, Some(0)) if block2.is_none_or(|b| b.num == 0) => {
814                Some(ObserveRequest::Register)
815            }
816            (codec::CODE_GET, Some(1)) => {
817                if lock(&shared.observers).deregister(peer_addr, &request.token) {
818                    info!(
819                        "CoAP {peer_addr} deregistered from {path} decision={}",
820                        Decision::ObservationEnded.as_str()
821                    );
822                }
823                Some(ObserveRequest::Deregister)
824            }
825            _ => None,
826        };
827
828        // --- Resource layer: the model decides -------------------------------------
829
830        let answer_with = actions::answer_with_for_request(method, &path);
831
832        let mut event_data = serde_json::json!({
833            "method": method,
834            "path": path,
835            "path_segments": request.path_segments(),
836            "message_type": request.mtype.as_str(),
837            "message_id": request.message_id,
838            "answer_with": answer_with,
839        });
840        if let Some(query) = request.uri_query() {
841            event_data["query"] = serde_json::json!(query);
842        }
843        if let Some(observe) = observe {
844            event_data["observe"] = serde_json::json!(observe.as_str());
845        }
846        if let Some((blocks, _)) = upload {
847            event_data["blocks"] = serde_json::json!(blocks);
848        }
849        if let Some(cf) = request.option_uint(codec::OPT_CONTENT_FORMAT) {
850            let id = cf as u16;
851            event_data["content_format"] = serde_json::json!(
852                codec::content_format_name(id).unwrap_or("application/octet-stream")
853            );
854        }
855        if let Some(accept) = request.option_uint(codec::OPT_ACCEPT) {
856            let id = accept as u16;
857            event_data["accept"] = serde_json::json!(
858                codec::content_format_name(id).unwrap_or("application/octet-stream")
859            );
860        }
861        if !payload.is_empty() {
862            event_data["payload_size"] = serde_json::json!(payload.len());
863            let printable = payload
864                .iter()
865                .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace());
866            if printable {
867                event_data["payload"] =
868                    serde_json::json!(String::from_utf8_lossy(&payload).to_string());
869                event_data["payload_encoding"] = serde_json::json!("utf8");
870            } else {
871                event_data["payload"] = serde_json::json!(hex::encode(&payload));
872                event_data["payload_encoding"] = serde_json::json!("hex");
873            }
874        }
875
876        debug!(
877            "CoAP {} {} {} from {} (mid={})",
878            request.mtype.as_str(),
879            method,
880            path,
881            peer_addr,
882            request.message_id
883        );
884
885        let event = Event::new(&COAP_REQUEST_EVENT, event_data);
886
887        let (decision, outcome) = match call_llm(
888            &llm_client,
889            &app_state,
890            server_id,
891            Some(connection_id),
892            &event,
893            protocol.as_ref(),
894        )
895        .await
896        {
897            Ok(execution_result) => {
898                for msg in execution_result.messages {
899                    let _ = status_tx.send(msg);
900                }
901                Self::outcome_from_results(&request, &execution_result.protocol_results)
902            }
903            Err(e) => {
904                // The error text stays in the log; the peer gets only the code.
905                error!("CoAP LLM error for {} {}: {}", method, path, e);
906                let _ = status_tx.send(format!("✗ CoAP LLM error: {e}"));
907                // Fail closed with a code that means exactly what happened, rather than
908                // inventing a representation or leaving the client to retransmit.
909                (
910                    Decision::FailClosedLlmError,
911                    Outcome::Response {
912                        code: codec::CODE_SERVICE_UNAVAILABLE,
913                        payload: Vec::new(),
914                        content_format: None,
915                        observable: false,
916                    },
917                )
918            }
919        };
920
921        // Log the decision before writing it, and make the fail-closed paths loud. 5.03 is
922        // both what a backend outage produces and something the model may pick itself, so
923        // the peer cannot tell them apart and an operator has to be able to.
924        let summary = format!(
925            "CoAP {method} {path} from {peer_addr} decision={}",
926            decision.as_str()
927        );
928        if decision.is_fail_closed() {
929            Log::new(Some(&status_tx)).error(format!(
930                "{summary} (answered 5.03 because no usable answer was produced)"
931            ));
932        } else {
933            debug!("{summary}");
934        }
935
936        let fresh = shared.fresh_message_id();
937        let message = match outcome {
938            Outcome::Ignore => {
939                debug!(
940                    "CoAP deliberately sending no reply to {} {} from {}",
941                    method, path, peer_addr
942                );
943                return;
944            }
945            Outcome::Reset => codec::reset_for(request.message_id),
946            Outcome::Response {
947                code,
948                payload,
949                content_format,
950                observable,
951            } => {
952                let mut response = codec::response_to(&request, fresh, code);
953                let rep = Arc::new(Representation::new(code, payload, content_format));
954
955                // The registration, if one was asked for and the model served the resource.
956                if observe == Some(ObserveRequest::Register) && codec::code_class(code) == 2 {
957                    if observable {
958                        let observer = Observer {
959                            peer: peer_addr,
960                            token: request.token.clone(),
961                            path: path.clone(),
962                            query: request.uri_query(),
963                            block_szx: block2.map(|b| b.szx),
964                            connection_id,
965                        };
966                        shared.expire_observers();
967                        let mut observers = lock(&shared.observers);
968                        match observers.register(observer, Instant::now()) {
969                            Registered::Added | Registered::Replaced => {
970                                let seq = observers.next_sequence();
971                                drop(observers);
972                                response
973                                    .options
974                                    .push((codec::OPT_OBSERVE, codec::uint_option(seq)));
975                                info!(
976                                    "CoAP {peer_addr} now observes {path} decision={}",
977                                    Decision::ObserveRegistered.as_str()
978                                );
979                            }
980                            Registered::Full => {
981                                drop(observers);
982                                warn!(
983                                    "CoAP {peer_addr} asked to observe {path}, but {} observers \
984                                     are registered; served without Observe decision={}",
985                                    observe::MAX_OBSERVERS,
986                                    Decision::ObserveCapReached.as_str()
987                                );
988                            }
989                            Registered::PeerFull => {
990                                drop(observers);
991                                warn!(
992                                    "CoAP {peer_addr} asked to observe {path}, but its IP holds \
993                                     {} observations; served without Observe decision={}",
994                                    observe::MAX_OBSERVERS_PER_IP,
995                                    Decision::ObservePeerCapReached.as_str()
996                                );
997                            }
998                        }
999                    } else {
1000                        info!(
1001                            "CoAP {peer_addr} asked to observe {path}; the model declined \
1002                             decision={}",
1003                            Decision::ObserveDeclined.as_str()
1004                        );
1005                    }
1006                }
1007
1008                let szx = block2.map_or(codec::MAX_SZX, |b| b.szx);
1009                if !shared.fill_representation(&mut response, &rep, resource, block2, szx) {
1010                    let reply = spec_reply(
1011                        codec::CODE_BAD_OPTION,
1012                        Decision::SpecReply.as_str(),
1013                        format!(
1014                            "block {} is past the end of the representation",
1015                            block2.map_or(0, |b| b.num)
1016                        ),
1017                    );
1018                    send(reply).await;
1019                    return;
1020                }
1021                if let Some((_, last)) = upload {
1022                    response.options.push((codec::OPT_BLOCK1, last.encode()));
1023                }
1024                response
1025            }
1026        };
1027
1028        send(message).await;
1029    }
1030
1031    /// Interpret the model's structured answer.
1032    ///
1033    /// Fails closed: no usable action becomes 5.03 Service Unavailable, never a
1034    /// plausible-looking 2.05 with an empty body.
1035    fn outcome_from_results(
1036        request: &CoapMessage,
1037        results: &[ActionResult],
1038    ) -> (Decision, Outcome) {
1039        for result in results {
1040            let ActionResult::Custom { name, data } = result else {
1041                continue;
1042            };
1043            match name.as_str() {
1044                RESULT_RESPONSE => {
1045                    let code = data
1046                        .get("code")
1047                        .and_then(|v| v.as_u64())
1048                        .unwrap_or(codec::CODE_INTERNAL_SERVER_ERROR as u64)
1049                        as u8;
1050                    let payload = data
1051                        .get("payload_hex")
1052                        .and_then(|v| v.as_str())
1053                        .and_then(|s| hex::decode(s).ok())
1054                        .unwrap_or_default();
1055                    let content_format = data
1056                        .get("content_format")
1057                        .and_then(|v| v.as_u64())
1058                        .map(|v| v as u16);
1059                    // `execute_action` always writes it; absent only for a result built by
1060                    // hand, which is then an ordinary response that registers nobody.
1061                    let observable = data
1062                        .get("observable")
1063                        .and_then(|v| v.as_bool())
1064                        .unwrap_or(false);
1065                    // A class-2 code is the model serving the resource; 4.xx and 5.xx are
1066                    // it refusing. `execute_action` has already rejected every other class.
1067                    let decision = if codec::code_class(code) == 2 {
1068                        Decision::ModelAnswer
1069                    } else {
1070                        Decision::ModelReject
1071                    };
1072                    return (
1073                        decision,
1074                        Outcome::Response {
1075                            code,
1076                            payload,
1077                            content_format,
1078                            observable,
1079                        },
1080                    );
1081                }
1082                RESULT_RESET => return (Decision::ModelReset, Outcome::Reset),
1083                RESULT_IGNORE => return (Decision::ModelSilent, Outcome::Ignore),
1084                _ => {}
1085            }
1086        }
1087
1088        error!(
1089            "CoAP: no usable action returned for {} {}; answering 5.03 Service Unavailable",
1090            codec::method_name(request.code).unwrap_or("?"),
1091            request.uri_path()
1092        );
1093        (
1094            Decision::FailClosedNoAction,
1095            Outcome::Response {
1096                code: codec::CODE_SERVICE_UNAVAILABLE,
1097                payload: Vec::new(),
1098                content_format: None,
1099                observable: false,
1100            },
1101        )
1102    }
1103}
1104
1105/// Why a request is being answered the way it is.
1106///
1107/// 5.03 Service Unavailable is what both fail-closed paths put on the wire, and it is also
1108/// a code the model may legitimately choose itself, so the three are indistinguishable to
1109/// the peer. The log carries the distinction instead, with stable tokens -
1110/// `decision=fail_closed_` finds every request the model did not actually answer. Mirrors
1111/// `src/server/radius/`.
1112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1113enum Decision {
1114    /// The model served the resource (a class-2 response).
1115    ModelAnswer,
1116    /// The model refused, with a class-4 or class-5 code of its choosing.
1117    ModelReject,
1118    /// The model chose a Reset - "this message makes no sense at all".
1119    ModelReset,
1120    /// The model chose to send nothing. A decision, not an absence of one.
1121    ModelSilent,
1122    /// The specification determined the reply; the model was never asked.
1123    SpecReply,
1124    /// The datagram was over `codec::MAX_MESSAGE_LEN` and was refused unread. A decision
1125    /// the server makes on its own, so not a `fail_closed_` — but loud, because it is the
1126    /// tag that says an oversize request never became a prompt.
1127    RefusedTooLarge,
1128    /// The LLM backend failed. Nobody decided anything.
1129    FailClosedLlmError,
1130    /// The model was asked and returned nothing this request could use.
1131    FailClosedNoAction,
1132    /// A Block1 block that is not the last: 2.31 Continue, decided by RFC 7959, not the model.
1133    UploadContinue,
1134    /// A later Block2 block served from the representation the model already gave.
1135    BlockCache,
1136    /// A GET with Observe 0 the model served with a 2.xx: the client is now an observer.
1137    ObserveRegistered,
1138    /// The model served an Observe registration but said the resource is not observable.
1139    ObserveDeclined,
1140    /// `observe::MAX_OBSERVERS` were registered, so a registration was served without Observe.
1141    ObserveCapReached,
1142    /// The peer's IP held `observe::MAX_OBSERVERS_PER_IP` registrations, so a registration
1143    /// was served without Observe.
1144    ObservePeerCapReached,
1145    /// A notification went out and the observation stands.
1146    Notified,
1147    /// An observation ended: deregistered, or a non-2.xx notification.
1148    ObservationEnded,
1149    /// A client answered a notification with RST (RFC 7641 §3.6).
1150    ObserverReset,
1151    /// A Confirmable notification was never acknowledged (RFC 7641 §4.5).
1152    ObserverTimeout,
1153    /// A registration went unconfirmed for `observe::OBSERVER_MAX_AGE`.
1154    ObserverExpired,
1155}
1156
1157impl Decision {
1158    fn as_str(self) -> &'static str {
1159        match self {
1160            Decision::ModelAnswer => "model_answer",
1161            Decision::ModelReject => "model_reject",
1162            Decision::ModelReset => "model_reset",
1163            Decision::ModelSilent => "model_silent",
1164            Decision::SpecReply => "spec_reply",
1165            Decision::RefusedTooLarge => "refused_too_large",
1166            Decision::FailClosedLlmError => "fail_closed_llm_error",
1167            Decision::FailClosedNoAction => "fail_closed_no_action",
1168            Decision::UploadContinue => "upload_continue",
1169            Decision::BlockCache => "block_cache",
1170            Decision::ObserveRegistered => "observe_registered",
1171            Decision::ObserveDeclined => "observe_declined",
1172            Decision::ObserveCapReached => "observe_cap_reached",
1173            Decision::ObservePeerCapReached => "observe_peer_cap_reached",
1174            Decision::Notified => "notified",
1175            Decision::ObservationEnded => "observation_ended",
1176            Decision::ObserverReset => "observer_reset",
1177            Decision::ObserverTimeout => "observer_timeout",
1178            Decision::ObserverExpired => "observer_expired",
1179        }
1180    }
1181
1182    fn is_fail_closed(self) -> bool {
1183        matches!(
1184            self,
1185            Decision::FailClosedLlmError | Decision::FailClosedNoAction
1186        )
1187    }
1188}
1189
1190/// What to put on the wire for a request.
1191enum Outcome {
1192    Response {
1193        code: u8,
1194        payload: Vec<u8>,
1195        content_format: Option<u16>,
1196        /// Whether an Observe registration this answers may stand.
1197        observable: bool,
1198    },
1199    Reset,
1200    Ignore,
1201}
1202
1203/// What a request's Observe option asked for.
1204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1205enum ObserveRequest {
1206    Register,
1207    Deregister,
1208}
1209
1210impl ObserveRequest {
1211    fn as_str(self) -> &'static str {
1212        match self {
1213            ObserveRequest::Register => "register",
1214            ObserveRequest::Deregister => "deregister",
1215        }
1216    }
1217}