1use 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}