Skip to main content

netget/client/pfcp/
actions.rs

1//! What the model can do as a PFCP control plane (an SMF) towards one UPF: associate,
2//! heartbeat, and establish, modify and delete sessions — and answer what the UPF asks.
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, ParameterDefinition, StartupExamples,
7};
8use crate::protocol::{ConnectContext, EventType};
9use crate::server::pfcp::actions::{action, ies_param, p};
10use crate::server::pfcp::wire;
11use crate::state::app_state::AppState;
12use anyhow::{bail, Context, Result};
13use serde_json::{json, Value};
14use std::sync::LazyLock;
15
16#[derive(Default)]
17pub struct PfcpClientProtocol;
18impl PfcpClientProtocol {
19    pub fn new() -> Self {
20        Self
21    }
22}
23
24fn associate() -> ActionDefinition {
25    action(
26        "pfcp_associate",
27        "Send an Association Setup Request (Node ID and Recovery Time Stamp are added); the answer arrives as pfcp_response.",
28        vec![ies_param("Extra IEs, e.g. {\"cp_function_features\": {\"hex\": \"0000\"}}")],
29        json!({"type":"pfcp_associate"}),
30    )
31}
32
33fn heartbeat() -> ActionDefinition {
34    action(
35        "pfcp_heartbeat",
36        "Send a Heartbeat Request; the answer arrives as pfcp_response.",
37        vec![],
38        json!({"type":"pfcp_heartbeat"}),
39    )
40}
41
42fn establish() -> ActionDefinition {
43    action(
44        "pfcp_establish_session",
45        "Ask the UPF for a session. Node ID and the CP F-SEID (a new session id) are added; give the rules as IEs.",
46        vec![ies_param("create_pdr, create_far, create_urr, create_qer …, e.g. {\"create_pdr\": [{\"pdr_id\": 1, \"precedence\": 255, \"pdi\": {\"source_interface\": \"access\", \"f_teid\": {\"choose\": true}}, \"far_id\": 1}], \"create_far\": [{\"far_id\": 1, \"apply_action\": [\"FORW\"], \"forwarding_parameters\": {\"destination_interface\": \"core\"}}]}")],
47        json!({"type":"pfcp_establish_session","ies":{"create_pdr":[{"pdr_id":1,"precedence":255,"pdi":{"source_interface":"access","f_teid":{"choose":true}},"far_id":1}],
48               "create_far":[{"far_id":1,"apply_action":["FORW"],"forwarding_parameters":{"destination_interface":"core"}}]}}),
49    )
50}
51
52fn modify() -> ActionDefinition {
53    action(
54        "pfcp_modify_session",
55        "Modify an established session (update_far, update_pdr, create_* / remove_* IEs).",
56        vec![
57            p(
58                "cp_seid",
59                "number",
60                "The session, by the id this client gave it (pfcp_response's cp_seid)",
61                true,
62            ),
63            ies_param(
64                "e.g. {\"update_far\": {\"far_id\": 2, \"apply_action\": [\"BUFF\", \"NOCP\"]}}",
65            ),
66        ],
67        json!({"type":"pfcp_modify_session","cp_seid":1,"ies":{"update_far":{"far_id":1,"apply_action":["DROP"]}}}),
68    )
69}
70
71fn delete() -> ActionDefinition {
72    action(
73        "pfcp_delete_session",
74        "Delete an established session.",
75        vec![p(
76            "cp_seid",
77            "number",
78            "The session, by the id this client gave it",
79            true,
80        )],
81        json!({"type":"pfcp_delete_session","cp_seid":1}),
82    )
83}
84
85fn release() -> ActionDefinition {
86    action(
87        "pfcp_release_association",
88        "Release the association (Node ID added).",
89        vec![],
90        json!({"type":"pfcp_release_association"}),
91    )
92}
93
94fn respond() -> ActionDefinition {
95    action(
96        "pfcp_respond",
97        "Answer a request the UPF sent (pfcp_request), e.g. a Session Report Request.",
98        vec![
99            p(
100                "sequence",
101                "number",
102                "The request's sequence number, from pfcp_request",
103                true,
104            ),
105            p(
106                "cause",
107                "string",
108                "request_accepted (default) or a rejection cause",
109                false,
110            ),
111            ies_param("IEs to add to the response"),
112        ],
113        json!({"type":"pfcp_respond","sequence":1,"cause":"request_accepted"}),
114    )
115}
116
117fn disconnect() -> ActionDefinition {
118    action(
119        "disconnect",
120        "Stop the client (no Association Release is sent).",
121        vec![],
122        json!({"type":"disconnect"}),
123    )
124}
125
126pub fn actions() -> Vec<ActionDefinition> {
127    vec![
128        associate(),
129        heartbeat(),
130        establish(),
131        modify(),
132        delete(),
133        release(),
134        respond(),
135        disconnect(),
136    ]
137}
138
139fn ev(id: &str, description: &str, params: Vec<crate::llm::actions::Parameter>) -> EventType {
140    EventType::new(id, description, establish().example.clone())
141        .with_parameters(params)
142        .with_actions(actions())
143}
144
145pub static READY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
146    ev(
147        "pfcp_ready",
148        "The client is ready to talk to the UPF; associate first.",
149        vec![
150            p("upf", "string", "The UPF's address and port", true),
151            p("node_id", "object", "This SMF's Node ID", true),
152        ],
153    )
154});
155
156pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
157    ev(
158        "pfcp_response",
159        "The UPF answered a request.",
160        vec![
161            p(
162                "request",
163                "string",
164                "The request it answers, e.g. session_establishment_request",
165                true,
166            ),
167            p(
168                "message",
169                "string",
170                "The response, e.g. session_establishment_response",
171                true,
172            ),
173            p(
174                "cause",
175                "string",
176                "The response's cause, e.g. request_accepted",
177                false,
178            ),
179            p(
180                "cp_seid",
181                "number",
182                "The session, for session messages",
183                false,
184            ),
185            p("up_seid", "number", "The UPF's id for that session", false),
186            p("ies", "object", "The response's IEs keyed by name", true),
187        ],
188    )
189});
190
191pub static TIMEOUT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
192    ev(
193        "pfcp_timeout",
194        "A request went unanswered after its retransmissions.",
195        vec![
196            p(
197                "request",
198                "string",
199                "The request that went unanswered",
200                true,
201            ),
202            p("sequence", "number", "Its sequence number", true),
203        ],
204    )
205});
206
207pub static REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
208    ev(
209        "pfcp_request",
210        "The UPF sent a request (other than a heartbeat, which is answered automatically). Answer with pfcp_respond.",
211        vec![
212            p("message", "string", "The request, e.g. session_report_request", true),
213            p("sequence", "number", "Its sequence number, for pfcp_respond", true),
214            p("cp_seid", "number", "The session it is about, if any", false),
215            p("ies", "object", "Its IEs keyed by name", true),
216        ],
217    )
218});
219
220pub fn cp_seid(v: &Value) -> Result<u64> {
221    v["cp_seid"].as_u64().context("cp_seid is required")
222}
223
224pub fn check(v: &Value) -> Result<()> {
225    let ies = v.get("ies").unwrap_or(&Value::Null);
226    match v["type"].as_str().unwrap_or_default() {
227        "pfcp_associate" | "pfcp_establish_session" | "pfcp_modify_session" | "pfcp_respond" => {
228            wire::encode(5, None, 0, ies)?;
229        }
230        "pfcp_heartbeat" | "pfcp_delete_session" | "pfcp_release_association" => {}
231        other => bail!("Unknown PFCP client action {other:?}"),
232    }
233    if matches!(
234        v["type"].as_str(),
235        Some("pfcp_modify_session" | "pfcp_delete_session")
236    ) {
237        cp_seid(v)?;
238    }
239    if v["type"] == "pfcp_respond" {
240        v["sequence"]
241            .as_u64()
242            .context("pfcp_respond needs the request's sequence")?;
243        if let Some(c) = v["cause"].as_str() {
244            wire::cause_code(c)?;
245        }
246    }
247    Ok(())
248}
249
250impl Protocol for PfcpClientProtocol {
251    fn protocol_name(&self) -> &'static str {
252        "PFCP"
253    }
254    fn stack_name(&self) -> &'static str {
255        "ETH>IP>UDP>PFCP"
256    }
257    fn keywords(&self) -> Vec<&'static str> {
258        vec![
259            "pfcp",
260            "pfcp client",
261            "smf",
262            "n4",
263            "session management function",
264        ]
265    }
266    fn description(&self) -> &'static str {
267        "PFCP control plane (an SMF on the 5G N4 interface): associates with a UPF and establishes, modifies and deletes sessions"
268    }
269    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
270        actions()
271    }
272    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
273        vec![]
274    }
275    fn get_event_types(&self) -> Vec<EventType> {
276        vec![
277            READY_EVENT.clone(),
278            RESPONSE_EVENT.clone(),
279            TIMEOUT_EVENT.clone(),
280            REQUEST_EVENT.clone(),
281        ]
282    }
283    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
284        vec![
285            ParameterDefinition {
286                name: "node_id".into(),
287                type_hint: "string".into(),
288                description: "This SMF's Node ID: an IPv4 address or an FQDN (default: the address it sends from)".into(),
289                required: false,
290                example: json!("smf.example.net"),
291                default: None,
292            },
293            ParameterDefinition {
294                name: "local_port".into(),
295                type_hint: "number".into(),
296                description: "UDP port to send from and listen on (default: an ephemeral port; 8805 to be reachable where a UPF expects an SMF)".into(),
297                required: false,
298                example: json!(8805),
299                default: None,
300            },
301        ]
302    }
303    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
304        use crate::protocol::metadata::*;
305        ProtocolMetadataV2::builder()
306            .state(DevelopmentState::Experimental)
307            .privilege_requirement(PrivilegeRequirement::None)
308            .implementation("The server's PFCP codec (src/server/pfcp/wire.rs) as the CP function: sequence numbers, retransmission (T1 3 s, N1 3), a session table mapping its CP SEIDs to the UPF's SEIDs, heartbeats from the UPF answered in Rust, other UPF requests handed to the model")
309            .llm_control("When to associate and heartbeat, which sessions to establish with which PDRs/FARs/URRs/QERs, how to modify and delete them, and how to answer the UPF's requests")
310            .e2e_testing("tests/client/pfcp: wmnsk/go-pfcp as the UPF, which decodes every request NetGet sends and heartbeats NetGet back")
311            .notes("One UPF per client. The model writes rule IEs as JSON; Node ID, F-SEID and Recovery Time Stamp are added in Rust.")
312            .max_inbound_bytes(wire::MAX_DATAGRAM)
313            .build()
314    }
315    fn example_prompt(&self) -> &'static str {
316        "Associate with the UPF at 192.0.2.2 and open a session for UE 10.60.0.1"
317    }
318    fn get_startup_examples(&self) -> StartupExamples {
319        let llm = json!({"type":"open_client","protocol":"pfcp","remote_addr":"127.0.0.1:8805",
320            "instruction":"Associate, then establish one session with an uplink PDR that asks for an F-TEID"});
321        let mut static_example = llm.clone();
322        static_example["event_handlers"] = json!([
323            {"event_pattern":"pfcp_ready","handler":{"type":"static","actions":[associate().example]}},
324            {"event_pattern":"*","handler":{"type":"static","actions":[]}}
325        ]);
326        let mut scripted = static_example.clone();
327        scripted["event_handlers"][1] = json!({"event_pattern":"pfcp_response","handler":{"type":"script","language":"python",
328            "code":"import json,sys\ne=json.load(sys.stdin)['event']\nprint(json.dumps({'actions':[{'type':'pfcp_establish_session','ies':{'create_pdr':{'pdr_id':1,'precedence':255,'pdi':{'source_interface':'access','f_teid':{'choose':True}},'far_id':1},'create_far':{'far_id':1,'apply_action':['FORW'],'forwarding_parameters':{'destination_interface':'core'}}}}] if e['request']=='association_setup_request' else []}))"}});
329        StartupExamples::new(llm, scripted, static_example)
330    }
331    fn group_name(&self) -> &'static str {
332        "Routing"
333    }
334}
335
336impl Client for PfcpClientProtocol {
337    fn connect(
338        &self,
339        ctx: ConnectContext,
340    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
341    {
342        Box::pin(super::connect(ctx))
343    }
344    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
345        if v["type"] == "disconnect" {
346            return Ok(ClientActionResult::Disconnect);
347        }
348        check(&v)?;
349        Ok(ClientActionResult::Custom {
350            name: v["type"].as_str().unwrap_or_default().to_string(),
351            data: v,
352        })
353    }
354}