Skip to main content

netget/client/ngap/
actions.rs

1//! What the model does as a gNB on N2: bring a UE in with an Initial UE Message carrying a NAS
2//! Registration Request it describes in fields, answer the AMF's NAS (identity, authentication)
3//! with Uplink NAS Transport, release UEs and report errors. NG Setup is performed by Rust on
4//! connect from the startup parameters, the way a real gNB does it from its configuration.
5use crate::llm::actions::{
6    client_trait::{Client, ClientActionResult},
7    protocol_trait::Protocol,
8    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
9};
10use crate::protocol::log_template::LogTemplate;
11use crate::protocol::{ConnectContext, EventType};
12use crate::server::ngap::{codec, nas};
13use crate::state::app_state::AppState;
14use anyhow::{anyhow, bail, Result};
15use serde_json::{json, Value};
16use std::sync::LazyLock;
17
18pub const INITIAL_UE: &str = "ngap_initial_ue_message";
19pub const UPLINK_NAS: &str = "ngap_uplink_nas_transport";
20pub const ERROR_INDICATION: &str = "ngap_error_indication";
21pub const NG_SETUP: &str = "ngap_ng_setup";
22
23/// Defaults for the gNB's NG Setup, used when the startup parameters name none.
24pub const DEFAULT_MCC: &str = "001";
25pub const DEFAULT_MNC: &str = "01";
26pub const DEFAULT_GNB_ID: u64 = 1;
27pub const DEFAULT_GNB_ID_BITS: u64 = 32;
28pub const DEFAULT_TAC: u64 = 1;
29pub const DEFAULT_SST: u64 = 1;
30pub const DEFAULT_NR_CELL_ID: u64 = 0x10;
31pub const DEFAULT_RAN_NODE_NAME: &str = "netget-gnb";
32pub const DEFAULT_PAGING_DRX: &str = "v128";
33pub const DEFAULT_RRC_CAUSE: &str = "mo-Signalling";
34
35#[derive(Default)]
36pub struct NgapClientProtocol;
37impl NgapClientProtocol {
38    pub fn new() -> Self {
39        Self
40    }
41}
42
43fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
44    Parameter {
45        name: name.into(),
46        type_hint: type_hint.into(),
47        description: description.into(),
48        required,
49    }
50}
51
52fn action(
53    name: &str,
54    description: &str,
55    parameters: Vec<Parameter>,
56    example: Value,
57) -> ActionDefinition {
58    ActionDefinition {
59        name: name.into(),
60        description: description.into(),
61        parameters,
62        example,
63        log_template: Some(
64            LogTemplate::new()
65                .with_info(format!("-> NGAP {name}"))
66                .with_debug(format!("NGAP client {name}")),
67        ),
68    }
69}
70
71pub fn actions() -> Vec<ActionDefinition> {
72    vec![
73        action(
74            INITIAL_UE,
75            "Bring a UE onto N2: an Initial UE Message carrying a NAS message Rust builds from \
76             fields. A new RAN UE NGAP ID is allocated unless you give one. The UE's location is \
77             this gNB's cell and tracking area.",
78            vec![
79                p(
80                    "nas",
81                    "object",
82                    "Usually {\"message\": \"registration_request\", \"registration_type\": \
83                     \"initial\", \"mobile_identity\": {\"type\": \"suci\", \"plmn\": {\"mcc\": \
84                     \"001\", \"mnc\": \"01\"}, \"routing_indicator\": \"0000\", \"msin\": \
85                     \"0000000001\"}} (null-scheme SUCI), or a 5G-GUTI identity {\"type\": \
86                     \"5g_guti\", \"plmn\", \"amf_region_id\", \"amf_set_id\", \"amf_pointer\", \
87                     \"tmsi\"}",
88                    true,
89                ),
90                p(
91                    "ran_ue_ngap_id",
92                    "number",
93                    "Default: the next free one",
94                    false,
95                ),
96                p(
97                    "rrc_establishment_cause",
98                    "string",
99                    "mo-Signalling (default), mo-Data, emergency, mt-Access, …",
100                    false,
101                ),
102            ],
103            json!({"type": INITIAL_UE, "nas": {"message": "registration_request",
104                   "registration_type": "initial",
105                   "mobile_identity": {"type": "suci", "plmn": {"mcc": "001", "mnc": "01"},
106                                       "routing_indicator": "0000", "msin": "0000000001"}}}),
107        ),
108        action(
109            UPLINK_NAS,
110            "Answer the AMF for a UE (Uplink NAS Transport). nas is structured: identity_response \
111             {mobile_identity}, authentication_failure {cause: mac_failure | synch_failure | \
112             non_5g_authentication_unacceptable}, registration_complete, \
113             deregistration_request_ue_originating {mobile_identity, switch_off}, \
114             security_mode_reject {cause}, 5gmm_status {cause}.",
115            vec![
116                p(
117                    "amf_ue_ngap_id",
118                    "number",
119                    "From the AMF's ngap_downlink_nas_transport",
120                    true,
121                ),
122                p("ran_ue_ngap_id", "number", "The UE's RAN UE NGAP ID", true),
123                p(
124                    "nas",
125                    "object",
126                    "e.g. {\"message\": \"identity_response\", \"mobile_identity\": {...}}",
127                    true,
128                ),
129            ],
130            json!({"type": UPLINK_NAS, "amf_ue_ngap_id": 1, "ran_ue_ngap_id": 1,
131                   "nas": {"message": "authentication_failure", "cause": "mac_failure"}}),
132        ),
133        action(
134            ERROR_INDICATION,
135            "Report an error to the AMF (Error Indication), optionally for one UE.",
136            vec![
137                p(
138                    "cause",
139                    "string",
140                    "group/value, e.g. protocol/semantic-error",
141                    false,
142                ),
143                p(
144                    "amf_ue_ngap_id",
145                    "number",
146                    "The UE concerned, if any",
147                    false,
148                ),
149                p(
150                    "ran_ue_ngap_id",
151                    "number",
152                    "The UE concerned, if any",
153                    false,
154                ),
155            ],
156            json!({"type": ERROR_INDICATION, "cause": "protocol/semantic-error"}),
157        ),
158        action(
159            NG_SETUP,
160            "Repeat NG Setup with this gNB's configuration (it is done on connect already).",
161            vec![],
162            json!({"type": NG_SETUP}),
163        ),
164        action(
165            "disconnect",
166            "Close the association to the AMF.",
167            vec![],
168            json!({"type": "disconnect"}),
169        ),
170    ]
171}
172
173fn ev(id: &str, description: &str, params: Vec<Parameter>) -> EventType {
174    EventType::new(id, description, actions()[0].example.clone())
175        .with_parameters(params)
176        .with_actions(actions())
177}
178
179pub static CONNECTED_EVENT: LazyLock<EventType> =
180    LazyLock::new(|| {
181        ev(
182        "ngap_connected",
183        "NG Setup succeeded: the AMF admitted this gNB. Its name, GUAMIs and supported PLMNs and \
184         slices are given. Bring a UE in with ngap_initial_ue_message.",
185        vec![
186            p("amf_name", "string", "The name the AMF gave in its NG Setup Response", true),
187            p("served_guami", "array", "The AMF's GUAMIs", true),
188            p("plmn_support", "array", "PLMNs and slices it supports", true),
189            p("transport", "string", "sctp, or the non-standard lab tcp framing", true),
190        ],
191    )
192    });
193
194pub static DOWNLINK_NAS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
195    ev(
196        "ngap_downlink_nas_transport",
197        "The AMF sent a NAS message to a UE. nas is decoded: identity_request {identity_type}, \
198         authentication_request {ngksi, rand, autn}, registration_reject {cause}, …. Answer with \
199         ngap_uplink_nas_transport using the ids given.",
200        vec![
201            p("amf_ue_ngap_id", "number", "The AMF's id for the UE", true),
202            p("ran_ue_ngap_id", "number", "This gNB's id for the UE", true),
203            p("nas", "object", "The decoded 5GMM message", true),
204        ],
205    )
206});
207
208pub static RELEASE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
209    ev(
210        "ngap_ue_context_release",
211        "The AMF released a UE (UE Context Release Command). NetGet already answered with UE \
212         Context Release Complete; the UE is gone from this gNB.",
213        vec![
214            p("amf_ue_ngap_id", "number", "The released UE", true),
215            p(
216                "cause",
217                "object",
218                "The AMF's NGAP cause for the release, {\"group\", \"value\"}",
219                true,
220            ),
221        ],
222    )
223});
224
225pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
226    ev(
227        "ngap_amf_error",
228        "The AMF sent an Error Indication or NG Reset, or a PDU NetGet does not implement. \
229         Observational.",
230        vec![
231            p("message", "string", "What arrived: error_indication, ng_reset, or the name of a PDU NetGet does not implement", true),
232            p("cause", "object", "The NGAP cause the AMF gave, {\"group\", \"value\"}, when it gave one", false),
233        ],
234    )
235});
236
237pub fn plmn(ctx: &ConnectContext) -> Result<codec::Plmn> {
238    let get = |k: &str, d: &str| -> Result<String> {
239        Ok(ctx
240            .startup_params
241            .as_ref()
242            .map(|p| p.get_optional_string(k))
243            .transpose()?
244            .flatten()
245            .unwrap_or_else(|| d.to_string()))
246    };
247    Ok(codec::Plmn::from_json(
248        &json!({"mcc": get("mcc", DEFAULT_MCC)?, "mnc": get("mnc", DEFAULT_MNC)?}),
249    )?)
250}
251
252/// Validate an action without sending it.
253pub fn check(v: &Value) -> Result<()> {
254    match v["type"].as_str().unwrap_or_default() {
255        INITIAL_UE => {
256            nas::encode(v.get("nas").unwrap_or(&Value::Null), nas::UPLINK_MESSAGES)
257                .map_err(|e| anyhow!(e))?;
258            codec::json_opt_u64(v, "ran_ue_ngap_id", codec::MAX_RAN_UE_NGAP_ID)?;
259        }
260        UPLINK_NAS => {
261            codec::json_u64(v, "amf_ue_ngap_id", codec::MAX_AMF_UE_NGAP_ID)?;
262            codec::json_u64(v, "ran_ue_ngap_id", codec::MAX_RAN_UE_NGAP_ID)?;
263            nas::encode(v.get("nas").unwrap_or(&Value::Null), nas::UPLINK_MESSAGES)
264                .map_err(|e| anyhow!(e))?;
265        }
266        ERROR_INDICATION => {
267            if let Some(c) = v.get("cause").filter(|c| !c.is_null()) {
268                codec::Cause::from_json(c)?;
269            }
270        }
271        NG_SETUP => {}
272        other => bail!("Unknown NGAP client action {other:?}"),
273    }
274    Ok(())
275}
276
277fn param(
278    name: &str,
279    type_hint: &str,
280    description: &str,
281    example: Value,
282    default: Option<Value>,
283) -> ParameterDefinition {
284    ParameterDefinition {
285        name: name.into(),
286        type_hint: type_hint.into(),
287        description: description.into(),
288        required: false,
289        example,
290        default,
291    }
292}
293
294impl Protocol for NgapClientProtocol {
295    fn protocol_name(&self) -> &'static str {
296        "NGAP"
297    }
298    fn stack_name(&self) -> &'static str {
299        "ETH>IP>SCTP>NGAP"
300    }
301    fn keywords(&self) -> Vec<&'static str> {
302        vec!["ngap", "ngap client", "gnb", "n2", "5g ran"]
303    }
304    fn description(&self) -> &'static str {
305        "NGAP gNB (5G N2): sets up N2 with an AMF and brings UEs in with NAS registration"
306    }
307    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
308        actions()
309    }
310    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
311        vec![]
312    }
313    fn get_event_types(&self) -> Vec<EventType> {
314        vec![
315            CONNECTED_EVENT.clone(),
316            DOWNLINK_NAS_EVENT.clone(),
317            RELEASE_EVENT.clone(),
318            ERROR_EVENT.clone(),
319        ]
320    }
321    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
322        vec![
323            param("transport", "string", "\"sctp\" (default, TS 38.412) or \"tcp\" — NON-STANDARD length-prefixed framing for local work, which no real AMF speaks", json!("sctp"), None),
324            param("mcc", "string", "The gNB's PLMN: mobile country code, three digits", json!("001"), Some(json!(DEFAULT_MCC))),
325            param("mnc", "string", "The gNB's PLMN: mobile network code, two or three digits", json!("01"), Some(json!(DEFAULT_MNC))),
326            param("gnb_id", "number", "The gNB ID sent in NG Setup (its GlobalRANNodeID), gnb_id_bits wide", json!(1), Some(json!(DEFAULT_GNB_ID))),
327            param("gnb_id_bits", "number", "Length of the gNB ID in bits, 22-32", json!(32), Some(json!(DEFAULT_GNB_ID_BITS))),
328            param("tac", "number", "The tracking area code served (24 bits)", json!(1), Some(json!(DEFAULT_TAC))),
329            param("sst", "number", "Slice/service type broadcast (0-255)", json!(1), Some(json!(DEFAULT_SST))),
330            param("sd", "string", "Slice differentiator, six hex digits (default: none)", json!("010203"), None),
331            param("nr_cell_id", "number", "The NR cell identity UEs are reported in (36 bits)", json!(16), Some(json!(DEFAULT_NR_CELL_ID))),
332            param("ran_node_name", "string", "The RAN node name sent in NG Setup", json!("netget-gnb"), Some(json!(DEFAULT_RAN_NODE_NAME))),
333        ]
334    }
335    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
336        use crate::protocol::metadata::*;
337        ProtocolMetadataV2::builder()
338            .state(DevelopmentState::Experimental)
339            .privilege_requirement(PrivilegeRequirement::None)
340            .implementation("The AMF role's APER codec and 5GMM subset (src/server/ngap) as a gNB: SCTP association (PPID 60, stream 0/1) or the labelled lab TCP framing, NG Setup from the startup parameters on connect, RAN UE NGAP ID allocation, UE Context Release Complete and NG Reset Acknowledge answered in Rust")
341            .llm_control("Which UEs to bring in and with what NAS Registration Request (SUCI or 5G-GUTI, registration type), and how each UE answers the AMF's identity and authentication requests")
342            .e2e_testing("tests/client/ngap: Open5GS's AMF (open5gs-amfd) over real SCTP on Linux accepts NG Setup and processes the model's Registration Request; a canned AMF over the lab transport for bounds, follow-up depth and the model's actions")
343            .notes("Experimental: one independent AMF (Open5GS), runnable only where SCTP exists. No NAS security: RES* cannot be computed without a subscriber key, so registration ends at authentication. No PDU sessions or user plane. Handler chains stop after 8 follow-ups.")
344            .max_inbound_bytes(codec::MAX_PDU_LEN)
345            .build()
346    }
347    fn example_prompt(&self) -> &'static str {
348        "Connect a gNB for PLMN 001-01 to the AMF at 127.0.0.5 and register one UE with SUCI MSIN 0000000001"
349    }
350    fn get_startup_examples(&self) -> StartupExamples {
351        let llm = json!({"type": "open_client", "protocol": "ngap", "remote_addr": "127.0.0.5:38412",
352            "startup_params": {"mcc": "001", "mnc": "01", "tac": 1, "sst": 1},
353            "instruction": "Register one UE with a null-scheme SUCI (MSIN 0000000001) and answer the AMF's identity request with the same SUCI"});
354        let mut static_example = llm.clone();
355        static_example["event_handlers"] = json!([
356            {"event_pattern": "ngap_connected", "handler": {"type": "static", "actions": [actions()[0].example.clone()]}},
357            {"event_pattern": "*", "handler": {"type": "static", "actions": []}}
358        ]);
359        let mut scripted = llm.clone();
360        scripted["event_handlers"] = json!([{"event_pattern": "*", "handler": {"type": "script", "language": "python",
361            "code": "import json,sys\ni=json.load(sys.stdin); e=i['event']; t=i['event_type_id']\nsuci={'type':'suci','plmn':{'mcc':'001','mnc':'01'},'routing_indicator':'0000','msin':'0000000001'}\na=[]\nif t=='ngap_connected': a=[{'type':'ngap_initial_ue_message','nas':{'message':'registration_request','mobile_identity':suci}}]\nelif t=='ngap_downlink_nas_transport' and e['nas'].get('message')=='identity_request': a=[{'type':'ngap_uplink_nas_transport','amf_ue_ngap_id':e['amf_ue_ngap_id'],'ran_ue_ngap_id':e['ran_ue_ngap_id'],'nas':{'message':'identity_response','mobile_identity':suci}}]\nprint(json.dumps({'actions':a}))"}}]);
362        StartupExamples::new(llm, scripted, static_example)
363    }
364    fn group_name(&self) -> &'static str {
365        "Routing"
366    }
367}
368
369impl Client for NgapClientProtocol {
370    fn connect(
371        &self,
372        ctx: ConnectContext,
373    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
374    {
375        Box::pin(super::connect(ctx))
376    }
377    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
378        let name = v["type"].as_str().unwrap_or_default().to_string();
379        if name == "disconnect" {
380            return Ok(ClientActionResult::Disconnect);
381        }
382        check(&v)?;
383        Ok(ClientActionResult::Custom { name, data: v })
384    }
385}