Skip to main content

netget/server/ngap/
actions.rs

1//! NGAP AMF actions, events and metadata.
2//!
3//! Every `send_*` action validates its fields against the 38.413 constraints and returns the
4//! fully encoded PDU as [`ActionResult::Output`]; the session reads the procedure and UE id back
5//! off those octets to apply the fail-closed rule. Nothing about the encoding depends on session
6//! state, so the same octets come out whether the model, a static handler or the dashboard's
7//! "message this peer" asked for them.
8
9use crate::llm::actions::{
10    protocol_trait::{ActionResult, Protocol, Server},
11    ActionDefinition, Parameter,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::EventType;
15use crate::state::app_state::AppState;
16use anyhow::{anyhow, Context, Result};
17use serde_json::{json, Value};
18use std::sync::LazyLock;
19
20use super::codec::{self, Cause};
21use super::nas;
22
23pub struct NgapProtocol;
24
25impl Default for NgapProtocol {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl NgapProtocol {
32    pub fn new() -> Self {
33        Self
34    }
35}
36
37fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
38    Parameter {
39        name: name.to_string(),
40        type_hint: type_hint.to_string(),
41        description: description.to_string(),
42        required,
43    }
44}
45
46fn list<'a>(action: &'a Value, field: &str) -> Result<&'a Vec<Value>> {
47    action
48        .get(field)
49        .and_then(Value::as_array)
50        .with_context(|| format!("{field} must be a list"))
51}
52
53fn cause_field(action: &Value, field: &str, default: Option<&str>) -> Result<Cause> {
54    match action.get(field) {
55        Some(v) if !v.is_null() => Ok(Cause::from_json(v)?),
56        _ => match default {
57            Some(d) => Ok(Cause::from_json(&json!(d))?),
58            None => Err(anyhow!(
59                "{field} is required, e.g. \"misc/unknown-PLMN-or-SNPN\""
60            )),
61        },
62    }
63}
64
65/// Encode the `nas` object of a downlink action into a NAS-PDU.
66pub fn downlink_nas(action: &Value) -> Result<Vec<u8>> {
67    let v = action.get("nas").context(
68        "nas is required: {\"message\": \"registration_reject\", \"cause\": \"illegal_ue\"}",
69    )?;
70    nas::encode(v, nas::DOWNLINK_MESSAGES).map_err(|e| anyhow!(e))
71}
72
73impl NgapProtocol {
74    fn ng_setup_response(&self, a: &Value) -> Result<ActionResult> {
75        let name = a
76            .get("amf_name")
77            .and_then(Value::as_str)
78            .context("amf_name is required")?;
79        let capacity = codec::json_opt_u64(a, "relative_amf_capacity", 255)?.unwrap_or(255);
80        Ok(ActionResult::Output(codec::ng_setup_response(
81            name,
82            list(a, "served_guami")?,
83            capacity,
84            list(a, "plmn_support")?,
85        )?))
86    }
87
88    fn ng_setup_failure(&self, a: &Value) -> Result<ActionResult> {
89        let cause = cause_field(a, "cause", None)?;
90        let ttw = a.get("time_to_wait").and_then(|v| match v {
91            Value::String(s) => Some(s.clone()),
92            Value::Number(n) => Some(n.to_string()),
93            _ => None,
94        });
95        Ok(ActionResult::Output(codec::ng_setup_failure(
96            &cause,
97            ttw.as_deref(),
98        )?))
99    }
100
101    fn downlink_nas_transport(&self, a: &Value) -> Result<ActionResult> {
102        let amf = codec::json_u64(a, "amf_ue_ngap_id", codec::MAX_AMF_UE_NGAP_ID)?;
103        let ran = codec::json_u64(a, "ran_ue_ngap_id", codec::MAX_RAN_UE_NGAP_ID)?;
104        let nas = downlink_nas(a)?;
105        Ok(ActionResult::Output(codec::downlink_nas_transport(
106            amf, ran, &nas,
107        )?))
108    }
109
110    fn ue_context_release(&self, a: &Value) -> Result<ActionResult> {
111        let amf = codec::json_u64(a, "amf_ue_ngap_id", codec::MAX_AMF_UE_NGAP_ID)?;
112        let ran = codec::json_opt_u64(a, "ran_ue_ngap_id", codec::MAX_RAN_UE_NGAP_ID)?;
113        let cause = cause_field(a, "cause", Some("nas/normal-release"))?;
114        Ok(ActionResult::Output(codec::ue_context_release_command(
115            amf, ran, &cause,
116        )?))
117    }
118
119    fn error_indication(&self, a: &Value) -> Result<ActionResult> {
120        let amf = codec::json_opt_u64(a, "amf_ue_ngap_id", codec::MAX_AMF_UE_NGAP_ID)?;
121        let ran = codec::json_opt_u64(a, "ran_ue_ngap_id", codec::MAX_RAN_UE_NGAP_ID)?;
122        let cause = match a.get("cause") {
123            Some(v) if !v.is_null() => Some(Cause::from_json(v)?),
124            _ => None,
125        };
126        Ok(ActionResult::Output(codec::error_indication(
127            amf,
128            ran,
129            cause.as_ref(),
130        )?))
131    }
132
133    fn ng_reset(&self, a: &Value) -> Result<ActionResult> {
134        let cause = cause_field(a, "cause", Some("misc/om-intervention"))?;
135        Ok(ActionResult::Output(codec::ng_reset(&cause, None)?))
136    }
137}
138
139// ============================================================================
140// Action definitions
141// ============================================================================
142
143const PLMN_HELP: &str = "{\"mcc\": \"001\", \"mnc\": \"01\"} — digits as strings";
144
145fn ng_setup_response_action() -> ActionDefinition {
146    ActionDefinition {
147        name: "send_ng_setup_response".into(),
148        description: "Admit this gNB: answer its NG Setup Request with an NG Setup Response \
149                      naming this AMF, its GUAMIs and the PLMNs and slices it serves. This is the \
150                      admission decision for N2 — return send_ng_setup_failure to refuse."
151            .into(),
152        parameters: vec![
153            p("amf_name", "string", "This AMF's name, 1-150 PrintableString characters", true),
154            p(
155                "served_guami",
156                "array",
157                &format!("GUAMIs this AMF serves (1-256): [{{\"plmn\": {PLMN_HELP}, \"amf_region_id\": 0-255, \"amf_set_id\": 0-1023, \"amf_pointer\": 0-63}}]"),
158                true,
159            ),
160            p(
161                "plmn_support",
162                "array",
163                "PLMNs and slices supported (1-12): [{\"plmn\": {...}, \"slices\": [{\"sst\": 1, \"sd\": \"010203\"}]}] — sd is optional, six hex digits",
164                true,
165            ),
166            p("relative_amf_capacity", "number", "0-255, default 255", false),
167        ],
168        example: json!({
169            "type": "send_ng_setup_response",
170            "amf_name": "netget-amf",
171            "served_guami": [{"plmn": {"mcc": "001", "mnc": "01"}, "amf_region_id": 2, "amf_set_id": 1, "amf_pointer": 0}],
172            "plmn_support": [{"plmn": {"mcc": "001", "mnc": "01"}, "slices": [{"sst": 1}]}],
173            "relative_amf_capacity": 255
174        }),
175        log_template: Some(
176            LogTemplate::new()
177                .with_info("-> NGAP NG Setup Response {amf_name}")
178                .with_debug("NGAP send_ng_setup_response {amf_name}"),
179        ),
180    }
181}
182
183fn ng_setup_failure_action() -> ActionDefinition {
184    ActionDefinition {
185        name: "send_ng_setup_failure".into(),
186        description: "Refuse this gNB with an NG Setup Failure carrying a cause. The explicit \
187                      refusal of N2 setup."
188            .into(),
189        parameters: vec![
190            p(
191                "cause",
192                "string",
193                "group/value, e.g. misc/unknown-PLMN-or-SNPN, misc/control-processing-overload, \
194                 misc/om-intervention, misc/unspecified, protocol/semantic-error",
195                true,
196            ),
197            p(
198                "time_to_wait",
199                "string",
200                "When the gNB may retry: v1s, v2s, v5s, v10s, v20s or v60s",
201                false,
202            ),
203        ],
204        example: json!({"type": "send_ng_setup_failure", "cause": "misc/unknown-PLMN-or-SNPN", "time_to_wait": "v10s"}),
205        log_template: Some(
206            LogTemplate::new()
207                .with_info("-> NGAP NG Setup Failure {cause}")
208                .with_debug("NGAP send_ng_setup_failure cause={cause}"),
209        ),
210    }
211}
212
213fn downlink_nas_action() -> ActionDefinition {
214    ActionDefinition {
215        name: "send_downlink_nas_transport".into(),
216        description:
217            "Send a NAS message to one UE in a Downlink NAS Transport. The NAS message is \
218                      structured and Rust encodes it. Only what a UE accepts before NAS security \
219                      exists is offered: registration_reject {cause}, identity_request \
220                      {identity_type}, authentication_request {ngksi, rand?, autn? — 32 hex digits \
221                      each, random when omitted; without the subscriber's key a real UE answers \
222                      with authentication_failure mac_failure}, authentication_reject, \
223                      service_reject {cause}, 5gmm_status {cause}."
224                .into(),
225        parameters: vec![
226            p(
227                "amf_ue_ngap_id",
228                "number",
229                "The UE's AMF UE NGAP ID, from the event",
230                true,
231            ),
232            p(
233                "ran_ue_ngap_id",
234                "number",
235                "The UE's RAN UE NGAP ID, from the event",
236                true,
237            ),
238            p(
239                "nas",
240                "object",
241                "e.g. {\"message\": \"registration_reject\", \"cause\": \"illegal_ue\"} or \
242                 {\"message\": \"identity_request\", \"identity_type\": \"suci\"}. 5GMM causes by \
243                 name: illegal_ue, plmn_not_allowed, tracking_area_not_allowed, congestion, \
244                 no_network_slices_available, protocol_error_unspecified, …",
245                true,
246            ),
247        ],
248        example: json!({
249            "type": "send_downlink_nas_transport",
250            "amf_ue_ngap_id": 1,
251            "ran_ue_ngap_id": 1,
252            "nas": {"message": "registration_reject", "cause": "illegal_ue"}
253        }),
254        log_template: Some(
255            LogTemplate::new()
256                .with_info("-> NGAP Downlink NAS Transport ue={amf_ue_ngap_id}")
257                .with_debug("NGAP send_downlink_nas_transport ue={amf_ue_ngap_id} nas={nas}"),
258        ),
259    }
260}
261
262fn release_action() -> ActionDefinition {
263    ActionDefinition {
264        name: "send_ue_context_release_command".into(),
265        description: "Release one UE's N2 context (UE Context Release Command). The gNB answers \
266                      with UE Context Release Complete, which NetGet handles."
267            .into(),
268        parameters: vec![
269            p("amf_ue_ngap_id", "number", "The UE's AMF UE NGAP ID", true),
270            p(
271                "ran_ue_ngap_id",
272                "number",
273                "The UE's RAN UE NGAP ID (sends the pair form)",
274                false,
275            ),
276            p(
277                "cause",
278                "string",
279                "group/value, default nas/normal-release",
280                false,
281            ),
282        ],
283        example: json!({"type": "send_ue_context_release_command", "amf_ue_ngap_id": 1, "ran_ue_ngap_id": 1, "cause": "nas/normal-release"}),
284        log_template: Some(
285            LogTemplate::new()
286                .with_info("-> NGAP UE Context Release Command ue={amf_ue_ngap_id}")
287                .with_debug("NGAP send_ue_context_release_command cause={cause}"),
288        ),
289    }
290}
291
292fn error_indication_action() -> ActionDefinition {
293    ActionDefinition {
294        name: "send_error_indication".into(),
295        description: "Report an error to the gNB with an Error Indication, optionally naming a UE."
296            .into(),
297        parameters: vec![
298            p(
299                "cause",
300                "string",
301                "group/value, e.g. protocol/semantic-error",
302                false,
303            ),
304            p(
305                "amf_ue_ngap_id",
306                "number",
307                "The UE concerned, if any",
308                false,
309            ),
310            p(
311                "ran_ue_ngap_id",
312                "number",
313                "The UE concerned, if any",
314                false,
315            ),
316        ],
317        example: json!({"type": "send_error_indication", "cause": "protocol/semantic-error"}),
318        log_template: Some(
319            LogTemplate::new()
320                .with_info("-> NGAP Error Indication {cause}")
321                .with_debug("NGAP send_error_indication cause={cause}"),
322        ),
323    }
324}
325
326fn ng_reset_action() -> ActionDefinition {
327    ActionDefinition {
328        name: "send_ng_reset".into(),
329        description: "Reset every UE context on this N2 interface (NG Reset, reset-all). The gNB \
330                      acknowledges and NetGet clears its UE table."
331            .into(),
332        parameters: vec![p(
333            "cause",
334            "string",
335            "group/value, default misc/om-intervention",
336            false,
337        )],
338        example: json!({"type": "send_ng_reset", "cause": "misc/om-intervention"}),
339        log_template: Some(
340            LogTemplate::new()
341                .with_info("-> NGAP NG Reset {cause}")
342                .with_debug("NGAP send_ng_reset cause={cause}"),
343        ),
344    }
345}
346
347fn wait_for_more_action() -> ActionDefinition {
348    ActionDefinition {
349        name: "wait_for_more".into(),
350        description: "Send nothing. On an NG Setup Request or Initial UE Message this is NOT an \
351                      admission: NetGet answers with a refusal."
352            .into(),
353        parameters: vec![],
354        example: json!({"type": "wait_for_more"}),
355        log_template: Some(
356            LogTemplate::new()
357                .with_info("-> NGAP wait for more")
358                .with_debug("NGAP wait_for_more"),
359        ),
360    }
361}
362
363fn response_actions() -> Vec<ActionDefinition> {
364    vec![
365        ng_setup_response_action(),
366        ng_setup_failure_action(),
367        downlink_nas_action(),
368        release_action(),
369        error_indication_action(),
370        ng_reset_action(),
371        wait_for_more_action(),
372    ]
373}
374
375// ============================================================================
376// Events
377// ============================================================================
378
379pub static NGAP_NG_SETUP_EVENT: LazyLock<EventType> = LazyLock::new(|| {
380    EventType::new(
381        "ngap_ng_setup_request",
382        "A gNB sent NG Setup Request and wants to set up N2 with this AMF. global_ran_node_id \
383         names it (plmn, gnb_id), supported_ta_list lists its tracking areas with the PLMNs and \
384         slices broadcast in each. Reply with send_ng_setup_response to admit it, or \
385         send_ng_setup_failure to refuse. Anything else — wait_for_more, no answer, a failed \
386         model — is answered by NetGet with NG Setup Failure: admitting a gNB is never the \
387         default.",
388        ng_setup_response_action().example,
389    )
390    .with_alternative_example(
391        json!({"type": "send_ng_setup_failure", "cause": "misc/unknown-PLMN-or-SNPN"}),
392    )
393    .with_actions(response_actions())
394    .with_log_template(
395        LogTemplate::new()
396            .with_info("NGAP NG Setup Request from {ran_node_name}")
397            .with_debug("NGAP NG Setup Request {global_ran_node_id}")
398            .with_trace("NGAP NG Setup Request: {json_pretty(.)}"),
399    )
400});
401
402pub static NGAP_INITIAL_UE_MESSAGE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
403    EventType::new(
404        "ngap_initial_ue_message",
405        "A UE's first NAS message arrived through the gNB (Initial UE Message). NetGet allocated \
406         amf_ue_ngap_id; ran_ue_ngap_id is the gNB's. nas is the decoded 5GMM message — for a \
407         registration_request: registration_type, ngksi and mobile_identity (a SUCI's plmn, \
408         routing_indicator, protection_scheme and, for the null scheme, msin; or a 5G-GUTI). \
409         Answer with send_downlink_nas_transport carrying identity_request, \
410         authentication_request or registration_reject, optionally followed by \
411         send_ue_context_release_command. Saying nothing makes NetGet reject the UE \
412         (registration_reject protocol_error_unspecified) and release it.",
413        downlink_nas_action().example,
414    )
415    .with_alternative_example(json!({
416        "type": "send_downlink_nas_transport",
417        "amf_ue_ngap_id": 1,
418        "ran_ue_ngap_id": 1,
419        "nas": {"message": "authentication_request", "ngksi": 0}
420    }))
421    .with_actions(response_actions())
422    .with_log_template(
423        LogTemplate::new()
424            .with_info("NGAP Initial UE Message ue={amf_ue_ngap_id}")
425            .with_debug("NGAP Initial UE Message ue={amf_ue_ngap_id} nas={nas}")
426            .with_trace("NGAP Initial UE Message: {json_pretty(.)}"),
427    )
428});
429
430pub static NGAP_UPLINK_NAS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
431    EventType::new(
432        "ngap_uplink_nas_transport",
433        "A UE answered through the gNB (Uplink NAS Transport). nas is the decoded 5GMM message — \
434         e.g. identity_response with its mobile_identity, or authentication_failure with its \
435         cause (mac_failure means the UE did not accept the AUTN). Answer with \
436         send_downlink_nas_transport, send_ue_context_release_command, or wait_for_more.",
437        json!({
438            "type": "send_downlink_nas_transport",
439            "amf_ue_ngap_id": 1,
440            "ran_ue_ngap_id": 1,
441            "nas": {"message": "registration_reject", "cause": "illegal_ue"}
442        }),
443    )
444    .with_alternative_example(json!({"type": "wait_for_more"}))
445    .with_actions(response_actions())
446    .with_log_template(
447        LogTemplate::new()
448            .with_info("NGAP Uplink NAS Transport ue={amf_ue_ngap_id}")
449            .with_debug("NGAP Uplink NAS Transport ue={amf_ue_ngap_id} nas={nas}")
450            .with_trace("NGAP Uplink NAS Transport: {json_pretty(.)}"),
451    )
452});
453
454pub static NGAP_ERROR_INDICATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
455    EventType::new(
456        "ngap_error_indication",
457        "The gNB reported an error (Error Indication), with an optional cause and UE ids. \
458         Observational: no answer is required. Release the UE with \
459         send_ue_context_release_command if it names one, or wait_for_more.",
460        json!({"type": "wait_for_more"}),
461    )
462    .with_alternative_example(
463        json!({"type": "send_ue_context_release_command", "amf_ue_ngap_id": 1}),
464    )
465    .with_actions(response_actions())
466    .with_log_template(
467        LogTemplate::new()
468            .with_info("NGAP Error Indication {cause}")
469            .with_debug("NGAP Error Indication {cause} ue={amf_ue_ngap_id}")
470            .with_trace("NGAP Error Indication: {json_pretty(.)}"),
471    )
472});
473
474// ============================================================================
475
476impl Protocol for NgapProtocol {
477    /// Every NGAP verb is addressed to one association, so there are no async actions; the
478    /// dashboard's "message this peer" runs the sync actions through the peer command channel.
479    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
480        vec![]
481    }
482
483    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
484        response_actions()
485    }
486
487    fn protocol_name(&self) -> &'static str {
488        "NGAP"
489    }
490
491    fn get_event_types(&self) -> Vec<EventType> {
492        vec![
493            NGAP_NG_SETUP_EVENT.clone(),
494            NGAP_INITIAL_UE_MESSAGE_EVENT.clone(),
495            NGAP_UPLINK_NAS_EVENT.clone(),
496            NGAP_ERROR_INDICATION_EVENT.clone(),
497        ]
498    }
499
500    fn stack_name(&self) -> &'static str {
501        "ETH>IP>SCTP>NGAP"
502    }
503
504    fn keywords(&self) -> Vec<&'static str> {
505        vec!["ngap", "n2", "amf", "5g core", "5gc", "38.413"]
506    }
507
508    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
509        use crate::protocol::metadata::{
510            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
511        };
512        ProtocolMetadataV2::builder()
513            .state(DevelopmentState::Experimental)
514            // 38412 is above 1023 and SCTP is an ordinary kernel transport, not a raw socket.
515            .privilege_requirement(PrivilegeRequirement::None)
516            // TS 38.412 section 7; the same number as `codec::SCTP_PORT`, written as a literal so
517            // the well-known-port ratchet can read it from source.
518            .well_known_sctp_port(38412)
519            .implementation(
520                "TS 38.413 ALIGNED PER hand-written for the procedures implemented (NG Setup, \
521                 Initial UE Message, Downlink/Uplink NAS Transport, Error Indication, NG Reset, \
522                 UE Context Release), other IEs framed and skipped by length; plain 5GMM (TS \
523                 24.501) decoded and the pre-security downlink messages encoded; SCTP via a \
524                 socket2 one-to-one socket with PPID 60 and stream 0/1 selection, plus a \
525                 non-standard length-prefixed TCP transport for lab use",
526            )
527            .llm_control(
528                "The model admits or refuses gNBs (NG Setup Response/Failure with a cause) and \
529                 decides what each UE is told (Identity Request, Authentication Request, \
530                 Registration Reject with a 5GMM cause, UE Context Release). Decoding, ID \
531                 allocation, NG Reset and every protocol-validity refusal are Rust's",
532            )
533            .e2e_testing(
534                "UERANSIM's nr-gnb and nr-ue (asn1c APER) set up N2 and register against it over \
535                 real SCTP on Linux, with tshark's ngap dissector reading a live capture; over \
536                 the lab TCP transport with a mocked model on any host, including the \
537                 fail-closed paths, the decode bounds and the pcap oracle on NetGet's own PDUs",
538            )
539            .notes(
540                "EXPERIMENTAL: one independent peer (UERANSIM), and its evidence runs only where \
541                 SCTP exists (Linux, the ngap pair job). macOS has no SCTP stack, so there the \
542                 default transport refuses to start by name and only the non-standard \
543                 transport=\"tcp\" framing runs. NAS security is not implemented: no 5G-AKA \
544                 derivation, no Security Mode Command, no integrity or ciphering — a real UE \
545                 answers NetGet's Authentication Request with MAC failure, and Registration \
546                 Accept cannot be sent. No PDU sessions, handover, paging or AMF \
547                 configuration update.",
548            )
549            .max_inbound_bytes(codec::MAX_PDU_LEN)
550            .build()
551    }
552
553    fn description(&self) -> &'static str {
554        "NGAP (5G N2) AMF: NG Setup and initial UE registration signalling with gNBs"
555    }
556
557    fn example_prompt(&self) -> &'static str {
558        "Start a 5G AMF for PLMN 001-01 that admits gNBs and rejects every UE as illegal"
559    }
560
561    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
562        vec![crate::llm::actions::ParameterDefinition {
563            name: "transport".into(),
564            type_hint: "string".into(),
565            description: "\"sctp\" (default — TS 38.412, PPID 60) or \"tcp\". TCP IS \
566                          NON-STANDARD: a NetGet length-prefixed framing that no real gNB speaks, \
567                          for local protocol work on hosts with no SCTP (macOS has none). A host \
568                          without SCTP refuses \"sctp\" rather than falling back."
569                .into(),
570            required: false,
571            example: json!("sctp"),
572            default: None,
573        }]
574    }
575
576    fn group_name(&self) -> &'static str {
577        "Routing"
578    }
579
580    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
581        let script = r#"import json, sys
582data = json.load(sys.stdin)
583event = data["event"]
584if data["event_type_id"] == "ngap_ng_setup_request":
585    plmn = event["global_ran_node_id"]["plmn"]
586    actions = [{"type": "send_ng_setup_response", "amf_name": "netget-amf",
587                "served_guami": [{"plmn": plmn, "amf_region_id": 2, "amf_set_id": 1, "amf_pointer": 0}],
588                "plmn_support": [{"plmn": plmn, "slices": [{"sst": 1}]}]}]
589elif data["event_type_id"] == "ngap_initial_ue_message":
590    actions = [{"type": "send_downlink_nas_transport", "amf_ue_ngap_id": event["amf_ue_ngap_id"],
591                "ran_ue_ngap_id": event["ran_ue_ngap_id"],
592                "nas": {"message": "registration_reject", "cause": "illegal_ue"}}]
593else:
594    actions = []
595print(json.dumps({"actions": actions}))"#;
596        crate::llm::actions::StartupExamples::new(
597            json!({
598                "type": "open_server",
599                "port": 38412,
600                "base_stack": "ngap",
601                "instruction": "Act as a 5G AMF for PLMN 001-01, slice SST 1. Admit gNBs that \
602                                broadcast that PLMN; ask every registering UE for its identity, \
603                                then reject it with illegal_ue.",
604                "startup_params": {"transport": "sctp"}
605            }),
606            json!({
607                "type": "open_server",
608                "port": 38412,
609                "base_stack": "ngap",
610                "startup_params": {"transport": "sctp"},
611                "event_handlers": [{
612                    "event_pattern": "*",
613                    "handler": {"type": "script", "language": "python", "code": script}
614                }]
615            }),
616            json!({
617                "type": "open_server",
618                "port": 38412,
619                "base_stack": "ngap",
620                "startup_params": {"transport": "sctp"},
621                "event_handlers": [{
622                    "event_pattern": "ngap_ng_setup_request",
623                    "handler": {"type": "static", "actions": [{
624                        "type": "send_ng_setup_failure",
625                        "cause": "misc/om-intervention",
626                        "time_to_wait": "v60s"
627                    }]}
628                }]
629            }),
630        )
631    }
632}
633
634impl Server for NgapProtocol {
635    fn spawn(
636        &self,
637        ctx: crate::protocol::SpawnContext,
638    ) -> std::pin::Pin<
639        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
640    > {
641        Box::pin(async move {
642            super::NgapServer::spawn_with_llm_actions(
643                ctx.legacy_listen_addr(),
644                ctx.llm_client,
645                ctx.state,
646                ctx.status_tx,
647                ctx.server_id,
648                ctx.startup_params,
649            )
650            .await
651        })
652    }
653
654    fn execute_action(&self, action: Value) -> Result<ActionResult> {
655        let t = action
656            .get("type")
657            .and_then(Value::as_str)
658            .context("Missing action type")?;
659        match t {
660            "send_ng_setup_response" => self.ng_setup_response(&action),
661            "send_ng_setup_failure" => self.ng_setup_failure(&action),
662            "send_downlink_nas_transport" => self.downlink_nas_transport(&action),
663            "send_ue_context_release_command" => self.ue_context_release(&action),
664            "send_error_indication" => self.error_indication(&action),
665            "send_ng_reset" => self.ng_reset(&action),
666            "wait_for_more" => Ok(ActionResult::WaitForMore),
667            // Not offered to the model; the dashboard's "disconnect this peer" injects it.
668            "close_connection" => Ok(ActionResult::CloseConnection),
669            _ => Err(anyhow!("Unknown NGAP action type: {t}")),
670        }
671    }
672}