Skip to main content

netget/server/m3ua/
mod.rs

1//! M3UA / SIGTRAN server (RFC 4666) — an SGP that an ASP connects to.
2//!
3//! # The transport problem, stated plainly
4//!
5//! **M3UA runs over SCTP.** RFC 4666 section 1.4.1 says so and IANA assigns it SCTP port 2905.
6//! macOS has no SCTP stack — no kernel support, no headers — and macOS is the machine that
7//! tests this code. Two of the three possible responses are implemented here, and the third is
8//! recorded rather than hidden:
9//!
10//! 1. **SCTP is the default, and a host without it gets a refusal that says so.** [`bind_listener`]
11//!    asks the kernel for a `SOCK_STREAM`/`IPPROTO_SCTP` socket; where that fails, `spawn()`
12//!    returns an `Err` naming SCTP, and `server_startup` puts the server in
13//!    `ServerStatus::Error`. This is the `bluetooth_ble_beacon` precedent: *hiding a protocol is
14//!    not the same as refusing to start it* — refused, the operator learns exactly why; hidden,
15//!    nobody does. It is a **probe, not a `cfg`**: the socket is actually requested, so a Linux
16//!    kernel with the `sctp` module unloaded is diagnosed the same way as macOS.
17//! 2. **`transport: "tcp"` is a lab affordance and is labelled as one everywhere** — in the
18//!    startup parameter description, in `metadata().notes`, in the startup log line, in the
19//!    connection's `protocol_info` and in every event's `transport` field. **No real SIGTRAN
20//!    peer speaks M3UA over TCP.** It exists so the layers above the transport can be exercised
21//!    at all on this machine, and nothing else.
22//! 3. **Userspace SCTP** — `webrtc-rs` is already in the tree and carries an implementation — is
23//!    the real fix and is out of scope here. See `CLAUDE.md` in this directory.
24//!
25//! Everything above the socket is transport-agnostic: M3UA is length-delimited, so the framing
26//! is identical whether the octets arrive on an SCTP association or a TCP connection. That is
27//! what makes the lab transport useful and is also exactly why it must be labelled — the code
28//! path being exercised is real, and the transport under it is not.
29//!
30//! # Division of labour
31//!
32//! Rust owns everything that is not a decision: framing, header and parameter validity, the ASP
33//! state machine's mechanics, BEAT → BEAT ACK, ASPDN → ASPDN ACK, ASPIA → ASPIA ACK, and every
34//! refusal that follows from the protocol rather than from policy.
35//!
36//! The model owns the two decisions M3UA actually has — **may this ASP come up, and may it go
37//! active** — plus what SS7 traffic to answer with. Those are admission decisions into a
38//! signalling network, so they **fail closed**: an ASP is never admitted because nothing
39//! answered. Silence, a backend failure and "no policy was ever configured" are three distinct
40//! things and all three end in ERR, tagged apart in the log the way `src/server/radius/` does.
41//!
42//! # Session
43//!
44//! ```text
45//! accept                       -> ASP-DOWN
46//! ASPUP  -> m3ua_asp_up_received     -> ASPUP ACK  -> ASP-INACTIVE   (or ERR, stay DOWN)
47//! ASPAC  -> m3ua_asp_active_received -> ASPAC ACK  -> ASP-ACTIVE     (or ERR, stay INACTIVE)
48//! DATA   -> m3ua_data_received       -> optional DATA back
49//! BEAT   -> BEAT ACK                                (Rust, no LLM call)
50//! ASPIA  -> ASPIA ACK                -> ASP-INACTIVE (Rust, no LLM call)
51//! ASPDN  -> ASPDN ACK, then m3ua_asp_down_received -> ASP-DOWN
52//! ERR    -> m3ua_error_received                     (observational)
53//! ```
54//!
55//! BEAT is answered in Rust deliberately. It is a keepalive, not a decision: routing it through
56//! a parked manual handler would let the association time out while a human read a question.
57
58pub mod actions;
59pub mod ber;
60pub mod cap;
61pub mod codec;
62pub mod gsm;
63pub mod map;
64pub mod sccp;
65pub mod ss7;
66pub mod ss7_actions;
67pub mod tcap;
68pub mod transport;
69
70use anyhow::{anyhow, Result};
71use std::net::SocketAddr;
72use std::sync::Arc;
73use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
74use tokio::net::{TcpListener, TcpStream};
75use tokio::sync::{mpsc, Mutex};
76use tracing::{debug, error, info, trace, warn};
77use transport::{Reader, Writer};
78
79use crate::llm::action_helper::call_llm;
80use crate::llm::actions::protocol_trait::ActionResult;
81use crate::llm::ollama_client::OllamaClient;
82use crate::logging::emit::Log;
83use crate::protocol::Event;
84use crate::server::connection::ConnectionId;
85use crate::server::M3uaProtocol;
86use crate::state::app_state::AppState;
87use crate::state::ServerId;
88use crate::utils::WireFailure;
89
90use actions::{
91    M3UA_ASP_ACTIVE_EVENT, M3UA_ASP_DOWN_EVENT, M3UA_ASP_UP_EVENT, M3UA_DATA_EVENT,
92    M3UA_ERROR_EVENT,
93};
94
95/// IANA protocol number for SCTP. Named here rather than taken from `libc` because the
96/// constant is absent on the platforms that lack the stack, which is precisely where this code
97/// has to compile in order to produce the refusal.
98const IPPROTO_SCTP: i32 = 132;
99
100/// How long to wait for a peer's first message after the association comes up.
101///
102/// RFC 4666 has the ASP send ASPUP as soon as the association is established — the SG says
103/// nothing first, and nothing legitimate precedes it. A peer that has connected and sent
104/// nothing is not mid-handshake; it is holding an association slot on no request at all.
105const FIRST_MESSAGE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
106
107/// How long to wait for a *further* message once one has arrived.
108///
109/// Fifteen minutes, and it is long on purpose: a signalling link with no traffic is the normal
110/// case, not a suspicious one, so this must not be a traffic timer. The protocol's own answer
111/// to "is this quiet link still alive" is BEAT (RFC 4666 section 3.5.5), which exists precisely
112/// so an idle-but-live ASP can prove it and which deployments configure at tens of seconds —
113/// so fifteen minutes is on the order of thirty missed beats. A peer that sends neither
114/// traffic nor a single BEAT for that long is indistinguishable from one that has gone away,
115/// and over TCP (this server's transport here) there is no SCTP heartbeat underneath to tell
116/// the difference.
117const IDLE_BETWEEN_MESSAGES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
118
119/// Concurrent associations this server admits.
120const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
121
122/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
123///
124/// An M3UA ERR carrying error code `Refused - Management Blocking`
125/// ([`codec::ERR_REFUSED_MANAGEMENT_BLOCKING`], 0x0d) — which is what that code is for, and
126/// which this server already sends elsewhere when it declines an ASP. ERR is a Management-class
127/// message either end may send unsolicited at any time, so unlike most protocols here there is
128/// a *correct* thing to say to a peer that has not spoken yet.
129///
130/// Encoded by hand rather than through [`codec::Message::encode`] because a `const` cannot call
131/// it: version 1, reserved 0, class MGMT(0), type ERR(0), length 16; then parameter tag
132/// `TAG_ERROR_CODE` (0x000c), parameter length 8, value 0x0000000d.
133const CONNECTION_CAP_REFUSAL: &[u8] = &[
134    0x01, 0x00, 0x00, 0x00, // version, reserved, class MGMT, type ERR
135    0x00, 0x00, 0x00, 0x10, // message length: 16
136    0x00, 0x0c, 0x00, 0x08, // parameter: Error Code, length 8
137    0x00, 0x00, 0x00, 0x0d, // Refused - Management Blocking
138];
139
140/// M3UA server: an SGP that ASPs connect to.
141pub struct M3uaServer;
142
143/// Which transport the association runs on.
144#[derive(Clone, Copy, Debug, PartialEq, Eq)]
145pub enum M3uaTransport {
146    /// The real one (RFC 4666 section 1.4.1). Requires an operating system with SCTP.
147    Sctp,
148    /// **Non-standard.** A lab affordance so the M3UA layer can be exercised on a host with no
149    /// SCTP stack. No real SIGTRAN peer speaks this.
150    Tcp,
151}
152
153impl M3uaTransport {
154    pub fn parse(value: &str) -> Result<Self> {
155        match value.trim().to_ascii_lowercase().as_str() {
156            "sctp" => Ok(Self::Sctp),
157            "tcp" => Ok(Self::Tcp),
158            other => Err(anyhow!(
159                "transport must be \"sctp\" (the transport RFC 4666 defines) or \"tcp\" \
160                 (non-standard, for local protocol work only), got {other:?}"
161            )),
162        }
163    }
164
165    pub fn as_str(self) -> &'static str {
166        match self {
167            Self::Sctp => "sctp",
168            Self::Tcp => "tcp",
169        }
170    }
171
172    /// How the transport is described wherever an operator or the model can see it.
173    ///
174    /// The TCP wording carries its own warning because this string travels: it is in the
175    /// startup log, in the connection's `protocol_info`, and in every event handed to the
176    /// model. Somebody reading any one of those must not come away believing they have SCTP.
177    pub fn label(self) -> &'static str {
178        match self {
179            Self::Sctp => "sctp (RFC 4666)",
180            Self::Tcp => "tcp (NON-STANDARD lab transport, not SIGTRAN)",
181        }
182    }
183}
184
185/// Bind the listening socket for `transport`.
186///
187/// Public because the SCTP refusal is a feature with its own test: on a host without an SCTP
188/// stack this must fail, and fail with a message that names SCTP rather than an errno.
189pub async fn bind_listener(addr: SocketAddr, transport: M3uaTransport) -> Result<TcpListener> {
190    match transport {
191        M3uaTransport::Tcp => {
192            crate::server::socket_helpers::create_reusable_tcp_listener(addr).await
193        }
194        M3uaTransport::Sctp => bind_sctp_listener(addr),
195    }
196}
197
198/// Ask the kernel for a one-to-one SCTP socket and listen on it.
199///
200/// The SCTP one-to-one style (`SOCK_STREAM` with `IPPROTO_SCTP`) presents the ordinary
201/// accept/read/write socket API, so the association is driven by the same code as the TCP lab
202/// transport once it exists. What differs is that on a host without SCTP, `socket(2)` fails
203/// here — and that failure is the whole point of this function.
204fn bind_sctp_listener(addr: SocketAddr) -> Result<TcpListener> {
205    use socket2::{Domain, Protocol, Socket, Type};
206
207    let domain = if addr.is_ipv4() {
208        Domain::IPV4
209    } else {
210        Domain::IPV6
211    };
212
213    let socket =
214        Socket::new(domain, Type::STREAM, Some(Protocol::from(IPPROTO_SCTP))).map_err(|e| {
215            anyhow!(
216                "M3UA cannot open an SCTP socket on this host: {e}. M3UA's transport is SCTP \
217                 (RFC 4666 section 1.4.1, IANA port 2905) and this operating system has no SCTP \
218                 stack available to this process — macOS ships neither kernel support nor \
219                 headers for SCTP, and a Linux kernel without the sctp module fails the same \
220                 way. NetGet refuses to start rather than pretend it is speaking SIGTRAN. \
221                 Either run this server on a host with SCTP, or, for local protocol work only, \
222                 pass the startup parameter transport=\"tcp\" — a NON-STANDARD framing that \
223                 exercises the M3UA layer and that no real SIGTRAN peer speaks."
224            )
225        })?;
226
227    socket
228        .set_reuse_address(true)
229        .map_err(|e| anyhow!("M3UA could not set SO_REUSEADDR on the SCTP socket: {e}"))?;
230    socket
231        .bind(&addr.into())
232        .map_err(|e| anyhow!("M3UA could not bind the SCTP socket to {addr}: {e}"))?;
233    socket
234        .listen(128)
235        .map_err(|e| anyhow!("M3UA could not listen on the SCTP socket at {addr}: {e}"))?;
236    socket
237        .set_nonblocking(true)
238        .map_err(|e| anyhow!("M3UA could not put the SCTP socket in non-blocking mode: {e}"))?;
239
240    let std_listener: std::net::TcpListener = socket.into();
241    TcpListener::from_std(std_listener)
242        .map_err(|e| anyhow!("M3UA could not register the SCTP listener with tokio: {e}"))
243}
244
245/// Operator configuration for one SGP instance.
246#[derive(Clone, Copy, Debug)]
247pub struct M3uaConfig {
248    pub transport: M3uaTransport,
249    /// When set, an ASPAC or DATA naming a different Routing Context is refused in Rust.
250    pub routing_context: Option<u32>,
251    /// When set, a DATA naming a different Network Appearance is refused in Rust.
252    pub network_appearance: Option<u32>,
253}
254
255impl M3uaConfig {
256    /// Validate startup parameters before anything binds.
257    ///
258    /// Errors propagate with `?`; nothing here unwraps. An undeclared key or a wrong-typed
259    /// value fails startup with a message naming the key rather than killing the task that is
260    /// starting the server.
261    fn from_params(params: Option<&crate::protocol::StartupParams>) -> Result<Self> {
262        let Some(params) = params else {
263            return Ok(Self {
264                transport: M3uaTransport::Sctp,
265                routing_context: None,
266                network_appearance: None,
267            });
268        };
269
270        let transport = match params.get_optional_string("transport")? {
271            Some(s) => M3uaTransport::parse(&s)?,
272            None => M3uaTransport::Sctp,
273        };
274
275        Ok(Self {
276            transport,
277            routing_context: params.get_optional_u32("routing_context")?,
278            network_appearance: params.get_optional_u32("network_appearance")?,
279        })
280    }
281}
282
283/// Where one ASP sits in the RFC 4666 section 4.3.1 state machine.
284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
285pub enum AspState {
286    Down,
287    Inactive,
288    Active,
289}
290
291impl AspState {
292    fn as_str(self) -> &'static str {
293        match self {
294            Self::Down => "ASP-DOWN",
295            Self::Inactive => "ASP-INACTIVE",
296            Self::Active => "ASP-ACTIVE",
297        }
298    }
299}
300
301impl M3uaServer {
302    /// Spawn the M3UA listener.
303    ///
304    /// Awaits readiness: the socket is bound before this returns, so `server_startup` reports a
305    /// real failure — including "this host has no SCTP" — instead of parking the server in
306    /// `Running` having bound nothing.
307    pub async fn spawn_with_llm_actions(
308        listen_addr: SocketAddr,
309        llm_client: OllamaClient,
310        app_state: Arc<AppState>,
311        status_tx: mpsc::UnboundedSender<String>,
312        server_id: ServerId,
313        startup_params: Option<crate::protocol::StartupParams>,
314    ) -> Result<SocketAddr> {
315        let config = M3uaConfig::from_params(startup_params.as_ref())?;
316        let listener = bind_listener(listen_addr, config.transport).await?;
317        let local_addr = listener.local_addr()?;
318
319        Log::new(Some(&status_tx)).info(format!(
320            "M3UA SGP listening on {} over {}",
321            local_addr,
322            config.transport.label()
323        ));
324        if config.transport == M3uaTransport::Tcp {
325            Log::new(Some(&status_tx)).warn(
326                "M3UA is running over TCP. This is NOT SIGTRAN: RFC 4666 defines M3UA over \
327                 SCTP only, and no real ASP will connect over TCP. Use it for local protocol \
328                 work and nothing else.",
329            );
330        }
331
332        let protocol = Arc::new(M3uaProtocol::new());
333        let task_registrar = app_state.clone();
334
335        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
336        let accept_handle = tokio::spawn(async move {
337            loop {
338                match crate::server::accept_bounded::accept_bounded(
339                    &listener,
340                    &limiter,
341                    CONNECTION_CAP_REFUSAL,
342                    "M3UA",
343                    Some(&status_tx),
344                )
345                .await
346                {
347                    Ok((stream, remote_addr, permit)) => {
348                        let connection_id =
349                            ConnectionId::new(app_state.get_next_unified_id().await);
350                        Log::new(Some(&status_tx)).info(format!(
351                            "M3UA association {} from {} ({})",
352                            connection_id,
353                            remote_addr,
354                            config.transport.as_str()
355                        ));
356
357                        register_connection(
358                            &app_state,
359                            server_id,
360                            connection_id,
361                            remote_addr,
362                            config.transport,
363                        )
364                        .await;
365                        let _ = status_tx.send("__UPDATE_UI__".to_string());
366
367                        let llm_clone = llm_client.clone();
368                        let state_clone = app_state.clone();
369                        let status_clone = status_tx.clone();
370                        let protocol_clone = protocol.clone();
371
372                        // Tracked, not detached: stop_server must abort this task too.
373                        let task_owner = app_state.clone();
374                        task_owner
375                            .spawn_server_task(server_id, async move {
376                                // Held for the life of the association, so the cap counts live
377                                // associations rather than accepts.
378                                let _permit = permit;
379                                if let Err(e) = run_session(
380                                    stream,
381                                    connection_id,
382                                    server_id,
383                                    remote_addr,
384                                    llm_clone,
385                                    state_clone.clone(),
386                                    status_clone.clone(),
387                                    protocol_clone,
388                                    config,
389                                )
390                                .await
391                                {
392                                    Log::new(Some(&status_clone))
393                                        .error(format!("M3UA association error: {}", e));
394                                }
395
396                                state_clone
397                                    .remove_peer_handle(server_id, connection_id.as_u32())
398                                    .await;
399                                state_clone
400                                    .close_connection_on_server(server_id, connection_id)
401                                    .await;
402                                Log::new(Some(&status_clone))
403                                    .info(format!("M3UA association {} closed", connection_id));
404                                let _ = status_clone.send("__UPDATE_UI__".to_string());
405                            })
406                            .await;
407                    }
408                    Err(e) => {
409                        Log::new(Some(&status_tx))
410                            .error(format!("Failed to accept M3UA association: {}", e));
411                        break;
412                    }
413                }
414            }
415        });
416
417        // Register the accept loop so stop_server can abort it and release the port.
418        task_registrar
419            .register_server_task(server_id, accept_handle)
420            .await;
421
422        Ok(local_addr)
423    }
424}
425
426async fn register_connection(
427    app_state: &AppState,
428    server_id: ServerId,
429    connection_id: ConnectionId,
430    remote_addr: SocketAddr,
431    transport: M3uaTransport,
432) {
433    use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
434    let now = crate::utils::clock::Instant::now();
435    app_state
436        .add_connection_to_server(
437            server_id,
438            ConnectionState {
439                id: connection_id,
440                remote_addr,
441                local_addr: remote_addr,
442                bytes_sent: 0,
443                bytes_received: 0,
444                packets_sent: 0,
445                packets_received: 0,
446                last_activity: now,
447                status: ConnectionStatus::Active,
448                status_changed_at: now,
449                protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
450                    "asp_state": AspState::Down.as_str(),
451                    // The rail shows this, and it must never let someone believe an association
452                    // over the lab transport is an SCTP association.
453                    "transport": transport.label(),
454                })),
455            },
456        )
457        .await;
458}
459
460/// One M3UA association.
461struct M3uaSession {
462    connection_id: ConnectionId,
463    server_id: ServerId,
464    remote_addr: SocketAddr,
465    llm_client: OllamaClient,
466    app_state: Arc<AppState>,
467    status_tx: mpsc::UnboundedSender<String>,
468    protocol: Arc<M3uaProtocol>,
469    config: M3uaConfig,
470    state: AspState,
471    /// The ASP Identifier from ASPUP, if it sent one — reported in later events so a handler
472    /// can tell two ASPs apart.
473    asp_identifier: Option<u32>,
474    writer: Arc<Mutex<Writer>>,
475    /// TCAP dialogues on this association (the SCCP / TCAP / MAP / CAP layer).
476    engine: ss7::Engine,
477}
478
479/// Outcome of reading one framed message.
480pub enum Incoming {
481    Message(codec::Message),
482    /// The common header was unusable. There is no way to find the next message boundary after
483    /// this, so the association ends.
484    HeaderError(codec::WireError),
485    /// The header was fine and the parameters were not. One ERR, association continues.
486    BodyError(codec::WireError),
487    Eof,
488}
489
490#[allow(clippy::too_many_arguments)]
491async fn run_session(
492    stream: TcpStream,
493    connection_id: ConnectionId,
494    server_id: ServerId,
495    remote_addr: SocketAddr,
496    llm_client: OllamaClient,
497    app_state: Arc<AppState>,
498    status_tx: mpsc::UnboundedSender<String>,
499    protocol: Arc<M3uaProtocol>,
500    config: M3uaConfig,
501) -> Result<()> {
502    // Split rather than clone: the read half stays here, the write half is shared with the
503    // dashboard's peer command task. Over SCTP each write is one message with PPID 3, on stream
504    // 0 for management and stream 1 for DATA (`transport.rs`).
505    let (mut reader, writer) = transport::split(stream, config.transport == M3uaTransport::Sctp);
506    let writer = Arc::new(Mutex::new(writer));
507
508    // Registered before the first read so "message this peer" and "disconnect this peer" work
509    // even while a manual rule has the ASPUP event parked for a human.
510    let peer_rx = crate::server::peer_support::register_peer_channel(
511        &app_state,
512        server_id,
513        connection_id.as_u32(),
514    )
515    .await;
516    crate::server::peer_support::spawn_peer_command_task(
517        peer_rx,
518        Arc::new(M3uaProtocol::for_peer_commands()),
519        app_state.clone(),
520        server_id,
521        connection_id.as_u32(),
522        writer.clone(),
523        status_tx.clone(),
524    );
525
526    let mut session = M3uaSession {
527        connection_id,
528        server_id,
529        remote_addr,
530        llm_client,
531        app_state,
532        status_tx,
533        protocol,
534        config,
535        state: AspState::Down,
536        asp_identifier: None,
537        writer,
538        engine: ss7::Engine::new(),
539    };
540
541    session.run(&mut reader).await
542}
543
544impl M3uaSession {
545    async fn run(&mut self, reader: &mut Reader) -> Result<()> {
546        debug!(
547            "M3UA association {} open, ASP in {}",
548            self.connection_id,
549            self.state.as_str()
550        );
551
552        let mut received_one = false;
553
554        loop {
555            // The deadline wraps `read_message` and nothing else. Everything that can
556            // legitimately take minutes — the LLM round-trip that decides whether an ASP may
557            // come up, and a `manual` rule parking that decision for a human
558            // (`src/state/intercepts.rs`, 300s by default) — happens below, once a whole
559            // message has been read. So neither sits inside a read deadline. What is bounded is
560            // a peer holding the association while sending nothing, and a peer that announces a
561            // message length and stalls part-way through delivering it.
562            let read_timeout = if received_one {
563                IDLE_BETWEEN_MESSAGES_TIMEOUT
564            } else {
565                FIRST_MESSAGE_READ_TIMEOUT
566            };
567            let (incoming, bytes_in) = read_message(reader, read_timeout).await;
568            if !matches!(incoming, Incoming::Eof) {
569                received_one = true;
570            }
571            if bytes_in > 0 {
572                self.app_state
573                    .update_connection_stats(
574                        self.server_id,
575                        self.connection_id,
576                        Some(bytes_in as u64),
577                        None,
578                        Some(1),
579                        None,
580                    )
581                    .await;
582            }
583
584            match incoming {
585                Incoming::Eof => {
586                    debug!("M3UA association {} closed by peer", self.connection_id);
587                    break;
588                }
589                Incoming::HeaderError(e) => {
590                    Log::new(Some(&self.status_tx)).warn(format!(
591                        "M3UA header rejected from {}: {}",
592                        self.remote_addr, e
593                    ));
594                    self.send_error(e.error_code).await;
595                    break;
596                }
597                Incoming::BodyError(e) => {
598                    Log::new(Some(&self.status_tx)).warn(format!(
599                        "M3UA message from {} rejected: {}",
600                        self.remote_addr, e
601                    ));
602                    self.send_error(e.error_code).await;
603                }
604                Incoming::Message(message) => {
605                    if !self.dispatch(message).await? {
606                        break;
607                    }
608                }
609            }
610        }
611        Ok(())
612    }
613
614    /// Handle one decoded message. Returns `false` when the association must close.
615    async fn dispatch(&mut self, message: codec::Message) -> Result<bool> {
616        trace!(
617            "M3UA {} from {} in {}",
618            message.name(),
619            self.remote_addr,
620            self.state.as_str()
621        );
622
623        match (message.class, message.msg_type) {
624            (codec::CLASS_ASPSM, codec::ASPSM_ASPUP) => self.on_asp_up(&message).await,
625            (codec::CLASS_ASPSM, codec::ASPSM_ASPDN) => self.on_asp_down(&message).await,
626            (codec::CLASS_ASPSM, codec::ASPSM_BEAT) => {
627                // Answered in Rust with no LLM call: a keepalive is not a decision, and parking
628                // it behind a manual handler would drop the association while a human read the
629                // question. The Heartbeat Data is echoed verbatim, which is what makes it
630                // useful to the ASP.
631                let heartbeat = message
632                    .param(codec::TAG_HEARTBEAT_DATA)
633                    .map(|p| p.value.clone());
634                trace!("M3UA BEAT from {} -> BEAT ACK", self.remote_addr);
635                // "Verbatim" has a ceiling: the peer chooses this data, and a BEAT carrying a
636                // maximal Heartbeat Data parameter cannot be echoed inside the 65535-octet
637                // message the header can describe. That is the one wire-reachable encode
638                // refusal in this protocol, so it gets ERR rather than silence — the ASP is
639                // told its keepalive was rejected instead of watching it disappear.
640                match codec::beat_ack(heartbeat.as_deref()) {
641                    Ok(bytes) => self.send(bytes).await,
642                    Err(e) => {
643                        error!(
644                            "M3UA decision=fail_closed_encode: BEAT from {} carries Heartbeat \
645                             Data that cannot be echoed within the message ceiling: {}",
646                            self.remote_addr, e
647                        );
648                        self.send_error(codec::ERR_PROTOCOL_ERROR).await;
649                    }
650                }
651                Ok(true)
652            }
653            (codec::CLASS_ASPTM, codec::ASPTM_ASPAC) => self.on_asp_active(&message).await,
654            (codec::CLASS_ASPTM, codec::ASPTM_ASPIA) => {
655                // Taking an ASP out of service needs no permission — the risk runs entirely the
656                // other way — so this is mechanical too.
657                self.state = AspState::Inactive;
658                self.set_connection_state().await;
659                Log::new(Some(&self.status_tx)).info(format!(
660                    "M3UA ASPIA from {}, ASP is now {}",
661                    self.remote_addr,
662                    self.state.as_str()
663                ));
664                self.send_encoded(
665                    "ASPIA ACK",
666                    codec::aspia_ack(message.param_u32(codec::TAG_ROUTING_CONTEXT)),
667                )
668                .await;
669                Ok(true)
670            }
671            (codec::CLASS_TRANSFER, codec::TRANSFER_DATA) => self.on_data(&message).await,
672            (codec::CLASS_MGMT, codec::MGMT_ERR) => self.on_error_received(&message).await,
673            (codec::CLASS_MGMT, codec::MGMT_NTFY) => {
674                // An SGP receiving NTFY is unusual but harmless; it carries no request.
675                Log::new(Some(&self.status_tx)).info(format!(
676                    "M3UA NTFY from {} status=0x{:08x}",
677                    self.remote_addr,
678                    message.param_u32(codec::TAG_STATUS).unwrap_or(0)
679                ));
680                Ok(true)
681            }
682            (codec::CLASS_RKM, _) => {
683                // Dynamic registration means keeping a routing key table, and a routing key
684                // table is storage — which the root CLAUDE.md forbids a protocol from
685                // implementing. Refusing explicitly is better than accepting and forgetting:
686                // an ASP that believes it registered a routing key will send traffic for it.
687                Log::new(Some(&self.status_tx)).warn(format!(
688                    "M3UA {} from {} refused: NetGet implements no routing key table \
689                     (dynamic registration is storage; configure routing_context instead)",
690                    message.name(),
691                    self.remote_addr
692                ));
693                self.send_error(codec::ERR_UNSUPPORTED_MESSAGE_TYPE).await;
694                Ok(true)
695            }
696            (codec::CLASS_SSNM, _) => {
697                // SSNM travels SGP -> ASP. One arriving here is an ASP speaking out of turn.
698                warn!(
699                    "M3UA {} from {} is an SGP-to-ASP message; refusing",
700                    message.name(),
701                    self.remote_addr
702                );
703                self.send_error(codec::ERR_UNEXPECTED_MESSAGE).await;
704                Ok(true)
705            }
706            (class, msg_type) => {
707                let known_class = matches!(
708                    class,
709                    codec::CLASS_MGMT
710                        | codec::CLASS_TRANSFER
711                        | codec::CLASS_SSNM
712                        | codec::CLASS_ASPSM
713                        | codec::CLASS_ASPTM
714                        | codec::CLASS_RKM
715                );
716                let code = if known_class {
717                    codec::ERR_UNSUPPORTED_MESSAGE_TYPE
718                } else {
719                    codec::ERR_UNSUPPORTED_MESSAGE_CLASS
720                };
721                Log::new(Some(&self.status_tx)).warn(format!(
722                    "M3UA class {} ({}) type {} from {} is not supported",
723                    class,
724                    codec::class_name(class),
725                    msg_type,
726                    self.remote_addr
727                ));
728                self.send_error(code).await;
729                Ok(true)
730            }
731        }
732    }
733
734    // -----------------------------------------------------------------------
735    // ASPSM / ASPTM
736    // -----------------------------------------------------------------------
737
738    async fn on_asp_up(&mut self, message: &codec::Message) -> Result<bool> {
739        self.asp_identifier = message.param_u32(codec::TAG_ASP_IDENTIFIER);
740
741        // RFC 4666 section 4.3.4.3: an ASPUP for an ASP that is already up is acknowledged and
742        // nothing else happens. Not an admission — this ASP was admitted already — so no policy
743        // is consulted for the retransmission.
744        if self.state != AspState::Down {
745            debug!(
746                "M3UA duplicate ASPUP from {} while {}; re-acknowledging",
747                self.remote_addr,
748                self.state.as_str()
749            );
750            self.send_encoded("ASPUP ACK", codec::aspup_ack(None)).await;
751            return Ok(true);
752        }
753
754        let info_string = message
755            .param(codec::TAG_INFO_STRING)
756            .map(|p| String::from_utf8_lossy(&p.value).to_string());
757
758        Log::new(Some(&self.status_tx)).info(format!(
759            "M3UA ASPUP from {} (asp_id={:?})",
760            self.remote_addr, self.asp_identifier
761        ));
762
763        let event = Event {
764            event_type: &M3UA_ASP_UP_EVENT,
765            data: serde_json::json!({
766                "connection_id": self.connection_id.to_string(),
767                "remote_addr": self.remote_addr.to_string(),
768                "transport": self.config.transport.label(),
769                "asp_identifier": self.asp_identifier,
770                "info_string": info_string,
771                "asp_state": self.state.as_str(),
772            }),
773        };
774
775        self.decide_admission(
776            &event,
777            (codec::CLASS_ASPSM, codec::ASPSM_ASPUP_ACK),
778            AspState::Inactive,
779        )
780        .await
781    }
782
783    async fn on_asp_active(&mut self, message: &codec::Message) -> Result<bool> {
784        // Protocol validity is decided in Rust and never delegated: an ASP that never came up
785        // cannot go active, whatever a policy would have said.
786        if self.state == AspState::Down {
787            Log::new(Some(&self.status_tx)).warn(format!(
788                "M3UA ASPAC from {} while ASP-DOWN; refusing without consulting policy",
789                self.remote_addr
790            ));
791            self.send_error(codec::ERR_UNEXPECTED_MESSAGE).await;
792            return Ok(true);
793        }
794
795        let routing_context = message.param_u32(codec::TAG_ROUTING_CONTEXT);
796        if let Some(expected) = self.config.routing_context {
797            if let Some(offered) = routing_context {
798                if offered != expected {
799                    Log::new(Some(&self.status_tx)).warn(format!(
800                        "M3UA ASPAC from {} names routing context {} but this SGP serves {}",
801                        self.remote_addr, offered, expected
802                    ));
803                    self.send_error(codec::ERR_INVALID_ROUTING_CONTEXT).await;
804                    return Ok(true);
805                }
806            }
807        }
808
809        // Already active: acknowledge the retransmission without re-asking.
810        if self.state == AspState::Active {
811            debug!(
812                "M3UA duplicate ASPAC from {} while ASP-ACTIVE; re-acknowledging",
813                self.remote_addr
814            );
815            self.send_encoded(
816                "ASPAC ACK",
817                codec::aspac_ack(
818                    message.param_u32(codec::TAG_TRAFFIC_MODE_TYPE),
819                    routing_context,
820                    None,
821                ),
822            )
823            .await;
824            return Ok(true);
825        }
826
827        let traffic_mode = message.param_u32(codec::TAG_TRAFFIC_MODE_TYPE);
828        Log::new(Some(&self.status_tx)).info(format!(
829            "M3UA ASPAC from {} (rc={:?}, traffic_mode={:?})",
830            self.remote_addr, routing_context, traffic_mode
831        ));
832
833        let event = Event {
834            event_type: &M3UA_ASP_ACTIVE_EVENT,
835            data: serde_json::json!({
836                "connection_id": self.connection_id.to_string(),
837                "remote_addr": self.remote_addr.to_string(),
838                "transport": self.config.transport.label(),
839                "asp_identifier": self.asp_identifier,
840                "routing_context": routing_context,
841                "traffic_mode": traffic_mode,
842                "traffic_mode_name": traffic_mode.map(traffic_mode_name),
843                "asp_state": self.state.as_str(),
844            }),
845        };
846
847        self.decide_admission(
848            &event,
849            (codec::CLASS_ASPTM, codec::ASPTM_ASPAC_ACK),
850            AspState::Active,
851        )
852        .await
853    }
854
855    /// The two admission decisions M3UA has, and the one place they may be made.
856    ///
857    /// Every path that is not an explicit acknowledgement ends in ERR and leaves the ASP where
858    /// it was. That is the fail-closed rule, and the four ways of not answering are kept apart
859    /// in the log rather than collapsed: an operator has to be able to tell a policy that
860    /// refused from a backend that fell over.
861    async fn decide_admission(
862        &mut self,
863        event: &Event,
864        ack: (u8, u8),
865        next_state: AspState,
866    ) -> Result<bool> {
867        let event_id = event.event_type.id.clone();
868        let outcome = self.call_llm_and_send(event).await;
869
870        match outcome {
871            HandlerOutcome::Ran { ref sent, closed } if sent.contains(&ack) => {
872                self.state = next_state;
873                self.set_connection_state().await;
874                Log::new(Some(&self.status_tx)).info(format!(
875                    "M3UA {} admitted {} to {}",
876                    event_id,
877                    self.remote_addr,
878                    self.state.as_str()
879                ));
880                Ok(!closed)
881            }
882            HandlerOutcome::Ran { ref sent, closed }
883                if sent.contains(&(codec::CLASS_MGMT, codec::MGMT_ERR)) =>
884            {
885                // The policy refused explicitly, and its own ERR is already on the wire. This
886                // path is structurally distinct from silence on purpose: a refusal must not be
887                // readable as an outage, nor an outage as a refusal.
888                info!(
889                    "M3UA {} decision=model_reject peer={} asp stays {}",
890                    event_id,
891                    self.remote_addr,
892                    self.state.as_str()
893                );
894                Ok(!closed)
895            }
896            HandlerOutcome::Ran { closed, .. } => {
897                // The policy ran and produced no acknowledgement — wait_for_more, an empty
898                // handler, or a NTFY and nothing else. For an admission that is a refusal:
899                // silence must never open a signalling gateway.
900                Log::new(Some(&self.status_tx)).warn(format!(
901                    "M3UA {} produced no acknowledgement for {} (decision=model_silent); \
902                     refusing, ASP stays {}",
903                    event_id,
904                    self.remote_addr,
905                    self.state.as_str()
906                ));
907                self.send_error(codec::ERR_REFUSED_MANAGEMENT_BLOCKING)
908                    .await;
909                Ok(!closed)
910            }
911            HandlerOutcome::Failed(failure) => {
912                // The operator configured a policy and it could not be evaluated. Admitting
913                // anyway would turn a backend outage into "any ASP may join the signalling
914                // network", which is the fail-open pattern the root CLAUDE.md calls the most
915                // dangerous in this codebase.
916                Log::new(Some(&self.status_tx)).warn(format!(
917                    "M3UA {} could not be evaluated for {} (decision=fail_closed_llm_error, \
918                     {}); refusing, ASP stays {}",
919                    event_id,
920                    self.remote_addr,
921                    failure_category(failure),
922                    self.state.as_str()
923                ));
924                self.send_error(codec::ERR_REFUSED_MANAGEMENT_BLOCKING)
925                    .await;
926                Ok(true)
927            }
928            HandlerOutcome::NoPolicy => {
929                // Nobody ever said who may use this gateway. There is no safe default for
930                // that question, so it is answered with a refusal and a log line that says
931                // exactly what is missing.
932                Log::new(Some(&self.status_tx)).warn(format!(
933                    "M3UA {} refused for {} (decision=no_policy_configured): this server has \
934                     no instruction and no event handler, so nothing decides which ASPs may \
935                     join. Give the server an instruction, or a handler for {}.",
936                    event_id, self.remote_addr, event_id
937                ));
938                self.send_error(codec::ERR_REFUSED_MANAGEMENT_BLOCKING)
939                    .await;
940                Ok(true)
941            }
942        }
943    }
944
945    async fn on_asp_down(&mut self, _message: &codec::Message) -> Result<bool> {
946        // Acknowledged first and unconditionally. Taking a peer *out* of service is the safe
947        // direction, so it needs no policy — and an ASP that cannot leave cleanly is a worse
948        // failure than one that cannot join.
949        self.send_encoded("ASPDN ACK", codec::aspdn_ack()).await;
950        self.state = AspState::Down;
951        self.set_connection_state().await;
952        Log::new(Some(&self.status_tx)).info(format!(
953            "M3UA ASPDN from {}, ASP is now {}",
954            self.remote_addr,
955            self.state.as_str()
956        ));
957
958        let event = Event {
959            event_type: &M3UA_ASP_DOWN_EVENT,
960            data: serde_json::json!({
961                "connection_id": self.connection_id.to_string(),
962                "remote_addr": self.remote_addr.to_string(),
963                "transport": self.config.transport.label(),
964                "asp_identifier": self.asp_identifier,
965                "asp_state": self.state.as_str(),
966            }),
967        };
968        let outcome = self.call_llm_and_send(&event).await;
969        self.log_observational_outcome("m3ua_asp_down_received", outcome);
970        Ok(true)
971    }
972
973    async fn on_error_received(&mut self, message: &codec::Message) -> Result<bool> {
974        let code = message.param_u32(codec::TAG_ERROR_CODE).unwrap_or(0);
975        Log::new(Some(&self.status_tx)).warn(format!(
976            "M3UA ERR from {}: 0x{:02x} {}",
977            self.remote_addr,
978            code,
979            codec::error_code_name(code)
980        ));
981
982        let event = Event {
983            event_type: &M3UA_ERROR_EVENT,
984            data: serde_json::json!({
985                "connection_id": self.connection_id.to_string(),
986                "remote_addr": self.remote_addr.to_string(),
987                "transport": self.config.transport.label(),
988                "error_code": code,
989                "error_name": codec::error_code_name(code),
990                "routing_context": message.param_u32(codec::TAG_ROUTING_CONTEXT),
991                "asp_state": self.state.as_str(),
992            }),
993        };
994        let outcome = self.call_llm_and_send(&event).await;
995        self.log_observational_outcome("m3ua_error_received", outcome);
996        Ok(true)
997    }
998
999    // -----------------------------------------------------------------------
1000    // Transfer
1001    // -----------------------------------------------------------------------
1002
1003    async fn on_data(&mut self, message: &codec::Message) -> Result<bool> {
1004        if self.state != AspState::Active {
1005            Log::new(Some(&self.status_tx)).warn(format!(
1006                "M3UA DATA from {} while {}; refusing",
1007                self.remote_addr,
1008                self.state.as_str()
1009            ));
1010            self.send_error(codec::ERR_UNEXPECTED_MESSAGE).await;
1011            return Ok(true);
1012        }
1013
1014        let routing_context = message.param_u32(codec::TAG_ROUTING_CONTEXT);
1015        if let (Some(expected), Some(offered)) = (self.config.routing_context, routing_context) {
1016            if expected != offered {
1017                self.send_error(codec::ERR_INVALID_ROUTING_CONTEXT).await;
1018                return Ok(true);
1019            }
1020        }
1021        let network_appearance = message.param_u32(codec::TAG_NETWORK_APPEARANCE);
1022        if let (Some(expected), Some(offered)) =
1023            (self.config.network_appearance, network_appearance)
1024        {
1025            if expected != offered {
1026                self.send_error(codec::ERR_INVALID_NETWORK_APPEARANCE).await;
1027                return Ok(true);
1028            }
1029        }
1030
1031        let Some(parameter) = message.param(codec::TAG_PROTOCOL_DATA) else {
1032            Log::new(Some(&self.status_tx)).warn(format!(
1033                "M3UA DATA from {} carries no Protocol Data parameter",
1034                self.remote_addr
1035            ));
1036            self.send_error(codec::ERR_MISSING_PARAMETER).await;
1037            return Ok(true);
1038        };
1039        let protocol_data = match codec::ProtocolData::parse(&parameter.value) {
1040            Ok(pd) => pd,
1041            Err(e) => {
1042                Log::new(Some(&self.status_tx)).warn(format!(
1043                    "M3UA DATA from {} has malformed Protocol Data: {}",
1044                    self.remote_addr, e
1045                ));
1046                self.send_error(e.error_code).await;
1047                return Ok(true);
1048            }
1049        };
1050
1051        // SCCP (Service Indicator 3) is decoded through TCAP down to MAP or CAP. Everything that
1052        // follows from the protocol is answered in Rust; a dialogue message the model must
1053        // answer becomes m3ua_map_received / m3ua_cap_received instead of an opaque payload.
1054        let now = crate::utils::clock::Instant::now();
1055        self.expire_dialogues(now).await;
1056        let mut sccp_note = None;
1057        match self
1058            .engine
1059            .on_data(&protocol_data, routing_context, network_appearance, now)
1060        {
1061            ss7::Inbound::NotSccp => {}
1062            ss7::Inbound::Undecoded(reason) => sccp_note = Some(reason),
1063            ss7::Inbound::Handled { replies, note } => {
1064                Log::new(Some(&self.status_tx)).info(format!(
1065                    "M3UA SCCP from {} opc={} handled without the model: {}",
1066                    self.remote_addr, protocol_data.opc, note
1067                ));
1068                for reply in replies {
1069                    self.send_ss7(reply).await;
1070                }
1071                return Ok(true);
1072            }
1073            ss7::Inbound::Event(event) => return Ok(self.on_ss7_event(event).await),
1074        }
1075
1076        let (payload, encoding) = render_payload(&protocol_data.payload);
1077        info!(
1078            "M3UA DATA from {} opc={} dpc={} si={} ({}) sls={}",
1079            self.remote_addr,
1080            protocol_data.opc,
1081            protocol_data.dpc,
1082            protocol_data.si,
1083            codec::si_name(protocol_data.si),
1084            protocol_data.sls
1085        );
1086
1087        let event = Event {
1088            event_type: &M3UA_DATA_EVENT,
1089            data: serde_json::json!({
1090                "connection_id": self.connection_id.to_string(),
1091                "remote_addr": self.remote_addr.to_string(),
1092                "transport": self.config.transport.label(),
1093                "opc": protocol_data.opc,
1094                "dpc": protocol_data.dpc,
1095                "si": protocol_data.si,
1096                "si_name": codec::si_name(protocol_data.si),
1097                "ni": protocol_data.ni,
1098                "mp": protocol_data.mp,
1099                "sls": protocol_data.sls,
1100                "payload": payload,
1101                // Stated, never left to be guessed. "48656c6c6f" is simultaneously valid text
1102                // and valid hex, and only the sender knows which it is.
1103                "encoding": encoding,
1104                "payload_bytes": protocol_data.payload.len(),
1105                "routing_context": routing_context,
1106                "network_appearance": network_appearance,
1107                "correlation_id": message.param_u32(codec::TAG_CORRELATION_ID),
1108                "sccp_note": sccp_note,
1109            }),
1110        };
1111
1112        let outcome = self.call_llm_and_send(&event).await;
1113        match outcome {
1114            HandlerOutcome::Ran { closed, .. } => Ok(!closed),
1115            // Answering nothing would tell the ASP its MSU reached the SS7 network, which is a
1116            // worse lie than a refusal: M3UA has an ERR message, so the peer gets a category
1117            // and the log gets the error. Nothing derived from the error reaches the wire —
1118            // ERR's Diagnostic Information parameter is left empty by `codec::error`.
1119            HandlerOutcome::Failed(failure) => {
1120                Log::new(Some(&self.status_tx)).warn(format!(
1121                    "M3UA m3ua_data_received could not be evaluated for {} \
1122                     (decision=fail_closed_llm_error, {}); answering ERR",
1123                    self.remote_addr,
1124                    failure_category(failure)
1125                ));
1126                self.send_error(codec::ERR_REFUSED_MANAGEMENT_BLOCKING)
1127                    .await;
1128                Ok(true)
1129            }
1130            HandlerOutcome::NoPolicy => {
1131                // Unreachable in practice — with no policy the ASP could never have been
1132                // admitted — but a refusal is still the right answer if it ever is reached.
1133                Log::new(Some(&self.status_tx)).warn(format!(
1134                    "M3UA m3ua_data_received refused for {} (decision=no_policy_configured)",
1135                    self.remote_addr
1136                ));
1137                self.send_error(codec::ERR_REFUSED_MANAGEMENT_BLOCKING)
1138                    .await;
1139                Ok(true)
1140            }
1141        }
1142    }
1143
1144    /// Log what an observational event decided.
1145    ///
1146    /// Nothing is written on any branch beyond what the handler itself produced, and that is
1147    /// correct: M3UA defines no reply to an ASPDN acknowledgement or to a peer's ERR. The three
1148    /// cases are still tagged so an operator can see an outage in one grep.
1149    fn log_observational_outcome(&self, event_id: &str, outcome: HandlerOutcome) {
1150        match outcome {
1151            HandlerOutcome::Failed(failure) => Log::new(Some(&self.status_tx)).warn(format!(
1152                "M3UA {} could not be evaluated for {} (decision=fail_closed_llm_error, {}); \
1153                 nothing sent",
1154                event_id,
1155                self.remote_addr,
1156                failure_category(failure)
1157            )),
1158            HandlerOutcome::NoPolicy => debug!(
1159                "M3UA {} peer={}: no operator policy configured, no LLM call",
1160                event_id, self.remote_addr
1161            ),
1162            HandlerOutcome::Ran { ref sent, .. } if sent.is_empty() => debug!(
1163                "M3UA {} decision=model_silent peer={}: nothing sent",
1164                event_id, self.remote_addr
1165            ),
1166            HandlerOutcome::Ran { .. } => debug!(
1167                "M3UA {} peer={}: handler answered",
1168                event_id, self.remote_addr
1169            ),
1170        }
1171    }
1172
1173    /// Run the handler for `event` and write whatever M3UA messages it produced.
1174    async fn call_llm_and_send(&mut self, event: &Event) -> HandlerOutcome {
1175        if !operator_wants_dynamic(&self.app_state, self.server_id, &event.event_type.id).await {
1176            debug!(
1177                "M3UA {}: no operator policy configured, no LLM call",
1178                event.event_type.id
1179            );
1180            return HandlerOutcome::NoPolicy;
1181        }
1182
1183        let result = match call_llm(
1184            &self.llm_client,
1185            &self.app_state,
1186            self.server_id,
1187            Some(self.connection_id),
1188            event,
1189            &*self.protocol,
1190        )
1191        .await
1192        {
1193            Ok(result) => result,
1194            Err(e) => {
1195                // The whole error goes to the log and the operator's status stream. The peer
1196                // gets a category mapped onto an RFC 4666 error code, and nothing else.
1197                let failure = WireFailure::classify(&e);
1198                error!(
1199                    "M3UA {} decision=fail_closed_llm_error category={:?} peer={}: {:#}",
1200                    event.event_type.id, failure, self.remote_addr, e
1201                );
1202                return HandlerOutcome::Failed(failure);
1203            }
1204        };
1205
1206        let mut sent = Vec::new();
1207        let mut closed = false;
1208        let mut queue: Vec<ActionResult> = result.protocol_results;
1209        queue.reverse();
1210        while let Some(item) = queue.pop() {
1211            match item {
1212                ActionResult::Multiple(inner) => {
1213                    for nested in inner.into_iter().rev() {
1214                        queue.push(nested);
1215                    }
1216                }
1217                ActionResult::Output(bytes) => {
1218                    // Read the class/type back off the octets rather than tracking it beside
1219                    // them: what is on the wire is what the state machine must follow.
1220                    if let Some(kind) = codec::peek_class_type(&bytes) {
1221                        sent.push(kind);
1222                    }
1223                    self.send(bytes).await;
1224                }
1225                ActionResult::CloseConnection => {
1226                    let mut writer = self.writer.lock().await;
1227                    let _ = writer.shutdown().await;
1228                    drop(writer);
1229                    closed = true;
1230                }
1231                ActionResult::Custom { name, data } if name == ss7::SS7_INTENT => {
1232                    let route = self.engine.last_route.clone();
1233                    match self.engine.apply(
1234                        &data,
1235                        route.as_ref(),
1236                        0,
1237                        crate::utils::clock::Instant::now(),
1238                    ) {
1239                        Ok((_, out)) => {
1240                            sent.push((codec::CLASS_TRANSFER, codec::TRANSFER_DATA));
1241                            self.send_ss7(out).await;
1242                        }
1243                        Err(e) => {
1244                            // The dialogue's own fail-closed rule (below, in on_ss7_event)
1245                            // still applies: an intent that could not be honoured answers
1246                            // nothing, so an invoke it was meant to answer stays unanswered.
1247                            Log::new(Some(&self.status_tx)).warn(format!(
1248                                "M3UA {} refused for {}: {} (decision=action_refused)",
1249                                data["type"].as_str().unwrap_or("SS7 action"),
1250                                self.remote_addr,
1251                                e
1252                            ));
1253                        }
1254                    }
1255                }
1256                ActionResult::WaitForMore | ActionResult::NoAction => {}
1257                other => debug!("M3UA ignoring action result {other:?}"),
1258            }
1259        }
1260
1261        HandlerOutcome::Ran { sent, closed }
1262    }
1263
1264    /// Wrap an SS7 message in M3UA DATA and send it.
1265    async fn send_ss7(&self, out: ss7::Outgoing) {
1266        trace!(
1267            "M3UA SS7 {} to {} opc={} dpc={}",
1268            out.label,
1269            self.remote_addr,
1270            out.protocol_data.opc,
1271            out.protocol_data.dpc
1272        );
1273        self.send_encoded(
1274            &out.label,
1275            codec::data(
1276                &out.protocol_data,
1277                out.network_appearance,
1278                out.routing_context,
1279                None,
1280            ),
1281        )
1282        .await;
1283    }
1284
1285    /// Abort and forget dialogues that have been idle past `ss7::DIALOGUE_IDLE_TIMEOUT`.
1286    async fn expire_dialogues(&mut self, now: crate::utils::clock::Instant) {
1287        for (dialogue, abort) in self.engine.expire(now) {
1288            Log::new(Some(&self.status_tx)).info(format!(
1289                "M3UA dialogue {} with {} idle for {}s; aborted (decision=dialogue_idle_timeout)",
1290                dialogue.id,
1291                self.remote_addr,
1292                ss7::DIALOGUE_IDLE_TIMEOUT.as_secs()
1293            ));
1294            if let Some(out) = abort {
1295                self.send_ss7(out).await;
1296            }
1297        }
1298    }
1299
1300    /// A decoded MAP or CAP dialogue message: ask the model, then hold it to an answer.
1301    ///
1302    /// Returns whether the association stays open.
1303    async fn on_ss7_event(&mut self, ev: ss7::Ss7Event) -> bool {
1304        for reply in ev.replies {
1305            self.send_ss7(reply).await;
1306        }
1307        let event_type: &'static crate::protocol::EventType = match ev.family {
1308            ss7::Family::Map => &ss7_actions::SGP_MAP_EVENT,
1309            ss7::Family::Cap => &ss7_actions::SGP_CAP_EVENT,
1310        };
1311        let mut data = ev.data;
1312        data["connection_id"] = serde_json::json!(self.connection_id.to_string());
1313        data["remote_addr"] = serde_json::json!(self.remote_addr.to_string());
1314        data["transport"] = serde_json::json!(self.config.transport.label());
1315        Log::new(Some(&self.status_tx)).info(format!(
1316            "M3UA {} TC-{} dialogue {} from {}",
1317            ev.family.name().to_uppercase(),
1318            ev.kind.name().to_uppercase(),
1319            ev.dialogue_id,
1320            self.remote_addr
1321        ));
1322        let event = Event { event_type, data };
1323        let outcome = self.call_llm_and_send(&event).await;
1324        let closed = matches!(outcome, HandlerOutcome::Ran { closed: true, .. });
1325        if ev.ended || !self.engine.awaiting_answer(ev.dialogue_id) {
1326            if ev.ended {
1327                self.log_observational_outcome(event_type.id.as_str(), outcome);
1328            }
1329            return !closed;
1330        }
1331        // The dialogue still owes the peer an answer. Leaving it open would have the far end's
1332        // invoke timer decide; answering something here is the honest alternative, and what is
1333        // answered depends on why there was no answer.
1334        let (fail_closed, decision) = match &outcome {
1335            HandlerOutcome::Ran { .. } => (false, "model_silent".to_string()),
1336            HandlerOutcome::Failed(f) => (
1337                true,
1338                format!("fail_closed_llm_error, {}", failure_category(*f)),
1339            ),
1340            HandlerOutcome::NoPolicy => (true, "no_policy_configured".to_string()),
1341        };
1342        if let Some(out) = self.engine.close_unanswered(ev.dialogue_id, fail_closed) {
1343            Log::new(Some(&self.status_tx)).warn(format!(
1344                "M3UA {} dialogue {} from {} left unanswered (decision={}); sending {}",
1345                ev.family.name().to_uppercase(),
1346                ev.dialogue_id,
1347                self.remote_addr,
1348                decision,
1349                out.label
1350            ));
1351            self.send_ss7(out).await;
1352        }
1353        !closed
1354    }
1355
1356    async fn send(&self, bytes: Vec<u8>) {
1357        let written = {
1358            let mut writer = self.writer.lock().await;
1359            match writer.write_all(&bytes).await {
1360                Ok(()) => writer.flush().await.is_ok(),
1361                Err(_) => false,
1362            }
1363        };
1364        if !written {
1365            debug!(
1366                "M3UA write failed on association {}; peer has gone",
1367                self.connection_id
1368            );
1369            return;
1370        }
1371        self.app_state
1372            .update_connection_stats(
1373                self.server_id,
1374                self.connection_id,
1375                None,
1376                Some(bytes.len() as u64),
1377                None,
1378                Some(1),
1379            )
1380            .await;
1381    }
1382
1383    /// Send a message the codec has just built, or log why it would not build one.
1384    ///
1385    /// The encoders refuse anything `parse_header` would reject at the far end, so a refusal
1386    /// here means the message could not have been understood — sending a truncated or
1387    /// wrong-length version of it would be strictly worse than sending nothing. Tagged
1388    /// `decision=fail_closed_encode` so it is distinguishable from the model's own silence,
1389    /// which is the rule the rest of this protocol follows.
1390    async fn send_encoded(&self, what: &str, encoded: Result<Vec<u8>, codec::WireError>) {
1391        match encoded {
1392            Ok(bytes) => self.send(bytes).await,
1393            Err(e) => {
1394                error!(
1395                    "M3UA decision=fail_closed_encode: refusing to send {} to {}: {}",
1396                    what, self.remote_addr, e
1397                );
1398                Log::new(Some(&self.status_tx)).warn(format!(
1399                    "M3UA could not encode {what} for {}",
1400                    self.remote_addr
1401                ));
1402            }
1403        }
1404    }
1405
1406    async fn send_error(&self, error_code: u32) {
1407        Log::new(Some(&self.status_tx)).warn(format!(
1408            "M3UA ERR 0x{:02x} ({}) to {}",
1409            error_code,
1410            codec::error_code_name(error_code),
1411            self.remote_addr
1412        ));
1413        self.send_encoded("ERR", codec::error(error_code, self.config.routing_context))
1414            .await;
1415    }
1416
1417    async fn set_connection_state(&self) {
1418        let server_id = self.server_id;
1419        let connection_id = self.connection_id;
1420        let value = serde_json::json!({
1421            "asp_state": self.state.as_str(),
1422            "transport": self.config.transport.label(),
1423        });
1424        self.app_state
1425            .with_server_mut(server_id, |server| {
1426                if let Some(connection) = server.connections.get_mut(&connection_id) {
1427                    connection.protocol_info =
1428                        crate::state::server::ProtocolConnectionInfo::new(value.clone());
1429                }
1430            })
1431            .await;
1432    }
1433}
1434
1435/// What a handler's actions amounted to.
1436#[derive(Debug, Clone)]
1437enum HandlerOutcome {
1438    /// No operator policy exists for this event, so nothing was consulted. Distinct from a
1439    /// policy that ran and said nothing: nobody has answered the question at all.
1440    NoPolicy,
1441    /// The handler could not be run — the backend errored, timed out or was saturated. Distinct
1442    /// from silence on purpose: a caller that treats silence as an answer must not treat this
1443    /// the same way.
1444    Failed(WireFailure),
1445    /// The handler ran. `sent` is the (class, type) of every message it put on the wire, in
1446    /// order; empty means it chose to say nothing.
1447    Ran { sent: Vec<(u8, u8)>, closed: bool },
1448}
1449
1450fn failure_category(failure: WireFailure) -> &'static str {
1451    // M3UA's error code registry (RFC 4666 section 3.8.1) has no resource-exhaustion code, so
1452    // both categories map onto Refused - Management Blocking on the wire. The distinction the
1453    // wire cannot carry is kept in the log, which is the rule the root CLAUDE.md states for
1454    // exactly this case.
1455    if failure.is_overloaded() {
1456        "resource"
1457    } else {
1458        "unavailable"
1459    }
1460}
1461
1462fn traffic_mode_name(mode: u32) -> &'static str {
1463    match mode {
1464        codec::TRAFFIC_MODE_OVERRIDE => "override",
1465        codec::TRAFFIC_MODE_LOADSHARE => "loadshare",
1466        codec::TRAFFIC_MODE_BROADCAST => "broadcast",
1467        _ => "unassigned",
1468    }
1469}
1470
1471/// Render an SS7 user part for the model, declaring how it was rendered.
1472///
1473/// Text payloads are common in a lab and unheard-of in a real network, so both are supported —
1474/// but the choice is reported in the event's `encoding` field rather than left to be inferred,
1475/// and `send_m3ua_data` takes the same field on the way out. That symmetry is the point: an
1476/// echo handler can feed the event's `payload` and `encoding` straight back.
1477fn render_payload(payload: &[u8]) -> (String, &'static str) {
1478    let printable = std::str::from_utf8(payload).ok().filter(|s| {
1479        s.chars()
1480            .all(|c| !c.is_control() || c == '\n' || c == '\r' || c == '\t')
1481    });
1482    match printable {
1483        Some(text) if !payload.is_empty() => (text.to_string(), "utf8"),
1484        _ => (hex::encode(payload), "hex"),
1485    }
1486}
1487
1488/// True when the operator opted into dynamic handling — a non-empty server instruction, or an
1489/// event handler matching this event.
1490///
1491/// M3UA treats `false` as a refusal rather than as a static default, unlike BGP: an ASP coming
1492/// up is an admission into a signalling network, and there is no configuration-free answer to
1493/// "who may join" that is not a guess.
1494async fn operator_wants_dynamic(state: &AppState, server_id: ServerId, event_id: &str) -> bool {
1495    state
1496        .with_server_mut(server_id, |server| {
1497            let has_instruction = !server.instruction.trim().is_empty();
1498            let has_handler = server
1499                .event_handler_config
1500                .as_ref()
1501                .map(|c| c.find_handler(event_id).is_some())
1502                .unwrap_or(false);
1503            has_instruction || has_handler
1504        })
1505        .await
1506        .unwrap_or(false)
1507}
1508
1509/// Read one M3UA message: the 8-octet common header, then exactly the declared body.
1510///
1511/// Nothing is allocated before the length has been validated, so a peer cannot choose the
1512/// buffer size, and `length - HEADER_LEN` cannot underflow.
1513///
1514/// The second element is the number of octets consumed, for the connection's inbound counters.
1515pub async fn read_message<R: AsyncRead + Unpin>(
1516    reader: &mut R,
1517    read_timeout: std::time::Duration,
1518) -> (Incoming, usize) {
1519    let mut header = [0u8; codec::HEADER_LEN];
1520    let header_read = match tokio::time::timeout(read_timeout, reader.read_exact(&mut header)).await
1521    {
1522        Ok(read) => read,
1523        Err(_) => {
1524            debug!(
1525                "M3UA peer sent nothing for {}s; closing idle association",
1526                read_timeout.as_secs()
1527            );
1528            return (Incoming::Eof, 0);
1529        }
1530    };
1531    match header_read {
1532        Ok(_) => {}
1533        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return (Incoming::Eof, 0),
1534        Err(e) => {
1535            debug!("M3UA read error: {e}");
1536            return (Incoming::Eof, 0);
1537        }
1538    }
1539
1540    let parsed = match codec::parse_header(&header) {
1541        Ok(h) => h,
1542        Err(e) => return (Incoming::HeaderError(e), codec::HEADER_LEN),
1543    };
1544
1545    let total = parsed.length as usize;
1546    let mut full = vec![0u8; total];
1547    full[..codec::HEADER_LEN].copy_from_slice(&header);
1548    if total > codec::HEADER_LEN {
1549        match tokio::time::timeout(
1550            read_timeout,
1551            reader.read_exact(&mut full[codec::HEADER_LEN..]),
1552        )
1553        .await
1554        {
1555            Ok(Ok(_)) => {}
1556            Ok(Err(e)) => {
1557                debug!("M3UA truncated message body: {e}");
1558                return (Incoming::Eof, codec::HEADER_LEN);
1559            }
1560            Err(_) => {
1561                debug!(
1562                    "M3UA peer announced {total} octets and stalled for {}s without \
1563                     delivering them; closing",
1564                    read_timeout.as_secs()
1565                );
1566                return (Incoming::Eof, codec::HEADER_LEN);
1567            }
1568        }
1569    }
1570
1571    // A sender that excludes the final parameter's padding from the Message Length still writes
1572    // those octets (RFC 4666 section 3.2), so consume them or the next header starts mid-word.
1573    // NetGet's own messages always have zero slack.
1574    let slack = codec::alignment_slack(parsed.length);
1575    if slack > 0 {
1576        let mut padding = [0u8; 3];
1577        match tokio::time::timeout(read_timeout, reader.read_exact(&mut padding[..slack])).await {
1578            Ok(Ok(_)) => {}
1579            _ => return (Incoming::Eof, total),
1580        }
1581    }
1582
1583    let consumed = total + slack;
1584    match codec::Message::parse(&full) {
1585        Ok(message) => (Incoming::Message(message), consumed),
1586        Err(e) => (Incoming::BodyError(e), consumed),
1587    }
1588}