Skip to main content

netget/server/ngap/
mod.rs

1//! NGAP (3GPP TS 38.413) — NetGet as an **AMF** on the 5G N2 interface, which a gNB connects to.
2//!
3//! # Division of labour
4//!
5//! Rust owns everything that is not a decision: the SCTP association (PPID 60, stream 0 for
6//! non-UE-associated signalling and stream 1 for UE-associated), ALIGNED PER encoding and
7//! decoding, procedure codes, IE structure and constraints, AMF UE NGAP ID allocation, the UE
8//! context table, and every refusal that follows from the protocol rather than from policy — a
9//! PDU that does not decode, a message before NG Setup, an unknown UE id, an NG Reset (answered
10//! mechanically: resetting is the safe direction).
11//!
12//! The model owns the decisions an AMF actually makes: **whether this gNB may set up N2** (NG
13//! Setup Response with the AMF's name, GUAMIs and supported slices, or NG Setup Failure with a
14//! cause), and **what to say to each UE** — an Identity Request, an Authentication Request, a
15//! Registration Reject with a 5GMM cause, a UE Context Release. NAS is decoded into structure for
16//! it (see [`nas`]) and its answers are encoded by Rust.
17//!
18//! # Fail closed
19//!
20//! An NG Setup Request is an admission into a 5G core and a Registration Request an admission
21//! into a network, so neither has a default answer. Every path that is not an explicit answer is
22//! a refusal, tagged apart in the log:
23//!
24//! | Situation | NG Setup | Initial UE Message |
25//! |---|---|---|
26//! | model answered | its Response or Failure | its NAS / release |
27//! | model refused explicitly | its Failure (`decision=model_reject`) | its Reject |
28//! | model said nothing | Failure misc/unspecified (`decision=model_silent`) | Registration/Service Reject #111, then release |
29//! | model could not be run | Failure misc/control-processing-overload + Time to Wait, or misc/unspecified (`decision=fail_closed_llm_error`) | Reject #22 (congestion) or #111, then release |
30//! | no policy configured | Failure misc/om-intervention (`decision=no_policy_configured`) | Reject #111, then release |
31//!
32//! # Transport
33//!
34//! See [`transport`]: SCTP by default and refused by name where there is none; `transport: "tcp"`
35//! is a non-standard, length-prefixed lab framing labelled as such everywhere it appears.
36
37pub mod actions;
38pub mod aper;
39pub mod codec;
40pub mod nas;
41pub mod transport;
42
43use anyhow::Result;
44use serde_json::{json, Value};
45use std::collections::BTreeMap;
46use std::net::SocketAddr;
47use std::sync::Arc;
48use tokio::sync::{mpsc, Mutex};
49use tracing::{debug, error, info, warn};
50
51use crate::llm::action_helper::call_llm;
52use crate::llm::actions::protocol_trait::ActionResult;
53use crate::llm::ollama_client::OllamaClient;
54use crate::logging::emit::Log;
55use crate::protocol::Event;
56use crate::server::connection::ConnectionId;
57use crate::state::app_state::AppState;
58use crate::state::ServerId;
59use crate::utils::WireFailure;
60
61use actions::{
62    NgapProtocol, NGAP_ERROR_INDICATION_EVENT, NGAP_INITIAL_UE_MESSAGE_EVENT, NGAP_NG_SETUP_EVENT,
63    NGAP_UPLINK_NAS_EVENT,
64};
65use codec::{Cause, PduKind};
66use transport::{MessageWriter, ReadOutcome, Transport};
67
68/// How long a new association may stay silent. TS 38.413 section 8.7.1 has the gNB send NG Setup
69/// Request as soon as the TNL association is up; nothing legitimate precedes it.
70const FIRST_MESSAGE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
71
72/// How long an established association may stay silent. An hour, deliberately: N2 has no
73/// application keepalive and an idle cell with no UEs is the normal case, so this must not be a
74/// traffic timer. Over SCTP the kernel's own HEARTBEAT detects a dead peer long before this; over
75/// the lab TCP framing nothing else would.
76const IDLE_BETWEEN_MESSAGES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
77
78/// Concurrent associations admitted.
79const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
80
81/// What an association over [`MAX_CONNECTIONS`] is told: nothing. NGAP's only message a node may
82/// send before NG Setup is Error Indication, and over SCTP it would need PPID 60 and a stream the
83/// generic refusal path cannot set; over the lab transport it would need the length prefix. The
84/// association is closed (an SCTP SHUTDOWN), which a gNB treats as a TNL failure and retries.
85const CONNECTION_CAP_REFUSAL: &[u8] = &[];
86
87/// UE contexts one association may hold. A gNB serving more is refused per UE with an Error
88/// Indication (misc/control-processing-overload) rather than growing the table without bound.
89pub const MAX_UE_CONTEXTS: usize = 4096;
90
91/// NGAP AMF server.
92pub struct NgapServer;
93
94impl NgapServer {
95    /// Spawn the AMF listener. Awaits readiness, so "this host has no SCTP" is a startup error.
96    pub async fn spawn_with_llm_actions(
97        listen_addr: SocketAddr,
98        llm_client: OllamaClient,
99        app_state: Arc<AppState>,
100        status_tx: mpsc::UnboundedSender<String>,
101        server_id: ServerId,
102        startup_params: Option<crate::protocol::StartupParams>,
103    ) -> Result<SocketAddr> {
104        let transport = Transport::from_params(startup_params.as_ref())?;
105        let listener = transport::bind_listener(listen_addr, transport).await?;
106        let local_addr = listener.local_addr()?;
107        Log::new(Some(&status_tx)).info(format!(
108            "NGAP AMF listening on {} over {}",
109            local_addr,
110            transport.label()
111        ));
112        if transport == Transport::Tcp {
113            Log::new(Some(&status_tx)).warn(
114                "NGAP is running over TCP with a NetGet length prefix. This is NOT N2: TS 38.412 \
115                 defines NGAP over SCTP only, and no real gNB will connect. Use it for local \
116                 protocol work and nothing else.",
117            );
118        }
119
120        let protocol = Arc::new(NgapProtocol::new());
121        let task_registrar = app_state.clone();
122        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
123        let accept_handle = tokio::spawn(async move {
124            loop {
125                match crate::server::accept_bounded::accept_bounded(
126                    &listener,
127                    &limiter,
128                    CONNECTION_CAP_REFUSAL,
129                    "NGAP",
130                    Some(&status_tx),
131                )
132                .await
133                {
134                    Ok((stream, remote_addr, permit)) => {
135                        let connection_id =
136                            ConnectionId::new(app_state.get_next_unified_id().await);
137                        Log::new(Some(&status_tx)).info(format!(
138                            "NGAP association {} from {} ({})",
139                            connection_id,
140                            remote_addr,
141                            transport.as_str()
142                        ));
143                        register_connection(
144                            &app_state,
145                            server_id,
146                            connection_id,
147                            remote_addr,
148                            transport,
149                        )
150                        .await;
151                        let _ = status_tx.send("__UPDATE_UI__".to_string());
152                        let conn = transport::Conn::new(stream, transport);
153                        let llm = llm_client.clone();
154                        let state = app_state.clone();
155                        let status = status_tx.clone();
156                        let protocol = protocol.clone();
157                        let owner = app_state.clone();
158                        owner
159                            .spawn_server_task(server_id, async move {
160                                let _permit = permit;
161                                if let Err(e) = run_session(
162                                    conn,
163                                    connection_id,
164                                    server_id,
165                                    remote_addr,
166                                    llm,
167                                    state.clone(),
168                                    status.clone(),
169                                    protocol,
170                                    transport,
171                                )
172                                .await
173                                {
174                                    Log::new(Some(&status))
175                                        .error(format!("NGAP association error: {e}"));
176                                }
177                                state
178                                    .remove_peer_handle(server_id, connection_id.as_u32())
179                                    .await;
180                                state
181                                    .close_connection_on_server(server_id, connection_id)
182                                    .await;
183                                Log::new(Some(&status))
184                                    .info(format!("NGAP association {connection_id} closed"));
185                                let _ = status.send("__UPDATE_UI__".to_string());
186                            })
187                            .await;
188                    }
189                    Err(e) => {
190                        Log::new(Some(&status_tx))
191                            .error(format!("Failed to accept NGAP association: {e}"));
192                        break;
193                    }
194                }
195            }
196        });
197        task_registrar
198            .register_server_task(server_id, accept_handle)
199            .await;
200        Ok(local_addr)
201    }
202}
203
204async fn register_connection(
205    app_state: &AppState,
206    server_id: ServerId,
207    connection_id: ConnectionId,
208    remote_addr: SocketAddr,
209    transport: Transport,
210) {
211    use crate::state::server::{ConnectionState, ConnectionStatus, ProtocolConnectionInfo};
212    let now = crate::utils::clock::Instant::now();
213    app_state
214        .add_connection_to_server(
215            server_id,
216            ConnectionState {
217                id: connection_id,
218                remote_addr,
219                local_addr: remote_addr,
220                bytes_sent: 0,
221                bytes_received: 0,
222                packets_sent: 0,
223                packets_received: 0,
224                last_activity: now,
225                status: ConnectionStatus::Active,
226                status_changed_at: now,
227                protocol_info: ProtocolConnectionInfo::new(json!({
228                    "state": "awaiting NG Setup",
229                    "transport": transport.label(),
230                })),
231            },
232        )
233        .await;
234}
235
236/// One UE's context on this association.
237struct Ue {
238    ran_ue_ngap_id: u64,
239    /// The 5GMM message type the UE opened with, so a fail-closed answer can be the matching
240    /// reject.
241    initial_nas: Option<u8>,
242}
243
244struct Session {
245    connection_id: ConnectionId,
246    server_id: ServerId,
247    remote_addr: SocketAddr,
248    llm_client: OllamaClient,
249    app_state: Arc<AppState>,
250    status_tx: mpsc::UnboundedSender<String>,
251    protocol: Arc<NgapProtocol>,
252    transport: Transport,
253    writer: Arc<Mutex<MessageWriter>>,
254    /// The gNB as it presented itself, once NG Setup succeeded.
255    gnb: Option<Value>,
256    ues: BTreeMap<u64, Ue>,
257    next_amf_ue_ngap_id: u64,
258}
259
260/// One PDU a handler put on the wire, read back off its own octets.
261#[derive(Debug, Clone, PartialEq, Eq)]
262struct Sent {
263    kind: PduKind,
264    code: u8,
265    amf_ue_ngap_id: Option<u64>,
266}
267
268#[derive(Debug, Clone)]
269enum HandlerOutcome {
270    NoPolicy,
271    Failed(WireFailure),
272    Ran { sent: Vec<Sent>, closed: bool },
273}
274
275#[allow(clippy::too_many_arguments)]
276async fn run_session(
277    conn: transport::Conn,
278    connection_id: ConnectionId,
279    server_id: ServerId,
280    remote_addr: SocketAddr,
281    llm_client: OllamaClient,
282    app_state: Arc<AppState>,
283    status_tx: mpsc::UnboundedSender<String>,
284    protocol: Arc<NgapProtocol>,
285    transport: Transport,
286) -> Result<()> {
287    let mut reader = conn.reader();
288    let writer = Arc::new(Mutex::new(conn.writer()));
289    let peer_rx = crate::server::peer_support::register_peer_channel(
290        &app_state,
291        server_id,
292        connection_id.as_u32(),
293    )
294    .await;
295    crate::server::peer_support::spawn_peer_command_task(
296        peer_rx,
297        protocol.clone(),
298        app_state.clone(),
299        server_id,
300        connection_id.as_u32(),
301        writer.clone(),
302        status_tx.clone(),
303    );
304    let mut session = Session {
305        connection_id,
306        server_id,
307        remote_addr,
308        llm_client,
309        app_state,
310        status_tx,
311        protocol,
312        transport,
313        writer,
314        gnb: None,
315        ues: BTreeMap::new(),
316        next_amf_ue_ngap_id: 1,
317    };
318
319    let mut received_one = false;
320    loop {
321        // The deadline covers the read only; the model round-trip (and a manual rule parking
322        // a decision for a human) happens after a whole message has arrived.
323        let deadline = if received_one {
324            IDLE_BETWEEN_MESSAGES_TIMEOUT
325        } else {
326            FIRST_MESSAGE_READ_TIMEOUT
327        };
328        match reader.next(deadline).await {
329            ReadOutcome::Message(bytes) => {
330                received_one = true;
331                session
332                    .app_state
333                    .update_connection_stats(
334                        server_id,
335                        connection_id,
336                        Some(bytes.len() as u64),
337                        None,
338                        Some(1),
339                        None,
340                    )
341                    .await;
342                if !session.dispatch(&bytes).await {
343                    break;
344                }
345            }
346            ReadOutcome::TooLarge(n) => {
347                received_one = true;
348                Log::new(Some(&session.status_tx)).warn(format!(
349                    "NGAP message of {n} octets from {} exceeds {} (decision=refused_oversize)",
350                    remote_addr,
351                    codec::MAX_PDU_LEN
352                ));
353                session
354                    .send_error_indication(
355                        None,
356                        None,
357                        Cause::protocol(codec::PROTOCOL_TRANSFER_SYNTAX_ERROR),
358                    )
359                    .await;
360                if transport == Transport::Tcp {
361                    // The lab framing cannot skip a declared length safely.
362                    break;
363                }
364            }
365            ReadOutcome::TimedOut => {
366                debug!("NGAP association {connection_id} idle past its deadline; closing");
367                break;
368            }
369            ReadOutcome::Eof => break,
370            ReadOutcome::Error(e) => {
371                debug!("NGAP association {connection_id} read error: {e}");
372                break;
373            }
374        }
375    }
376    session.writer.lock().await.close();
377    Ok(())
378}
379
380impl Session {
381    /// Handle one PDU. Returns `false` when the association must close.
382    async fn dispatch(&mut self, bytes: &[u8]) -> bool {
383        let pdu = match codec::decode_pdu(bytes) {
384            Ok(p) => p,
385            Err(e) => {
386                // Refused before the model: 38.413 section 10.2, transfer syntax error.
387                Log::new(Some(&self.status_tx)).warn(format!(
388                    "NGAP PDU from {} refused: {e} (decision=refused_malformed)",
389                    self.remote_addr
390                ));
391                self.send_error_indication(None, None, e.cause).await;
392                return true;
393            }
394        };
395        debug!("NGAP {} from {}", pdu.label(), self.remote_addr);
396        match (pdu.kind, pdu.procedure_code) {
397            (PduKind::Initiating, codec::PROC_NG_SETUP) => self.on_ng_setup(&pdu).await,
398            (PduKind::Initiating, codec::PROC_ERROR_INDICATION) => {
399                self.on_error_indication(&pdu).await
400            }
401            (PduKind::Initiating, _) if self.gnb.is_none() => {
402                Log::new(Some(&self.status_tx)).warn(format!(
403                    "NGAP {} from {} before NG Setup completed; refusing",
404                    pdu.label(),
405                    self.remote_addr
406                ));
407                self.send_error_indication(
408                    None,
409                    None,
410                    Cause::protocol(codec::PROTOCOL_MESSAGE_NOT_COMPATIBLE_WITH_RECEIVER_STATE),
411                )
412                .await;
413                true
414            }
415            (PduKind::Initiating, codec::PROC_INITIAL_UE_MESSAGE) => self.on_initial_ue(&pdu).await,
416            (PduKind::Initiating, codec::PROC_UPLINK_NAS_TRANSPORT) => {
417                self.on_uplink_nas(&pdu).await
418            }
419            (PduKind::Initiating, codec::PROC_NG_RESET) => self.on_ng_reset(&pdu).await,
420            (PduKind::Successful, codec::PROC_UE_CONTEXT_RELEASE) => {
421                self.on_release_complete(&pdu).await
422            }
423            (PduKind::Successful, codec::PROC_NG_RESET) => {
424                let n = self.ues.len();
425                self.ues.clear();
426                self.set_connection_state().await;
427                info!(
428                    "NGAP NG Reset acknowledged by {}; {n} UE contexts cleared",
429                    self.remote_addr
430                );
431                true
432            }
433            (PduKind::Initiating, _) => {
434                // 38.413 section 10.3.4.1: a procedure this AMF does not implement. A reject or
435                // notify criticality earns an Error Indication; ignore earns nothing.
436                let cause = match pdu.criticality {
437                    codec::Criticality::Reject => {
438                        Some(codec::PROTOCOL_ABSTRACT_SYNTAX_ERROR_REJECT)
439                    }
440                    codec::Criticality::Notify => Some(2),
441                    codec::Criticality::Ignore => None,
442                };
443                Log::new(Some(&self.status_tx)).warn(format!(
444                    "NGAP {} from {} is not implemented by NetGet's AMF",
445                    pdu.label(),
446                    self.remote_addr
447                ));
448                if let Some(c) = cause {
449                    self.send_error_indication(None, None, Cause::protocol(c))
450                        .await;
451                }
452                true
453            }
454            _ => {
455                debug!(
456                    "NGAP {} from {} answers nothing NetGet asked; ignored",
457                    pdu.label(),
458                    self.remote_addr
459                );
460                true
461            }
462        }
463    }
464
465    // ------------------------------------------------------------------------
466
467    async fn on_ng_setup(&mut self, pdu: &codec::Pdu) -> bool {
468        let info = match codec::parse_ng_setup_request(pdu) {
469            Ok(v) => v,
470            Err(e) => {
471                Log::new(Some(&self.status_tx)).warn(format!(
472                    "NGAP NG Setup Request from {} refused before the model: {e}",
473                    self.remote_addr
474                ));
475                self.send_encoded("NG Setup Failure", codec::ng_setup_failure(&e.cause, None))
476                    .await;
477                return true;
478            }
479        };
480        let mut data = info.clone();
481        data["transport"] = json!(self.transport.label());
482        let event = Event::new(&NGAP_NG_SETUP_EVENT, data);
483        let event_id = event.event_type.id.clone();
484        match self.call_llm_and_send(&event).await {
485            HandlerOutcome::Ran { ref sent, closed }
486                if sent
487                    .iter()
488                    .any(|s| s.kind == PduKind::Successful && s.code == codec::PROC_NG_SETUP) =>
489            {
490                self.gnb = Some(json!({
491                    "global_ran_node_id": info["global_ran_node_id"],
492                    "ran_node_name": info["ran_node_name"],
493                }));
494                self.set_connection_state().await;
495                Log::new(Some(&self.status_tx)).info(format!(
496                    "NGAP NG Setup accepted for {} ({})",
497                    self.remote_addr,
498                    info["ran_node_name"].as_str().unwrap_or("unnamed gNB")
499                ));
500                !closed
501            }
502            HandlerOutcome::Ran { ref sent, closed }
503                if sent
504                    .iter()
505                    .any(|s| s.kind == PduKind::Unsuccessful && s.code == codec::PROC_NG_SETUP) =>
506            {
507                info!(
508                    "NGAP {event_id} decision=model_reject peer={}",
509                    self.remote_addr
510                );
511                !closed
512            }
513            HandlerOutcome::Ran { closed, .. } => {
514                Log::new(Some(&self.status_tx)).warn(format!(
515                    "NGAP {event_id} produced no NG Setup answer for {} (decision=model_silent); \
516                     refusing with misc/unspecified",
517                    self.remote_addr
518                ));
519                self.send_encoded(
520                    "NG Setup Failure",
521                    codec::ng_setup_failure(&Cause::misc(codec::MISC_UNSPECIFIED), None),
522                )
523                .await;
524                !closed
525            }
526            HandlerOutcome::Failed(failure) => {
527                let (cause, wait) = if failure.is_overloaded() {
528                    (Cause::misc(codec::MISC_CONTROL_PROCESSING_OVERLOAD), "v5s")
529                } else {
530                    (Cause::misc(codec::MISC_UNSPECIFIED), "v10s")
531                };
532                Log::new(Some(&self.status_tx)).warn(format!(
533                    "NGAP {event_id} could not be evaluated for {} \
534                     (decision=fail_closed_llm_error, {}); refusing with {}",
535                    self.remote_addr,
536                    failure_category(failure),
537                    cause.label()
538                ));
539                self.send_encoded(
540                    "NG Setup Failure",
541                    codec::ng_setup_failure(&cause, Some(wait)),
542                )
543                .await;
544                true
545            }
546            HandlerOutcome::NoPolicy => {
547                Log::new(Some(&self.status_tx)).warn(format!(
548                    "NGAP {event_id} refused for {} (decision=no_policy_configured): this AMF has \
549                     no instruction and no event handler, so nothing decides which gNBs may set \
550                     up N2. Give the server an instruction, or a handler for {event_id}.",
551                    self.remote_addr
552                ));
553                self.send_encoded(
554                    "NG Setup Failure",
555                    codec::ng_setup_failure(&Cause::misc(codec::MISC_OM_INTERVENTION), None),
556                )
557                .await;
558                true
559            }
560        }
561    }
562
563    async fn on_initial_ue(&mut self, pdu: &codec::Pdu) -> bool {
564        let ue = match codec::parse_initial_ue_message(pdu) {
565            Ok(u) => u,
566            Err(e) => {
567                let ran = pdu
568                    .ie(codec::IE_RAN_UE_NGAP_ID)
569                    .and_then(|ie| codec::decode_ran_ue_ngap_id(ie).ok());
570                Log::new(Some(&self.status_tx)).warn(format!(
571                    "NGAP Initial UE Message from {} refused before the model: {e}",
572                    self.remote_addr
573                ));
574                self.send_error_indication(None, ran, e.cause).await;
575                return true;
576            }
577        };
578        if self.ues.len() >= MAX_UE_CONTEXTS {
579            Log::new(Some(&self.status_tx)).warn(format!(
580                "NGAP Initial UE Message from {} refused: {MAX_UE_CONTEXTS} UE contexts already \
581                 held on this association (decision=fail_closed_ue_cap)",
582                self.remote_addr
583            ));
584            self.send_error_indication(
585                None,
586                Some(ue.ran_ue_ngap_id),
587                Cause::misc(codec::MISC_CONTROL_PROCESSING_OVERLOAD),
588            )
589            .await;
590            return true;
591        }
592        let amf_id = self.next_amf_ue_ngap_id;
593        self.next_amf_ue_ngap_id = (self.next_amf_ue_ngap_id + 1) % (codec::MAX_AMF_UE_NGAP_ID + 1);
594        let initial_nas = nas::message_type(&ue.nas);
595        self.ues.insert(
596            amf_id,
597            Ue {
598                ran_ue_ngap_id: ue.ran_ue_ngap_id,
599                initial_nas,
600            },
601        );
602        self.set_connection_state().await;
603        let data = json!({
604            "amf_ue_ngap_id": amf_id,
605            "ran_ue_ngap_id": ue.ran_ue_ngap_id,
606            "nas": nas::decode(&ue.nas),
607            "user_location": ue.user_location,
608            "rrc_establishment_cause": ue.rrc_establishment_cause,
609            "five_g_s_tmsi": ue.five_g_s_tmsi,
610            "uninterpreted_ies": ue.uninterpreted,
611            "gnb": self.gnb.clone(),
612            "transport": self.transport.label(),
613        });
614        let event = Event::new(&NGAP_INITIAL_UE_MESSAGE_EVENT, data);
615        let outcome = self.call_llm_and_send(&event).await;
616        self.settle_ue_outcome(&event.event_type.id, amf_id, outcome, true)
617            .await
618    }
619
620    async fn on_uplink_nas(&mut self, pdu: &codec::Pdu) -> bool {
621        let t = match codec::parse_nas_transport(pdu) {
622            Ok(t) => t,
623            Err(e) => {
624                Log::new(Some(&self.status_tx)).warn(format!(
625                    "NGAP Uplink NAS Transport from {} refused before the model: {e}",
626                    self.remote_addr
627                ));
628                self.send_error_indication(None, None, e.cause).await;
629                return true;
630            }
631        };
632        match self.ues.get(&t.amf_ue_ngap_id) {
633            None => {
634                Log::new(Some(&self.status_tx)).warn(format!(
635                    "NGAP Uplink NAS Transport from {} names unknown AMF UE NGAP ID {}",
636                    self.remote_addr, t.amf_ue_ngap_id
637                ));
638                self.send_error_indication(
639                    Some(t.amf_ue_ngap_id),
640                    Some(t.ran_ue_ngap_id),
641                    Cause::radio(codec::RADIO_UNKNOWN_LOCAL_UE_NGAP_ID),
642                )
643                .await;
644                return true;
645            }
646            Some(ue) if ue.ran_ue_ngap_id != t.ran_ue_ngap_id => {
647                Log::new(Some(&self.status_tx)).warn(format!(
648                    "NGAP Uplink NAS Transport from {} pairs AMF UE NGAP ID {} with RAN UE NGAP \
649                     ID {} (the context holds {})",
650                    self.remote_addr, t.amf_ue_ngap_id, t.ran_ue_ngap_id, ue.ran_ue_ngap_id
651                ));
652                self.send_error_indication(
653                    Some(t.amf_ue_ngap_id),
654                    Some(t.ran_ue_ngap_id),
655                    Cause::radio(codec::RADIO_INCONSISTENT_REMOTE_UE_NGAP_ID),
656                )
657                .await;
658                return true;
659            }
660            Some(_) => {}
661        }
662        let data = json!({
663            "amf_ue_ngap_id": t.amf_ue_ngap_id,
664            "ran_ue_ngap_id": t.ran_ue_ngap_id,
665            "nas": nas::decode(&t.nas),
666            "user_location": t.user_location,
667            "uninterpreted_ies": t.uninterpreted,
668            "transport": self.transport.label(),
669        });
670        let event = Event::new(&NGAP_UPLINK_NAS_EVENT, data);
671        let outcome = self.call_llm_and_send(&event).await;
672        self.settle_ue_outcome(&event.event_type.id, t.amf_ue_ngap_id, outcome, false)
673            .await
674    }
675
676    /// Apply the fail-closed rule to a UE-associated event. `must_answer` is true for an Initial
677    /// UE Message, where silence leaves a UE waiting on an admission decision.
678    async fn settle_ue_outcome(
679        &mut self,
680        event_id: &str,
681        amf_id: u64,
682        outcome: HandlerOutcome,
683        must_answer: bool,
684    ) -> bool {
685        match outcome {
686            HandlerOutcome::Ran { ref sent, closed }
687                if sent.iter().any(|s| s.amf_ue_ngap_id == Some(amf_id)) =>
688            {
689                !closed
690            }
691            HandlerOutcome::Ran { closed, .. } if !must_answer => {
692                debug!(
693                    "NGAP {event_id} decision=model_silent amf_ue_ngap_id={amf_id}: nothing sent"
694                );
695                !closed
696            }
697            HandlerOutcome::Ran { closed, .. } => {
698                Log::new(Some(&self.status_tx)).warn(format!(
699                    "NGAP {event_id} produced no answer for UE {amf_id} (decision=model_silent); \
700                     rejecting"
701                ));
702                self.fail_closed_ue(amf_id, None).await;
703                !closed
704            }
705            HandlerOutcome::Failed(failure) => {
706                Log::new(Some(&self.status_tx)).warn(format!(
707                    "NGAP {event_id} could not be evaluated for UE {amf_id} \
708                     (decision=fail_closed_llm_error, {}); rejecting",
709                    failure_category(failure)
710                ));
711                self.fail_closed_ue(amf_id, Some(failure)).await;
712                true
713            }
714            HandlerOutcome::NoPolicy => {
715                Log::new(Some(&self.status_tx)).warn(format!(
716                    "NGAP {event_id} for UE {amf_id} (decision=no_policy_configured): nothing \
717                     decides what this AMF tells UEs; rejecting"
718                ));
719                self.fail_closed_ue(amf_id, None).await;
720                true
721            }
722        }
723    }
724
725    /// Refuse a UE with the reject that matches what it asked for, then release its context. A
726    /// UE is never told it is registered because nothing answered.
727    async fn fail_closed_ue(&mut self, amf_id: u64, failure: Option<WireFailure>) {
728        let Some(ue) = self.ues.get(&amf_id) else {
729            return;
730        };
731        let ran = ue.ran_ue_ngap_id;
732        let overloaded = failure.map(|f| f.is_overloaded()).unwrap_or(false);
733        let nas_cause = if overloaded {
734            nas::CAUSE_CONGESTION
735        } else {
736            nas::CAUSE_PROTOCOL_ERROR_UNSPECIFIED
737        };
738        let reject = match ue.initial_nas {
739            Some(nas::REGISTRATION_REQUEST) => Some("registration_reject"),
740            Some(nas::SERVICE_REQUEST) => Some("service_reject"),
741            _ => None,
742        };
743        if let Some(message) = reject {
744            let pdu = nas::encode(
745                &json!({"message": message, "cause": nas_cause}),
746                nas::DOWNLINK_MESSAGES,
747            )
748            .map_err(codec::EncodeError)
749            .and_then(|n| codec::downlink_nas_transport(amf_id, ran, &n));
750            self.send_encoded("Downlink NAS Transport (reject)", pdu)
751                .await;
752        }
753        let cause = if overloaded {
754            Cause::misc(codec::MISC_CONTROL_PROCESSING_OVERLOAD)
755        } else {
756            Cause::nas(codec::NAS_UNSPECIFIED)
757        };
758        self.send_encoded(
759            "UE Context Release Command",
760            codec::ue_context_release_command(amf_id, Some(ran), &cause),
761        )
762        .await;
763    }
764
765    async fn on_error_indication(&mut self, pdu: &codec::Pdu) -> bool {
766        let mut data = match codec::parse_cause_message(pdu) {
767            Ok(v) => v,
768            Err(e) => {
769                warn!(
770                    "NGAP Error Indication from {} did not decode: {e}",
771                    self.remote_addr
772                );
773                return true;
774            }
775        };
776        Log::new(Some(&self.status_tx)).warn(format!(
777            "NGAP Error Indication from {}: {}",
778            self.remote_addr,
779            data.get("cause")
780                .map(|c| c.to_string())
781                .unwrap_or_else(|| "no cause".into())
782        ));
783        data["transport"] = json!(self.transport.label());
784        let event = Event::new(&NGAP_ERROR_INDICATION_EVENT, data);
785        match self.call_llm_and_send(&event).await {
786            HandlerOutcome::Failed(f) => warn!(
787                "NGAP ngap_error_indication decision=fail_closed_llm_error ({}); nothing sent — \
788                 an Error Indication needs no answer",
789                failure_category(f)
790            ),
791            HandlerOutcome::Ran { closed: true, .. } => return false,
792            _ => {}
793        }
794        true
795    }
796
797    async fn on_ng_reset(&mut self, pdu: &codec::Pdu) -> bool {
798        let mut budget = codec::Budget::default();
799        let partial = match pdu
800            .require(codec::IE_RESET_TYPE)
801            .and_then(|ie| codec::decode_reset_type(ie, &mut budget))
802        {
803            Ok(p) => p,
804            Err(e) => {
805                self.send_error_indication(None, None, e.cause).await;
806                return true;
807            }
808        };
809        // Resetting is the safe direction, so it needs no policy: 38.413 section 8.7.4.
810        let cleared = match &partial {
811            None => {
812                let n = self.ues.len();
813                self.ues.clear();
814                n
815            }
816            Some(items) => {
817                let before = self.ues.len();
818                for (amf, ran) in items {
819                    match (amf, ran) {
820                        (Some(a), _) => {
821                            self.ues.remove(a);
822                        }
823                        (None, Some(r)) => self.ues.retain(|_, u| u.ran_ue_ngap_id != *r),
824                        (None, None) => {}
825                    }
826                }
827                before - self.ues.len()
828            }
829        };
830        self.set_connection_state().await;
831        Log::new(Some(&self.status_tx)).info(format!(
832            "NGAP NG Reset from {}: {cleared} UE contexts cleared",
833            self.remote_addr
834        ));
835        self.send_encoded(
836            "NG Reset Acknowledge",
837            codec::ng_reset_acknowledge(partial.as_deref()),
838        )
839        .await;
840        true
841    }
842
843    async fn on_release_complete(&mut self, pdu: &codec::Pdu) -> bool {
844        if let Some(amf) = pdu
845            .ie(codec::IE_AMF_UE_NGAP_ID)
846            .and_then(|ie| codec::decode_amf_ue_ngap_id(ie).ok())
847        {
848            if self.ues.remove(&amf).is_some() {
849                info!("NGAP UE context {amf} released by {}", self.remote_addr);
850                self.set_connection_state().await;
851            }
852        }
853        true
854    }
855
856    // ------------------------------------------------------------------------
857
858    async fn call_llm_and_send(&mut self, event: &Event) -> HandlerOutcome {
859        if !operator_wants_dynamic(&self.app_state, self.server_id, &event.event_type.id).await {
860            return HandlerOutcome::NoPolicy;
861        }
862        let result = match call_llm(
863            &self.llm_client,
864            &self.app_state,
865            self.server_id,
866            Some(self.connection_id),
867            event,
868            &*self.protocol,
869        )
870        .await
871        {
872            Ok(r) => r,
873            Err(e) => {
874                let failure = WireFailure::classify(&e);
875                // The whole error goes to the operator; the peer only ever gets a Cause.
876                Log::new(Some(&self.status_tx)).error(format!(
877                    "NGAP {} decision=fail_closed_llm_error category={:?} peer={}: {:#}",
878                    event.event_type.id, failure, self.remote_addr, e
879                ));
880                return HandlerOutcome::Failed(failure);
881            }
882        };
883        let mut sent = Vec::new();
884        let mut closed = false;
885        let mut queue: Vec<ActionResult> = result.protocol_results;
886        queue.reverse();
887        while let Some(item) = queue.pop() {
888            match item {
889                ActionResult::Multiple(inner) => {
890                    for nested in inner.into_iter().rev() {
891                        queue.push(nested);
892                    }
893                }
894                ActionResult::Output(bytes) => {
895                    if let Ok(p) = codec::decode_pdu(&bytes) {
896                        sent.push(Sent {
897                            kind: p.kind,
898                            code: p.procedure_code,
899                            amf_ue_ngap_id: p
900                                .ie(codec::IE_AMF_UE_NGAP_ID)
901                                .and_then(|ie| codec::decode_amf_ue_ngap_id(ie).ok())
902                                .or_else(|| {
903                                    p.ie(codec::IE_UE_NGAP_IDS).and_then(|ie| {
904                                        codec::decode_ue_ngap_ids(ie, &mut codec::Budget::default())
905                                            .ok()
906                                            .map(|(a, _)| a)
907                                    })
908                                }),
909                        });
910                    }
911                    self.send(bytes).await;
912                }
913                ActionResult::CloseConnection => {
914                    self.writer.lock().await.close();
915                    closed = true;
916                }
917                ActionResult::WaitForMore | ActionResult::NoAction => {}
918                other => debug!("NGAP ignoring action result {other:?}"),
919            }
920        }
921        HandlerOutcome::Ran { sent, closed }
922    }
923
924    async fn send(&self, bytes: Vec<u8>) {
925        let ok = self.writer.lock().await.send(&bytes).await.is_ok();
926        if !ok {
927            debug!(
928                "NGAP write failed on association {}; peer has gone",
929                self.connection_id
930            );
931            return;
932        }
933        self.app_state
934            .update_connection_stats(
935                self.server_id,
936                self.connection_id,
937                None,
938                Some(bytes.len() as u64),
939                None,
940                Some(1),
941            )
942            .await;
943    }
944
945    async fn send_encoded(&self, what: &str, encoded: codec::EncodeResult<Vec<u8>>) {
946        match encoded {
947            Ok(bytes) => self.send(bytes).await,
948            Err(e) => error!(
949                "NGAP decision=fail_closed_encode: refusing to send {what} to {}: {e}",
950                self.remote_addr
951            ),
952        }
953    }
954
955    async fn send_error_indication(&self, amf: Option<u64>, ran: Option<u64>, cause: Cause) {
956        Log::new(Some(&self.status_tx)).warn(format!(
957            "NGAP Error Indication ({}) to {}",
958            cause.label(),
959            self.remote_addr
960        ));
961        self.send_encoded(
962            "Error Indication",
963            codec::error_indication(amf, ran, Some(&cause)),
964        )
965        .await;
966    }
967
968    async fn set_connection_state(&self) {
969        let value = json!({
970            "state": if self.gnb.is_some() { "NG Setup complete" } else { "awaiting NG Setup" },
971            "gnb": self.gnb,
972            "ue_contexts": self.ues.len(),
973            "transport": self.transport.label(),
974        });
975        let connection_id = self.connection_id;
976        self.app_state
977            .with_server_mut(self.server_id, |server| {
978                if let Some(c) = server.connections.get_mut(&connection_id) {
979                    c.protocol_info =
980                        crate::state::server::ProtocolConnectionInfo::new(value.clone());
981                }
982            })
983            .await;
984    }
985}
986
987fn failure_category(failure: WireFailure) -> &'static str {
988    if failure.is_overloaded() {
989        "resource"
990    } else {
991        "unavailable"
992    }
993}
994
995/// True when the operator opted into dynamic handling — an instruction or a matching handler.
996/// `false` is a refusal for this AMF: who may join a 5G core has no configuration-free answer.
997async fn operator_wants_dynamic(state: &AppState, server_id: ServerId, event_id: &str) -> bool {
998    state
999        .with_server_mut(server_id, |server| {
1000            let has_instruction = !server.instruction.trim().is_empty();
1001            let has_handler = server
1002                .event_handler_config
1003                .as_ref()
1004                .map(|c| c.find_handler(event_id).is_some())
1005                .unwrap_or(false);
1006            has_instruction || has_handler
1007        })
1008        .await
1009        .unwrap_or(false)
1010}