Skip to main content

netget/server/gtp/
mod.rs

1//! GTP-C / GTP-U server — a mobile-core node driven by the model.
2//!
3//! Two UDP sockets: the control plane (2123 by default), where GTPv1-C and GTPv2-C session
4//! management arrives, and the user plane (2152), where subscribers' own IP packets arrive
5//! wrapped in G-PDUs. Both ports are above 1023, so this server needs no privilege at all.
6//!
7//! This file owns the sockets, the version demultiplex, the event payloads and — the part
8//! that matters — **the fail-closed rule**. `codec.rs` owns the bytes; `actions.rs` owns the
9//! model's vocabulary. See `src/server/gtp/CLAUDE.md`.
10//!
11//! # Fail closed
12//!
13//! A subscriber session is network access. When the model does not answer, or the LLM call
14//! fails, this server answers with a **refusing** cause — never a success one, and never the
15//! error text (`crate::utils::wire_failure` classifies; the peer gets a category, the log
16//! gets the error). Echo Requests and G-PDUs get *silence* instead, because every reply they
17//! define is a positive assertion: an Echo Response asserts this node is healthy, and
18//! fabricating that during an outage is the `openvpn` mistake in miniature.
19//!
20//! Every path logs a stable `decision=` token, so `model_reject` and `fail_closed_llm_error`
21//! can never be confused for one another. That is the RADIUS discipline, and the reason for
22//! it is in the root `CLAUDE.md`'s OAuth2 post-mortem.
23
24pub mod actions;
25pub mod codec;
26
27pub use actions::GtpProtocol;
28
29use anyhow::{Context, Result};
30use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
31use std::sync::Arc;
32use tokio::net::UdpSocket;
33use tokio::sync::mpsc;
34use tracing::{debug, warn};
35
36use crate::llm::action_helper::call_llm;
37use crate::llm::ollama_client::OllamaClient;
38use crate::llm::ActionResult;
39use crate::logging::emit::Log;
40use crate::protocol::{Event, SpawnContext};
41use crate::server::connection::ConnectionId;
42use crate::state::app_state::AppState;
43use crate::state::ServerId;
44use crate::utils::wire_failure::WireFailure;
45
46use actions::{
47    GTP_CREATE_SESSION_REQUEST_EVENT, GTP_DELETE_SESSION_REQUEST_EVENT, GTP_ECHO_REQUEST_EVENT,
48    GTP_GPDU_RECEIVED_EVENT, GTP_UPDATE_CONTEXT_REQUEST_EVENT,
49};
50use codec::{GtpV1Header, GtpV1Ie, GtpV1Message, GtpV2Header, GtpV2Ie, GtpV2Message, GtpVersion};
51
52/// Which socket a datagram arrived on. GTP runs path management independently per plane.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum Plane {
55    Control,
56    User,
57}
58
59impl Plane {
60    fn as_str(self) -> &'static str {
61        match self {
62            Plane::Control => "control",
63            Plane::User => "user",
64        }
65    }
66}
67
68/// How a reply was arrived at.
69///
70/// The distinction the tokens preserve is the one OAuth2 lost: **a model that refuses and a
71/// model that is unreachable must never look the same**, in the log or on the wire.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub enum Decision {
74    ModelEcho,
75    /// The model granted or confirmed something — a session created, an update kept.
76    ModelAccept,
77    /// The model refused, with a cause it chose.
78    ModelReject,
79    ModelGpdu,
80    ModelErrorIndication,
81    /// The model deliberately said nothing (`no_response`).
82    ModelSilent,
83    /// The model produced no usable action. The server refuses.
84    FailClosedNoAction,
85    /// The model's action could not be turned into a packet. The server refuses.
86    FailClosedActionError,
87    /// The LLM call itself failed. The server refuses.
88    FailClosedLlmError,
89}
90
91impl Decision {
92    /// Stable, grep-able token. `decision=fail_closed_` finds every request the model did not
93    /// actually answer.
94    pub fn as_str(self) -> &'static str {
95        match self {
96            Decision::ModelEcho => "model_echo",
97            Decision::ModelAccept => "model_accept",
98            Decision::ModelReject => "model_reject",
99            Decision::ModelGpdu => "model_gpdu",
100            Decision::ModelErrorIndication => "model_error_indication",
101            Decision::ModelSilent => "model_silent",
102            Decision::FailClosedNoAction => "fail_closed_no_action",
103            Decision::FailClosedActionError => "fail_closed_action_error",
104            Decision::FailClosedLlmError => "fail_closed_llm_error",
105        }
106    }
107
108    pub fn is_fail_closed(self) -> bool {
109        matches!(
110            self,
111            Decision::FailClosedNoAction
112                | Decision::FailClosedActionError
113                | Decision::FailClosedLlmError
114        )
115    }
116}
117
118/// Everything a datagram handler needs, gathered so the handlers do not take a dozen
119/// arguments each.
120#[derive(Clone)]
121struct Shared {
122    llm_client: OllamaClient,
123    state: Arc<AppState>,
124    status_tx: mpsc::UnboundedSender<String>,
125    server_id: ServerId,
126    control_socket: Arc<UdpSocket>,
127    user_socket: Option<Arc<UdpSocket>>,
128    control_addr: SocketAddr,
129    user_addr: Option<SocketAddr>,
130}
131
132impl Shared {
133    fn socket_for(&self, plane: Plane) -> &Arc<UdpSocket> {
134        match plane {
135            Plane::User => self.user_socket.as_ref().unwrap_or(&self.control_socket),
136            Plane::Control => &self.control_socket,
137        }
138    }
139
140    fn local_addr(&self, plane: Plane) -> SocketAddr {
141        match plane {
142            Plane::User => self.user_addr.unwrap_or(self.control_addr),
143            Plane::Control => self.control_addr,
144        }
145    }
146
147    /// Address to advertise for this node's control plane in a GSN Address or F-TEID.
148    fn advertised_control_ip(&self) -> IpAddr {
149        concrete_ip(self.control_addr.ip())
150    }
151
152    /// The same for the user plane.
153    fn advertised_user_ip(&self) -> IpAddr {
154        concrete_ip(self.user_addr.unwrap_or(self.control_addr).ip())
155    }
156}
157
158/// A wildcard bind gives an unspecified address, which is useless inside an F-TEID or a GSN
159/// Address — the peer would send traffic to 0.0.0.0. Substitute the loopback so the field is
160/// at least well-formed, and say so in the docs rather than pretending to know the right one.
161fn concrete_ip(ip: IpAddr) -> IpAddr {
162    match ip {
163        IpAddr::V4(v4) if v4.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
164        IpAddr::V6(v6) if v6.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
165        other => other,
166    }
167}
168
169/// What the request was, reduced to the fields a reply needs.
170struct RequestContext {
171    version: GtpVersion,
172    message_type: u8,
173    message_name: &'static str,
174    sequence: u32,
175    /// TEID to put in the reply header unless the model overrides it.
176    default_response_teid: u32,
177    /// NSAPI (GTPv1) or EPS Bearer Identity (GTPv2), when the request named one.
178    bearer_id: Option<u8>,
179    plane: Plane,
180    peer_addr: SocketAddr,
181}
182
183pub struct GtpServer;
184
185impl GtpServer {
186    /// Bind both planes and start serving.
187    ///
188    /// Returns `Err` — so `server_startup` records `ServerStatus::Error` rather than a server
189    /// that lies about being up — when either socket cannot be bound or a startup parameter
190    /// is unusable.
191    pub async fn spawn_with_llm_actions(ctx: SpawnContext) -> Result<SocketAddr> {
192        let listen_addr = ctx.legacy_listen_addr();
193        let SpawnContext {
194            llm_client,
195            state,
196            status_tx,
197            server_id,
198            startup_params,
199            ..
200        } = ctx;
201
202        // Startup parameters are untrusted model/MCP input: propagate with `?`, never
203        // `unwrap()`, or an undeclared key kills the task that would have reported it.
204        let (requested_user_port, enable_user_plane) = match &startup_params {
205            Some(params) => {
206                let port = match params.get_optional_u64("user_plane_port")? {
207                    Some(v) if v <= u16::MAX as u64 => Some(v as u16),
208                    Some(v) => anyhow::bail!(
209                        "GTP user_plane_port must be a UDP port number 0-65535, got {v}"
210                    ),
211                    None => None,
212                };
213                (
214                    port,
215                    params
216                        .get_optional_bool("enable_user_plane")?
217                        .unwrap_or(true),
218                )
219            }
220            None => (None, true),
221        };
222
223        let control_socket =
224            Arc::new(UdpSocket::bind(listen_addr).await.with_context(|| {
225                format!("GTP failed to bind the control plane on {listen_addr}")
226            })?);
227        let control_addr = control_socket.local_addr()?;
228
229        let log = Log::new(Some(&status_tx));
230        log.info(format!("GTP-C server listening on {control_addr}"));
231
232        let (user_socket, user_addr) = if enable_user_plane {
233            // 2152 only when the control plane is on its own standard port. Anywhere else —
234            // an e2e test on an ephemeral port, two instances side by side — a fixed 2152
235            // would collide with whatever else is running, so take an ephemeral one.
236            let port = requested_user_port.unwrap_or({
237                if control_addr.port() == codec::GTPC_PORT {
238                    codec::GTPU_PORT
239                } else {
240                    0
241                }
242            });
243            let addr = SocketAddr::new(control_addr.ip(), port);
244            let socket = Arc::new(UdpSocket::bind(addr).await.with_context(|| {
245                format!(
246                    "GTP failed to bind the user plane on {addr}. Pass a different \
247                         user_plane_port, or enable_user_plane: false for a control-plane-only \
248                         node."
249                )
250            })?);
251            let local = socket.local_addr()?;
252            // Deliberately not the words "listening on": the e2e harness scans stdout for
253            // that phrase to learn a port-0 server's real port, and a second match would
254            // hand it the user-plane port for the server it thinks is the control plane.
255            log.info(format!("GTP-U user plane bound to {local}"));
256            (Some(socket), Some(local))
257        } else {
258            log.info("GTP-U disabled by enable_user_plane: false; control plane only");
259            (None, None)
260        };
261
262        let shared = Shared {
263            llm_client,
264            state: state.clone(),
265            status_tx: status_tx.clone(),
266            server_id,
267            control_socket: control_socket.clone(),
268            user_socket: user_socket.clone(),
269            control_addr,
270            user_addr,
271        };
272
273        let control_loop = tokio::spawn(Self::receive_loop(
274            shared.clone(),
275            control_socket,
276            Plane::Control,
277        ));
278        state.register_server_task(server_id, control_loop).await;
279
280        if let Some(socket) = user_socket {
281            let user_loop = tokio::spawn(Self::receive_loop(shared, socket, Plane::User));
282            state.register_server_task(server_id, user_loop).await;
283        }
284
285        Ok(control_addr)
286    }
287
288    async fn receive_loop(shared: Shared, socket: Arc<UdpSocket>, plane: Plane) {
289        let local_addr = shared.local_addr(plane);
290        Log::new(Some(&shared.status_tx)).info(format!(
291            "GTP {} plane receive loop started on {local_addr}",
292            plane.as_str()
293        ));
294
295        // A G-PDU carries a whole user IP packet, and GTP-U is routinely offered a 1500-octet
296        // inner MTU plus headers. 2048 covers that with headroom; 65535 would be a jumbogram.
297        let mut buffer = vec![0u8; 2048];
298
299        loop {
300            let (n, peer_addr) = match socket.recv_from(&mut buffer).await {
301                Ok(pair) => pair,
302                Err(e) => {
303                    Log::new(Some(&shared.status_tx))
304                        .error(format!("GTP {} plane receive error: {e}", plane.as_str()));
305                    break;
306                }
307            };
308
309            let data = buffer[..n].to_vec();
310            let shared = shared.clone();
311            // Tracked, not detached: stop_server must abort this task too.
312            let task_owner = shared.state.clone();
313            task_owner
314                .spawn_server_task(shared.server_id, async move {
315                    Self::handle_datagram(shared, data, peer_addr, plane).await;
316                })
317                .await;
318        }
319    }
320
321    async fn handle_datagram(shared: Shared, data: Vec<u8>, peer_addr: SocketAddr, plane: Plane) {
322        let connection_id = Self::record_connection(&shared, peer_addr, plane, data.len()).await;
323
324        let log = Log::new(Some(&shared.status_tx));
325        log.debug(format!(
326            "GTP received {} octets from {peer_addr} on the {} plane",
327            data.len(),
328            plane.as_str()
329        ));
330        log.trace(format!("GTP received (hex): {}", hex::encode(&data)));
331
332        match codec::peek_version(&data) {
333            Some(1) => Self::handle_v1(shared, data, peer_addr, plane, connection_id).await,
334            Some(2) => Self::handle_v2(shared, data, peer_addr, plane, connection_id).await,
335            Some(other) => {
336                // TS 29.060 §11.1.1: a node that receives a message of an unsupported version
337                // answers Version Not Supported, in the highest version it does support. This
338                // is mechanical — there is nothing to decide — so it never reaches the model.
339                warn!("GTP version {other} from {peer_addr} is not supported");
340                if plane == Plane::Control {
341                    let reply = GtpV1Message {
342                        header: GtpV1Header::with_sequence(codec::V1_VERSION_NOT_SUPPORTED, 0, 0),
343                        body: Vec::new(),
344                    }
345                    .encode();
346                    Self::send(&shared, &reply, peer_addr, plane, connection_id).await;
347                }
348            }
349            None => debug!("GTP empty datagram from {peer_addr} ignored"),
350        }
351    }
352
353    // -----------------------------------------------------------------------
354    // GTPv1
355    // -----------------------------------------------------------------------
356
357    async fn handle_v1(
358        shared: Shared,
359        data: Vec<u8>,
360        peer_addr: SocketAddr,
361        plane: Plane,
362        connection_id: ConnectionId,
363    ) {
364        let message = match GtpV1Message::decode(&data) {
365            Ok(m) => m,
366            Err(e) => {
367                Log::new(Some(&shared.status_tx))
368                    .warn(format!("GTPv1 dropped datagram from {peer_addr}: {e}"));
369                return;
370            }
371        };
372
373        let message_type = message.header.message_type;
374        let message_name = codec::v1_message_name(message_type);
375        let sequence = message.header.sequence.unwrap_or(0) as u32;
376
377        // A G-PDU is not IE-encoded: its body is the subscriber's own packet.
378        if message_type == codec::V1_G_PDU {
379            let ctx = RequestContext {
380                version: GtpVersion::V1,
381                message_type,
382                message_name,
383                sequence,
384                default_response_teid: message.header.teid,
385                bearer_id: None,
386                plane,
387                peer_addr,
388            };
389            let data = Self::gpdu_event_data(&message, peer_addr);
390            Self::consult(shared, ctx, &GTP_GPDU_RECEIVED_EVENT, data, connection_id).await;
391            return;
392        }
393
394        let ies = match codec::parse_v1_ies(&message.body) {
395            Ok(ies) => ies,
396            Err(e) => {
397                Log::new(Some(&shared.status_tx)).warn(format!(
398                    "GTPv1 {message_name} from {peer_addr} has unparseable information \
399                     elements: {e}"
400                ));
401                return;
402            }
403        };
404
405        // The peer's own control TEID is what a response header must carry. On a first
406        // contact the request header's TEID is 0, so IE 17 is the only source — and this
407        // server holds no context table to look it up in later, deliberately.
408        let peer_control_teid = codec::find_v1(&ies, codec::V1_IE_TEID_CONTROL_PLANE)
409            .and_then(|v| v.try_into().ok())
410            .map(u32::from_be_bytes);
411        let peer_data_teid = codec::find_v1(&ies, codec::V1_IE_TEID_DATA_I)
412            .and_then(|v| v.try_into().ok())
413            .map(u32::from_be_bytes);
414        let bearer_id = codec::find_v1(&ies, codec::V1_IE_NSAPI)
415            .and_then(|v| v.first().copied())
416            .map(|v| v & 0x0F);
417
418        let ctx = RequestContext {
419            version: GtpVersion::V1,
420            message_type,
421            message_name,
422            sequence,
423            default_response_teid: peer_control_teid.unwrap_or(message.header.teid),
424            bearer_id,
425            plane,
426            peer_addr,
427        };
428
429        let mut base = serde_json::json!({
430            "version": 1,
431            "message": message_name,
432            "sequence": sequence,
433            "teid": message.header.teid,
434            "source_address": peer_addr.to_string(),
435            "information_elements": Self::v1_ie_summary(&ies),
436        });
437
438        match message_type {
439            codec::V1_ECHO_REQUEST => {
440                base["plane"] = serde_json::json!(plane.as_str());
441                Self::consult(shared, ctx, &GTP_ECHO_REQUEST_EVENT, base, connection_id).await;
442            }
443            codec::V1_CREATE_PDP_CONTEXT_REQUEST => {
444                Self::add_v1_subscriber_fields(&mut base, &ies);
445                base["control_teid"] = serde_json::json!(peer_control_teid);
446                base["data_teid"] = serde_json::json!(peer_data_teid);
447                base["nsapi"] = serde_json::json!(bearer_id);
448                Self::consult(
449                    shared,
450                    ctx,
451                    &GTP_CREATE_SESSION_REQUEST_EVENT,
452                    base,
453                    connection_id,
454                )
455                .await;
456            }
457            codec::V1_UPDATE_PDP_CONTEXT_REQUEST => {
458                Self::add_v1_subscriber_fields(&mut base, &ies);
459                base["control_teid"] = serde_json::json!(peer_control_teid);
460                base["data_teid"] = serde_json::json!(peer_data_teid);
461                base["nsapi"] = serde_json::json!(bearer_id);
462                Self::consult(
463                    shared,
464                    ctx,
465                    &GTP_UPDATE_CONTEXT_REQUEST_EVENT,
466                    base,
467                    connection_id,
468                )
469                .await;
470            }
471            codec::V1_DELETE_PDP_CONTEXT_REQUEST => {
472                base["nsapi"] = serde_json::json!(bearer_id);
473                base["teardown_indicator"] =
474                    serde_json::json!(codec::find_v1(&ies, codec::V1_IE_TEARDOWN_IND)
475                        .and_then(|v| v.first().copied())
476                        .map(|v| v & 0x01 == 1));
477                Self::consult(
478                    shared,
479                    ctx,
480                    &GTP_DELETE_SESSION_REQUEST_EVENT,
481                    base,
482                    connection_id,
483                )
484                .await;
485            }
486            other => {
487                // Responses, End Marker, Error Indication and everything else: a server must
488                // not answer a response, and there is nothing to decide about a notification.
489                debug!(
490                    "GTPv1 {} (type {other}) from {peer_addr} noted and not answered",
491                    message_name
492                );
493            }
494        }
495    }
496
497    fn add_v1_subscriber_fields(base: &mut serde_json::Value, ies: &[GtpV1Ie]) {
498        // IMSI and MSISDN are subscriber identifiers. They come from the peer and are handed
499        // to the model as digits; nothing here reads any real subscriber source.
500        if let Some(v) = codec::find_v1(ies, codec::V1_IE_IMSI) {
501            base["imsi"] = serde_json::json!(codec::decode_tbcd(v));
502        }
503        if let Some(v) = codec::find_v1(ies, codec::V1_IE_MSISDN) {
504            // The MSISDN IE leads with a numbering-plan octet before the TBCD digits.
505            let digits = if v.len() > 1 {
506                codec::decode_tbcd(&v[1..])
507            } else {
508                String::new()
509            };
510            base["msisdn"] = serde_json::json!(digits);
511        }
512        if let Some(v) = codec::find_v1(ies, codec::V1_IE_ACCESS_POINT_NAME) {
513            if let Some(apn) = codec::decode_apn(v) {
514                base["apn"] = serde_json::json!(apn);
515            }
516        }
517        if let Some(v) = codec::find_v1(ies, codec::V1_IE_END_USER_ADDRESS) {
518            let (pdp_type, addr) = codec::decode_end_user_address(v);
519            base["pdp_type"] = serde_json::json!(pdp_type);
520            base["requested_address"] = serde_json::json!(addr.map(|a| a.to_string()));
521        }
522        if let Some(v) = codec::find_v1(ies, codec::V1_IE_RAT_TYPE) {
523            if let Some(rat) = v.first() {
524                base["rat_type"] = serde_json::json!(codec::rat_type_name(GtpVersion::V1, *rat));
525            }
526        }
527        // The first GSN Address is the peer's control plane (TS 29.060 §7.3.1).
528        if let Some(v) = codec::find_v1(ies, codec::V1_IE_GSN_ADDRESS) {
529            if let Some(addr) = gsn_address(v) {
530                base["peer_address"] = serde_json::json!(addr.to_string());
531            }
532        }
533    }
534
535    fn v1_ie_summary(ies: &[GtpV1Ie]) -> serde_json::Value {
536        serde_json::Value::Array(
537            ies.iter()
538                .map(|ie| {
539                    serde_json::json!({
540                        "type": ie.ie_type,
541                        "name": codec::v1_ie_name(ie.ie_type),
542                        "length": ie.value.len(),
543                    })
544                })
545                .collect(),
546        )
547    }
548
549    fn gpdu_event_data(message: &GtpV1Message, peer_addr: SocketAddr) -> serde_json::Value {
550        let mut data = serde_json::json!({
551            "teid": message.header.teid,
552            "sequence": message.header.sequence,
553            "source_address": peer_addr.to_string(),
554        });
555
556        if let Some(inner) = codec::decode_inner_ip(&message.body) {
557            data["inner_ip"] = serde_json::json!({
558                "version": inner.version,
559                "source": inner.source.to_string(),
560                "destination": inner.destination.to_string(),
561                "protocol": inner.protocol,
562                "protocol_name": inner.protocol_name,
563                "ttl": inner.ttl,
564                "length": inner.length,
565                "source_port": inner.source_port,
566                "destination_port": inner.destination_port,
567            });
568            if let Some(offset) = inner.payload_offset {
569                if offset < message.body.len() {
570                    let payload = &message.body[offset..];
571                    let printable = payload
572                        .iter()
573                        .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace());
574                    if printable {
575                        data["payload"] =
576                            serde_json::json!(String::from_utf8_lossy(payload).to_string());
577                        data["payload_encoding"] = serde_json::json!("utf8");
578                    } else {
579                        data["payload"] = serde_json::json!(hex::encode(payload));
580                        data["payload_encoding"] = serde_json::json!("hex");
581                    }
582                }
583            }
584        }
585
586        data
587    }
588
589    // -----------------------------------------------------------------------
590    // GTPv2-C
591    // -----------------------------------------------------------------------
592
593    async fn handle_v2(
594        shared: Shared,
595        data: Vec<u8>,
596        peer_addr: SocketAddr,
597        plane: Plane,
598        connection_id: ConnectionId,
599    ) {
600        if plane == Plane::User {
601            // GTP-U is version 1 only; there is no GTPv2 user plane.
602            warn!("GTPv2 datagram arrived on the user plane from {peer_addr}; dropped");
603            return;
604        }
605
606        let message = match GtpV2Message::decode(&data) {
607            Ok(m) => m,
608            Err(e) => {
609                Log::new(Some(&shared.status_tx))
610                    .warn(format!("GTPv2 dropped datagram from {peer_addr}: {e}"));
611                return;
612            }
613        };
614
615        let message_type = message.header.message_type;
616        let message_name = codec::v2_message_name(message_type);
617        let header_teid = message.header.teid.unwrap_or(0);
618
619        let sender_fteid = message
620            .find_instance(codec::V2_IE_FTEID, 0)
621            .and_then(codec::decode_fteid);
622        let bearer_id = message
623            .find(codec::V2_IE_BEARER_CONTEXT)
624            .and_then(|v| codec::parse_v2_grouped(v).ok())
625            .and_then(|inner| {
626                inner
627                    .iter()
628                    .find(|ie| ie.ie_type == codec::V2_IE_EBI)
629                    .and_then(|ie| ie.value.first().copied())
630            })
631            .or_else(|| {
632                message
633                    .find(codec::V2_IE_EBI)
634                    .and_then(|v| v.first().copied())
635            })
636            .map(|v| v & 0x0F);
637
638        let ctx = RequestContext {
639            version: GtpVersion::V2,
640            message_type,
641            message_name,
642            sequence: message.header.sequence,
643            default_response_teid: sender_fteid.map(|f| f.1).unwrap_or(header_teid),
644            bearer_id,
645            plane,
646            peer_addr,
647        };
648
649        let mut base = serde_json::json!({
650            "version": 2,
651            "message": message_name,
652            "sequence": message.header.sequence,
653            "teid": header_teid,
654            "source_address": peer_addr.to_string(),
655            "information_elements": serde_json::Value::Array(
656                message.ies.iter().map(|ie| serde_json::json!({
657                    "type": ie.ie_type,
658                    "name": codec::v2_ie_name(ie.ie_type),
659                    "instance": ie.instance,
660                    "length": ie.value.len(),
661                })).collect(),
662            ),
663        });
664
665        match message_type {
666            codec::V2_ECHO_REQUEST => {
667                base["plane"] = serde_json::json!(plane.as_str());
668                Self::consult(shared, ctx, &GTP_ECHO_REQUEST_EVENT, base, connection_id).await;
669            }
670            codec::V2_CREATE_SESSION_REQUEST => {
671                Self::add_v2_subscriber_fields(&mut base, &message, sender_fteid);
672                base["nsapi"] = serde_json::json!(bearer_id);
673                Self::consult(
674                    shared,
675                    ctx,
676                    &GTP_CREATE_SESSION_REQUEST_EVENT,
677                    base,
678                    connection_id,
679                )
680                .await;
681            }
682            codec::V2_MODIFY_BEARER_REQUEST => {
683                Self::add_v2_subscriber_fields(&mut base, &message, sender_fteid);
684                base["nsapi"] = serde_json::json!(bearer_id);
685                Self::consult(
686                    shared,
687                    ctx,
688                    &GTP_UPDATE_CONTEXT_REQUEST_EVENT,
689                    base,
690                    connection_id,
691                )
692                .await;
693            }
694            codec::V2_DELETE_SESSION_REQUEST => {
695                base["nsapi"] = serde_json::json!(bearer_id);
696                Self::consult(
697                    shared,
698                    ctx,
699                    &GTP_DELETE_SESSION_REQUEST_EVENT,
700                    base,
701                    connection_id,
702                )
703                .await;
704            }
705            other => debug!(
706                "GTPv2 {} (type {other}) from {peer_addr} noted and not answered",
707                message_name
708            ),
709        }
710    }
711
712    fn add_v2_subscriber_fields(
713        base: &mut serde_json::Value,
714        message: &GtpV2Message,
715        sender_fteid: Option<(u8, u32, Option<IpAddr>)>,
716    ) {
717        if let Some(v) = message.find(codec::V2_IE_IMSI) {
718            base["imsi"] = serde_json::json!(codec::decode_tbcd(v));
719        }
720        if let Some(v) = message.find(codec::V2_IE_MSISDN) {
721            base["msisdn"] = serde_json::json!(codec::decode_tbcd(v));
722        }
723        if let Some(v) = message.find(codec::V2_IE_APN) {
724            if let Some(apn) = codec::decode_apn(v) {
725                base["apn"] = serde_json::json!(apn);
726            }
727        }
728        if let Some(v) = message.find(codec::V2_IE_PAA) {
729            let (pdn_type, addr) = codec::decode_paa(v);
730            base["pdp_type"] = serde_json::json!(pdn_type);
731            base["requested_address"] = serde_json::json!(addr.map(|a| a.to_string()));
732        }
733        if let Some(v) = message.find(codec::V2_IE_RAT_TYPE) {
734            if let Some(rat) = v.first() {
735                base["rat_type"] = serde_json::json!(codec::rat_type_name(GtpVersion::V2, *rat));
736            }
737        }
738        if let Some((_, teid, addr)) = sender_fteid {
739            base["control_teid"] = serde_json::json!(teid);
740            base["peer_address"] = serde_json::json!(addr.map(|a| a.to_string()));
741        }
742        // The user-plane F-TEID lives inside the Bearer Context.
743        if let Some(inner) = message
744            .find(codec::V2_IE_BEARER_CONTEXT)
745            .and_then(|v| codec::parse_v2_grouped(v).ok())
746        {
747            if let Some(fteid) = inner
748                .iter()
749                .find(|ie| ie.ie_type == codec::V2_IE_FTEID)
750                .and_then(|ie| codec::decode_fteid(&ie.value))
751            {
752                base["data_teid"] = serde_json::json!(fteid.1);
753            }
754        }
755    }
756
757    // -----------------------------------------------------------------------
758    // Ask the model, then apply the fail-closed rule
759    // -----------------------------------------------------------------------
760
761    async fn consult(
762        shared: Shared,
763        ctx: RequestContext,
764        event_type: &'static crate::protocol::EventType,
765        event_data: serde_json::Value,
766        connection_id: ConnectionId,
767    ) {
768        let protocol = GtpProtocol::new();
769        let event = Event::new(event_type, event_data);
770
771        let outcome = call_llm(
772            &shared.llm_client,
773            &shared.state,
774            shared.server_id,
775            Some(connection_id),
776            &event,
777            &protocol,
778        )
779        .await;
780
781        let (decision, reply) = Self::decide(&shared, &ctx, outcome);
782
783        let summary = format!(
784            "GTP v{} {} seq={} from {} decision={}",
785            ctx.version.as_number(),
786            ctx.message_name,
787            ctx.sequence,
788            ctx.peer_addr,
789            decision.as_str()
790        );
791        let log = Log::new(Some(&shared.status_tx));
792        if decision.is_fail_closed() {
793            log.error(format!(
794                "{summary} (refused because no usable decision was produced)"
795            ));
796        } else {
797            log.info(&summary);
798        }
799
800        if let Some((bytes, plane)) = reply {
801            Self::send(&shared, &bytes, ctx.peer_addr, plane, connection_id).await;
802        }
803    }
804
805    /// **The fail-closed rule.** Returns the decision and, if anything is to be sent, the
806    /// bytes and the plane to send them on.
807    ///
808    /// - A usable model action is rendered verbatim.
809    /// - No usable action, or an LLM error, on a *session* request produces a synthesised
810    ///   response carrying a **refusing** cause, tagged `fail_closed_*` — never
811    ///   `model_reject`, because a model that denies and a backend that is down must stay
812    ///   distinguishable.
813    /// - Echo Requests and G-PDUs get silence instead: every reply they define asserts
814    ///   something positive (this node is healthy; here is a subscriber's traffic), and
815    ///   inventing that during an outage is worse than saying nothing.
816    fn decide(
817        shared: &Shared,
818        ctx: &RequestContext,
819        outcome: Result<crate::llm::ExecutionResult>,
820    ) -> (Decision, Option<(Vec<u8>, Plane)>) {
821        let log = Log::new(Some(&shared.status_tx));
822
823        let execution = match outcome {
824            Ok(result) => {
825                for message in &result.messages {
826                    log.info(message);
827                }
828                result
829            }
830            Err(e) => {
831                // The peer gets a category, the log gets the error. `WireFailure` is passed
832                // the error only to classify it; nothing derived from it reaches the wire.
833                let category = WireFailure::classify(&e);
834                log.error(format!(
835                    "GTP LLM call failed for {} from {} (category={}): {e}",
836                    ctx.message_name,
837                    ctx.peer_addr,
838                    if category.is_overloaded() {
839                        "overloaded"
840                    } else {
841                        "unavailable"
842                    }
843                ));
844                return (
845                    Decision::FailClosedLlmError,
846                    Self::synthesised_refusal(ctx, category),
847                );
848            }
849        };
850
851        let mut first_error: Option<String> = None;
852        for result in &execution.protocol_results {
853            let ActionResult::Custom { name, data } = result else {
854                continue;
855            };
856            match Self::render(shared, ctx, name, data) {
857                Ok(Some(rendered)) => return rendered,
858                Ok(None) => {}
859                Err(e) => {
860                    log.error(format!(
861                        "GTP could not encode the model's {name} for {} from {}: {e}",
862                        ctx.message_name, ctx.peer_addr
863                    ));
864                    first_error.get_or_insert(e.to_string());
865                }
866            }
867        }
868
869        if first_error.is_some() {
870            return (
871                Decision::FailClosedActionError,
872                Self::synthesised_refusal(ctx, WireFailure::Unavailable),
873            );
874        }
875        (
876            Decision::FailClosedNoAction,
877            Self::synthesised_refusal(ctx, WireFailure::Unavailable),
878        )
879    }
880
881    /// Read the model's `sequence` override, refusing anything wider than the header field.
882    ///
883    /// Returns the request's own sequence when the model omitted it, which is the documented
884    /// default and almost always what a response wants.
885    fn checked_sequence(
886        data: &serde_json::Value,
887        version: GtpVersion,
888        fallback: u32,
889    ) -> Result<u32> {
890        match data.get("sequence").and_then(|v| v.as_u64()) {
891            Some(v) if v > version.max_sequence() as u64 => anyhow::bail!(
892                "sequence {v} does not fit a GTPv{} header: the field is {} bits, so the \
893                 largest value is {}. Copy the request's own sequence from the event, or \
894                 omit 'sequence' entirely and the server echoes it for you.",
895                version.as_number(),
896                version.sequence_bits(),
897                version.max_sequence()
898            ),
899            Some(v) => Ok(v as u32),
900            None => Ok(fallback),
901        }
902    }
903
904    /// Turn one `ActionResult::Custom` into wire bytes.
905    ///
906    /// `Ok(None)` means "this result is not one of ours"; `Err` means the model asked for
907    /// something that cannot be encoded, which fails closed.
908    #[allow(clippy::type_complexity)]
909    fn render(
910        shared: &Shared,
911        ctx: &RequestContext,
912        name: &str,
913        data: &serde_json::Value,
914    ) -> Result<Option<(Decision, Option<(Vec<u8>, Plane)>)>> {
915        // Checked against the width of the field it is going into, not against `u32`.
916        // `optional_u32` in actions.rs cannot do this: a sequence number is 16 bits in
917        // GTPv1 and 24 in GTPv2, and only `mod.rs` knows which version is being answered.
918        // Without the check `sequence as u16` turned a model's 70000 into 4464, which the
919        // peer cannot match to any outstanding request — so the response is discarded and
920        // the request times out, a failure that reads as a lost packet rather than a bad
921        // field.
922        let sequence = Self::checked_sequence(data, ctx.version, ctx.sequence)?;
923        let teid = data
924            .get("teid")
925            .and_then(|v| v.as_u64())
926            .map(|v| v as u32)
927            .unwrap_or(ctx.default_response_teid);
928
929        match name {
930            actions::RESULT_NO_RESPONSE => Ok(Some((Decision::ModelSilent, None))),
931
932            actions::RESULT_ECHO_RESPONSE => {
933                let recovery = data.get("recovery").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
934                let bytes = match ctx.version {
935                    GtpVersion::V1 => encode_v1(
936                        codec::V1_ECHO_RESPONSE,
937                        0,
938                        sequence,
939                        &[ie(codec::V1_IE_RECOVERY, vec![recovery])],
940                    ),
941                    GtpVersion::V2 => GtpV2Message {
942                        header: GtpV2Header::without_teid(codec::V2_ECHO_RESPONSE, sequence),
943                        ies: vec![GtpV2Ie::new(codec::V2_IE_RECOVERY, 0, vec![recovery])],
944                    }
945                    .encode(),
946                };
947                Ok(Some((Decision::ModelEcho, Some((bytes, ctx.plane)))))
948            }
949
950            actions::RESULT_CREATE_RESPONSE
951            | actions::RESULT_UPDATE_RESPONSE
952            | actions::RESULT_DELETE_RESPONSE => {
953                let (cause, accepts) = cause_for(ctx.version, data)?;
954                let decision = if accepts {
955                    Decision::ModelAccept
956                } else {
957                    Decision::ModelReject
958                };
959                let bytes =
960                    Self::encode_session_response(shared, ctx, name, sequence, teid, cause, data)?;
961                Ok(Some((decision, Some((bytes, Plane::Control)))))
962            }
963
964            actions::RESULT_ERROR_INDICATION => {
965                let target = data
966                    .get("teid")
967                    .and_then(|v| v.as_u64())
968                    .context("gtp_error_indication result carries no teid")?
969                    as u32;
970                // TS 29.060 §7.3.7: the Error Indication carries the offending TEID in a
971                // Tunnel Endpoint Identifier Data I IE and the sender's GSN Address, with a
972                // header TEID of 0.
973                let mut ies = vec![ie(codec::V1_IE_TEID_DATA_I, target.to_be_bytes().to_vec())];
974                ies.push(gsn_address_ie(shared.advertised_user_ip()));
975                let bytes = encode_v1(codec::V1_ERROR_INDICATION, 0, sequence, &ies);
976                Ok(Some((
977                    Decision::ModelErrorIndication,
978                    Some((bytes, Plane::User)),
979                )))
980            }
981
982            actions::RESULT_GPDU => {
983                let target =
984                    data.get("teid")
985                        .and_then(|v| v.as_u64())
986                        .context("gtp_gpdu result carries no teid")? as u32;
987                let payload = hex::decode(
988                    data.get("payload_hex")
989                        .and_then(|v| v.as_str())
990                        .unwrap_or_default(),
991                )
992                .context("gtp_gpdu payload is not valid hex")?;
993                if payload.len() > codec::MAX_GPDU_PAYLOAD_LEN {
994                    anyhow::bail!(
995                        "G-PDU payload is {} octets, over the {}-octet limit. GTP-U's Length \
996                         field is 16 bits and the datagram has to fit one UDP packet, so a \
997                         larger T-PDU would wrap the length field and then fail to send at \
998                         all. A T-PDU is one IP packet — send several G-PDUs instead.",
999                        payload.len(),
1000                        codec::MAX_GPDU_PAYLOAD_LEN
1001                    );
1002                }
1003                // A G-PDU is always GTPv1 whatever version the surrounding context is, so
1004                // re-check against the 16-bit field rather than reusing `sequence`, which
1005                // was checked against `ctx.version`.
1006                let header = match data.get("sequence") {
1007                    Some(serde_json::Value::Null) | None => {
1008                        GtpV1Header::new(codec::V1_G_PDU, target)
1009                    }
1010                    Some(_) => {
1011                        let seq = Self::checked_sequence(data, GtpVersion::V1, ctx.sequence)?;
1012                        GtpV1Header::with_sequence(codec::V1_G_PDU, target, seq as u16)
1013                    }
1014                };
1015                let bytes = GtpV1Message {
1016                    header,
1017                    body: payload,
1018                }
1019                .encode();
1020                Ok(Some((Decision::ModelGpdu, Some((bytes, Plane::User)))))
1021            }
1022
1023            _ => Ok(None),
1024        }
1025    }
1026
1027    fn encode_session_response(
1028        shared: &Shared,
1029        ctx: &RequestContext,
1030        result_name: &str,
1031        sequence: u32,
1032        teid: u32,
1033        cause: u8,
1034        data: &serde_json::Value,
1035    ) -> Result<Vec<u8>> {
1036        let accepts = codec::cause_accepts(ctx.version, cause);
1037        let assigned = json_ip(data, "assigned_address")?;
1038        let control_teid = json_u32(data, "control_teid");
1039        let data_teid = json_u32(data, "data_teid");
1040        let charging_id = json_u32(data, "charging_id");
1041        let recovery = data
1042            .get("recovery")
1043            .and_then(|v| v.as_u64())
1044            .map(|v| v as u8);
1045        let dns: Vec<IpAddr> = data
1046            .get("dns_servers")
1047            .and_then(|v| v.as_array())
1048            .map(|a| {
1049                a.iter()
1050                    .filter_map(|e| e.as_str())
1051                    .filter_map(|s| s.parse().ok())
1052                    .collect()
1053            })
1054            .unwrap_or_default();
1055
1056        let is_delete = result_name == actions::RESULT_DELETE_RESPONSE;
1057
1058        match ctx.version {
1059            GtpVersion::V1 => {
1060                let message_type = match result_name {
1061                    actions::RESULT_CREATE_RESPONSE => codec::V1_CREATE_PDP_CONTEXT_RESPONSE,
1062                    actions::RESULT_UPDATE_RESPONSE => codec::V1_UPDATE_PDP_CONTEXT_RESPONSE,
1063                    _ => codec::V1_DELETE_PDP_CONTEXT_RESPONSE,
1064                };
1065
1066                // GTPv1 requires information elements in ascending type order.
1067                let mut ies = vec![ie(codec::V1_IE_CAUSE, vec![cause])];
1068                if !is_delete && accepts {
1069                    // Reordering Required is mandatory in a Create PDP Context Response.
1070                    // 0xFE = spare bits set, reordering not required.
1071                    ies.push(ie(codec::V1_IE_REORDERING_REQUIRED, vec![0xFE]));
1072                }
1073                if let Some(r) = recovery {
1074                    ies.push(ie(codec::V1_IE_RECOVERY, vec![r]));
1075                }
1076                if !is_delete {
1077                    if let Some(t) = data_teid {
1078                        ies.push(ie(codec::V1_IE_TEID_DATA_I, t.to_be_bytes().to_vec()));
1079                    }
1080                    if let Some(t) = control_teid {
1081                        ies.push(ie(
1082                            codec::V1_IE_TEID_CONTROL_PLANE,
1083                            t.to_be_bytes().to_vec(),
1084                        ));
1085                    }
1086                    if let Some(c) = charging_id {
1087                        ies.push(ie(codec::V1_IE_CHARGING_ID, c.to_be_bytes().to_vec()));
1088                    }
1089                    if let Some(addr) = assigned {
1090                        ies.push(ie(
1091                            codec::V1_IE_END_USER_ADDRESS,
1092                            codec::encode_end_user_address(Some(addr)),
1093                        ));
1094                    }
1095                }
1096                if !dns.is_empty() {
1097                    ies.push(ie(
1098                        codec::V1_IE_PROTOCOL_CONFIG_OPTIONS,
1099                        codec::encode_pco_dns(&dns),
1100                    ));
1101                }
1102                if !is_delete && accepts {
1103                    ies.push(gsn_address_ie(shared.advertised_control_ip()));
1104                    ies.push(gsn_address_ie(shared.advertised_user_ip()));
1105                }
1106
1107                Ok(encode_v1(message_type, teid, sequence, &ies))
1108            }
1109            GtpVersion::V2 => {
1110                let message_type = match result_name {
1111                    actions::RESULT_CREATE_RESPONSE => codec::V2_CREATE_SESSION_RESPONSE,
1112                    actions::RESULT_UPDATE_RESPONSE => codec::V2_MODIFY_BEARER_RESPONSE,
1113                    _ => codec::V2_DELETE_SESSION_RESPONSE,
1114                };
1115
1116                // TS 29.274 §8.4: the Cause IE is two octets — the value, then a flags octet.
1117                let mut ies = vec![GtpV2Ie::new(codec::V2_IE_CAUSE, 0, vec![cause, 0])];
1118                if let Some(r) = recovery {
1119                    ies.push(GtpV2Ie::new(codec::V2_IE_RECOVERY, 0, vec![r]));
1120                }
1121                if !is_delete {
1122                    if !dns.is_empty() {
1123                        ies.push(GtpV2Ie::new(
1124                            codec::V2_IE_PCO,
1125                            0,
1126                            codec::encode_pco_dns(&dns),
1127                        ));
1128                    }
1129                    if let Some(addr) = assigned {
1130                        ies.push(GtpV2Ie::new(codec::V2_IE_PAA, 0, codec::encode_paa(addr)));
1131                    }
1132                    if let Some(t) = control_teid {
1133                        // Interface type 7: PGW S5/S8 GTP-C.
1134                        ies.push(GtpV2Ie::new(
1135                            codec::V2_IE_FTEID,
1136                            1,
1137                            codec::encode_fteid(7, t, shared.advertised_control_ip()),
1138                        ));
1139                    }
1140                    if let Some(t) = data_teid {
1141                        // Bearer Context created: EBI, Cause, and the S5/S8-U PGW F-TEID
1142                        // (interface type 5) the peer must send user traffic to.
1143                        let inner = vec![
1144                            GtpV2Ie::new(codec::V2_IE_EBI, 0, vec![ctx.bearer_id.unwrap_or(5)]),
1145                            GtpV2Ie::new(codec::V2_IE_CAUSE, 0, vec![cause, 0]),
1146                            GtpV2Ie::new(
1147                                codec::V2_IE_FTEID,
1148                                2,
1149                                codec::encode_fteid(5, t, shared.advertised_user_ip()),
1150                            ),
1151                        ];
1152                        ies.push(GtpV2Ie::new(
1153                            codec::V2_IE_BEARER_CONTEXT,
1154                            0,
1155                            codec::encode_v2_grouped(&inner),
1156                        ));
1157                    }
1158                }
1159
1160                Ok(GtpV2Message {
1161                    header: GtpV2Header::new(message_type, teid, sequence),
1162                    ies,
1163                }
1164                .encode())
1165            }
1166        }
1167    }
1168
1169    /// The refusal sent when the model did not decide.
1170    ///
1171    /// Deliberately *not* the same shape as a model refusal: it carries a cause only, no
1172    /// address and no TEIDs, so the two denial paths differ on the wire as well as in the
1173    /// log. `Overloaded` maps to "no resources available", which a peer may retry;
1174    /// `Unavailable` maps to "system failure", which it should not.
1175    fn synthesised_refusal(
1176        ctx: &RequestContext,
1177        category: WireFailure,
1178    ) -> Option<(Vec<u8>, Plane)> {
1179        let response_type = response_type(ctx.version, ctx.message_type)?;
1180
1181        // Echo and the user plane assert something positive, so there is nothing safe to
1182        // synthesise: stay silent and let the peer draw its own conclusion.
1183        let is_session_response = match ctx.version {
1184            GtpVersion::V1 => matches!(
1185                response_type,
1186                codec::V1_CREATE_PDP_CONTEXT_RESPONSE
1187                    | codec::V1_UPDATE_PDP_CONTEXT_RESPONSE
1188                    | codec::V1_DELETE_PDP_CONTEXT_RESPONSE
1189            ),
1190            GtpVersion::V2 => matches!(
1191                response_type,
1192                codec::V2_CREATE_SESSION_RESPONSE
1193                    | codec::V2_MODIFY_BEARER_RESPONSE
1194                    | codec::V2_DELETE_SESSION_RESPONSE
1195            ),
1196        };
1197        if !is_session_response {
1198            return None;
1199        }
1200
1201        let name = if category.is_overloaded() {
1202            "no_resources_available"
1203        } else {
1204            "system_failure"
1205        };
1206        let cause = codec::cause_by_name(name)?;
1207        debug_assert!(!cause.accepts, "a fail-closed cause must never accept");
1208
1209        let bytes = match ctx.version {
1210            GtpVersion::V1 => encode_v1(
1211                response_type,
1212                ctx.default_response_teid,
1213                ctx.sequence,
1214                &[ie(codec::V1_IE_CAUSE, vec![cause.v1])],
1215            ),
1216            GtpVersion::V2 => GtpV2Message {
1217                header: GtpV2Header::new(response_type, ctx.default_response_teid, ctx.sequence),
1218                ies: vec![GtpV2Ie::new(codec::V2_IE_CAUSE, 0, vec![cause.v2, 0])],
1219            }
1220            .encode(),
1221        };
1222        Some((bytes, Plane::Control))
1223    }
1224
1225    // -----------------------------------------------------------------------
1226    // Plumbing
1227    // -----------------------------------------------------------------------
1228
1229    async fn record_connection(
1230        shared: &Shared,
1231        peer_addr: SocketAddr,
1232        plane: Plane,
1233        bytes: usize,
1234    ) -> ConnectionId {
1235        use crate::state::server::{
1236            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
1237        };
1238
1239        let connection_id = ConnectionId::new(shared.state.get_next_unified_id().await);
1240        let now = crate::utils::clock::Instant::now();
1241        shared
1242            .state
1243            .add_connection_to_server(
1244                shared.server_id,
1245                ServerConnectionState {
1246                    id: connection_id,
1247                    remote_addr: peer_addr,
1248                    local_addr: shared.local_addr(plane),
1249                    bytes_sent: 0,
1250                    bytes_received: bytes as u64,
1251                    packets_sent: 0,
1252                    packets_received: 1,
1253                    last_activity: now,
1254                    status: ConnectionStatus::Active,
1255                    status_changed_at: now,
1256                    protocol_info: ProtocolConnectionInfo::empty(),
1257                },
1258            )
1259            .await;
1260        let _ = shared.status_tx.send("__UPDATE_UI__".to_string());
1261        connection_id
1262    }
1263
1264    async fn send(
1265        shared: &Shared,
1266        bytes: &[u8],
1267        peer_addr: SocketAddr,
1268        plane: Plane,
1269        connection_id: ConnectionId,
1270    ) {
1271        if plane == Plane::User && shared.user_socket.is_none() {
1272            warn!(
1273                "GTP wanted to send {} octets on the user plane to {peer_addr}, but the user \
1274                 plane is disabled; sending on the control plane instead",
1275                bytes.len()
1276            );
1277        }
1278        let socket = shared.socket_for(plane);
1279        if let Err(e) = socket.send_to(bytes, peer_addr).await {
1280            Log::new(Some(&shared.status_tx)).error(format!("GTP send to {peer_addr} failed: {e}"));
1281            return;
1282        }
1283
1284        shared
1285            .state
1286            .update_connection_stats(
1287                shared.server_id,
1288                connection_id,
1289                None,
1290                Some(bytes.len() as u64),
1291                None,
1292                Some(1),
1293            )
1294            .await;
1295
1296        let log = Log::new(Some(&shared.status_tx));
1297        log.debug(format!(
1298            "GTP sent {} octets to {peer_addr} on the {} plane",
1299            bytes.len(),
1300            plane.as_str()
1301        ));
1302        log.trace(format!("GTP sent (hex): {}", hex::encode(bytes)));
1303        let _ = shared
1304            .status_tx
1305            .send(format!("→ GTP response to {peer_addr}"));
1306    }
1307}
1308
1309// ===========================================================================
1310// Small helpers
1311// ===========================================================================
1312
1313fn ie(ie_type: u8, value: Vec<u8>) -> GtpV1Ie {
1314    GtpV1Ie { ie_type, value }
1315}
1316
1317fn gsn_address_ie(addr: IpAddr) -> GtpV1Ie {
1318    let value = match addr {
1319        IpAddr::V4(v4) => v4.octets().to_vec(),
1320        IpAddr::V6(v6) => v6.octets().to_vec(),
1321    };
1322    ie(codec::V1_IE_GSN_ADDRESS, value)
1323}
1324
1325/// A GSN Address IE is a bare 4- or 16-octet address (TS 29.060 §7.7.32).
1326fn gsn_address(value: &[u8]) -> Option<IpAddr> {
1327    match value.len() {
1328        4 => Some(IpAddr::V4(Ipv4Addr::new(
1329            value[0], value[1], value[2], value[3],
1330        ))),
1331        16 => {
1332            let mut octets = [0u8; 16];
1333            octets.copy_from_slice(value);
1334            Some(IpAddr::V6(Ipv6Addr::from(octets)))
1335        }
1336        _ => None,
1337    }
1338}
1339
1340/// Every GTP-C signalling message carries a sequence number, so the S flag is always set —
1341/// and with it, by the all-or-nothing rule, all four optional octets.
1342fn encode_v1(message_type: u8, teid: u32, sequence: u32, ies: &[GtpV1Ie]) -> Vec<u8> {
1343    GtpV1Message {
1344        header: GtpV1Header::with_sequence(message_type, teid, sequence as u16),
1345        body: codec::encode_v1_ies(ies),
1346    }
1347    .encode()
1348}
1349
1350/// Which response answers which request.
1351fn response_type(version: GtpVersion, request_type: u8) -> Option<u8> {
1352    Some(match (version, request_type) {
1353        (GtpVersion::V1, codec::V1_ECHO_REQUEST) => codec::V1_ECHO_RESPONSE,
1354        (GtpVersion::V1, codec::V1_CREATE_PDP_CONTEXT_REQUEST) => {
1355            codec::V1_CREATE_PDP_CONTEXT_RESPONSE
1356        }
1357        (GtpVersion::V1, codec::V1_UPDATE_PDP_CONTEXT_REQUEST) => {
1358            codec::V1_UPDATE_PDP_CONTEXT_RESPONSE
1359        }
1360        (GtpVersion::V1, codec::V1_DELETE_PDP_CONTEXT_REQUEST) => {
1361            codec::V1_DELETE_PDP_CONTEXT_RESPONSE
1362        }
1363        (GtpVersion::V2, codec::V2_ECHO_REQUEST) => codec::V2_ECHO_RESPONSE,
1364        (GtpVersion::V2, codec::V2_CREATE_SESSION_REQUEST) => codec::V2_CREATE_SESSION_RESPONSE,
1365        (GtpVersion::V2, codec::V2_MODIFY_BEARER_REQUEST) => codec::V2_MODIFY_BEARER_RESPONSE,
1366        (GtpVersion::V2, codec::V2_DELETE_SESSION_REQUEST) => codec::V2_DELETE_SESSION_RESPONSE,
1367        _ => return None,
1368    })
1369}
1370
1371/// Pick the cause code for this version out of the normalised action result.
1372fn cause_for(version: GtpVersion, data: &serde_json::Value) -> Result<(u8, bool)> {
1373    let cause = data
1374        .get("cause")
1375        .context("session response result carries no cause")?;
1376    let key = match version {
1377        GtpVersion::V1 => "v1",
1378        GtpVersion::V2 => "v2",
1379    };
1380    let value = cause
1381        .get(key)
1382        .and_then(|v| v.as_u64())
1383        .with_context(|| format!("session response cause has no {key} code"))?
1384        as u8;
1385    Ok((value, codec::cause_accepts(version, value)))
1386}
1387
1388fn json_u32(data: &serde_json::Value, key: &str) -> Option<u32> {
1389    data.get(key).and_then(|v| v.as_u64()).map(|v| v as u32)
1390}
1391
1392fn json_ip(data: &serde_json::Value, key: &str) -> Result<Option<IpAddr>> {
1393    match data.get(key).and_then(|v| v.as_str()) {
1394        None => Ok(None),
1395        Some(s) => s
1396            .parse::<IpAddr>()
1397            .map(Some)
1398            .with_context(|| format!("'{key}' is not an IP address: {s:?}")),
1399    }
1400}