"""Decode tshark EK records; unrecognized messages remain explicitly labelled.""" import re import json from functools import lru_cache from pathlib import Path from collections import defaultdict import yaml from factory.registry import all_devices, RAN from apps.phones import SMS_PHONES from apps.ims_config import IMS_PHONES RAN_NAMES = {address: name for name, address in RAN.items()} EXPLANATIONS = yaml.safe_load((Path(__file__).parents[1] / "explanations.yaml").read_text(encoding="utf-8")) NAS_MM = {0x41: "Registration Request", 0x42: "Registration Accept", 0x43: "Registration Complete", 0x44: "Registration Reject", 0x45: "Deregistration Request", 0x46: "Deregistration Accept", 0x47: "Deregistration Request", 0x48: "Deregistration Accept", 0x54: "Configuration Update Command", 0x55: "Configuration Update Complete", 0x56: "Authentication Request", 0x57: "Authentication Response", 0x58: "Authentication Reject", 0x59: "Authentication Failure", 0x5b: "Identity Request", 0x5c: "Identity Response", 0x5d: "Security Mode Command", 0x5e: "Security Mode Complete", 0x5f: "Security Mode Reject", 0x67: "Uplink NAS Transport", 0x68: "Downlink NAS Transport"} NAS_SM = {0xc1: "PDU Session Establishment Request", 0xc2: "PDU Session Establishment Accept", 0xc3: "PDU Session Establishment Reject", 0xcb: "PDU Session Modification Command", 0xcc: "PDU Session Modification Complete", 0xd1: "PDU Session Release Request", 0xd3: "PDU Session Release Command", 0xd4: "PDU Session Release Complete"} PFCP = {1: "Heartbeat Request", 2: "Heartbeat Response", 5: "Association Setup Request", 6: "Association Setup Response", 50: "Session Establishment Request", 51: "Session Establishment Response", 52: "Session Modification Request", 53: "Session Modification Response", 54: "Session Deletion Request", 55: "Session Deletion Response", 56: "Session Report Request", 57: "Session Report Response"} NGAP = {21: ("NG Setup Request", "NG Setup Response", "NG Setup Failure"), 14: ("Initial UE Message",)*3, 46: ("Uplink NAS Transport",)*3, 4: ("Downlink NAS Transport",)*3, 29: ("PDU Session Resource Setup Request", "PDU Session Resource Setup Response", "PDU Session Resource Setup Failure"), 15: ("Initial Context Setup Request", "Initial Context Setup Response", "Initial Context Setup Failure"), 41: ("UE Context Release Command", "UE Context Release Complete", "UE Context Release Failure")} NGAP[28] = ("PDU Session Resource Release Command", "PDU Session Resource Release Response", "PDU Session Resource Release Failure") NGAP.update({12: ("Handover Required", "Handover Command", "Handover Preparation Failure"), 13: ("Handover Request", "Handover Request Acknowledge", "Handover Failure"), 11: ("Handover Notify",) * 3, 25: ("Path Switch Request", "Path Switch Request Acknowledge", "Path Switch Request Failure")}) XN = {0: ("Xn Handover Request", "Xn Handover Request Acknowledge", "Xn Handover Preparation Failure"), 6: ("Xn UE Context Release",) * 3, 17: ("Xn Setup Request", "Xn Setup Response", "Xn Setup Failure")} @lru_cache(maxsize=4096) def normalized(key): return re.sub(r"[^a-zA-Z0-9]", "_", key).lower() class Fields(dict): """Index suffixes once per packet instead of scanning hundreds of keys per field.""" def suffix_values(self, suffix): if getattr(self, "exact", False): return self.get(suffix, []) if not hasattr(self, "_suffixes"): self._suffixes = defaultdict(list) for key, vals in self.items(): aliases = {key} aliases.update(key[i+1:] for i, char in enumerate(key) if char == "_") for alias in aliases: self._suffixes[alias].extend(vals) return self._suffixes.get(suffix, []) def flatten(obj, exact=False): if isinstance(obj, Fields): return obj result = defaultdict(list) def visit(value): if isinstance(value, dict): for key, child in value.items(): if isinstance(child, (dict, list)): if isinstance(child, list) and all(not isinstance(x, (dict, list)) for x in child): result[normalized(key)].extend(str(x) for x in child) else: visit(child) else: result[normalized(key)].append(str(child)) elif isinstance(value, list): for child in value: visit(child) visit(obj) fields = Fields(result) fields.exact = exact return fields def values(fields, *suffixes): for suffix in suffixes: suffix = normalized(suffix) found = fields.suffix_values(suffix) if isinstance(fields, Fields) else [value for key, vals in fields.items() if key == suffix or key.endswith("_" + suffix) for value in vals] if found: return found return [] def first(fields, *suffixes, default=None): found = values(fields, *suffixes) return found[0] if found else default def number(value, default=0): try: return int(value, 16) if str(value).lower().startswith("0x") else int(value) except (TypeError, ValueError): return default def tunnel_key(value): try: return f"{int(str(value).lower().replace(':',''), 16):08x}" except (ValueError, TypeError): return "" def f_seids(fields): """Read sender F-SEID IE 57, including Wireshark versions lacking its numeric field.""" result = {number(value) for value in values(fields, "pfcp.f_seid_seid") if number(value)} for encoded in values(fields, "udp.payload"): try: payload = bytes.fromhex(encoded.replace(":", "")) if len(payload) < 8: continue offset = 16 if payload[0] & 1 else 8 limit = min(len(payload), int.from_bytes(payload[2:4], "big")+4) while offset+4 <= limit: kind = int.from_bytes(payload[offset:offset+2], "big") length = int.from_bytes(payload[offset+2:offset+4], "big") end = offset+4+length if end > limit: break if kind == 57 and length >= 9: result.add(int.from_bytes(payload[offset+5:offset+13], "big")) offset = end except (ValueError, TypeError): continue result.discard(0) return result def sms_transfer(fields, inner_src, inner_dst): """Identify the actual message/receipt, excluding readiness probes and other UDP.""" if "9002" not in values(fields, "udp.srcport") + values(fields, "udp.dstport"): return None # GTP-U has an outer UDP payload and an inner application UDP payload. payloads = values(fields, "udp.payload") or values(fields, "data.data") payload = payloads[-1] if payloads else None try: message = json.loads(bytes.fromhex(payload.replace(":", ""))) except (AttributeError, ValueError, UnicodeError, TypeError): return None if not isinstance(message, dict) or message.get("kind", "sms") != "sms": return None sender, recipient = message.get("sender"), message.get("recipient") phones = SMS_PHONES if not isinstance(sender, str) or not isinstance(recipient, str) or sender not in phones or recipient not in phones or sender == recipient: return None if not isinstance(message.get("id"), str) or not re.fullmatch(r"[a-f0-9]{32}", message["id"]): return None receipt = message.get("status") == "delivered" expected = (phones[recipient], phones[sender]) if receipt else (phones[sender], phones[recipient]) if (inner_src, inner_dst) != expected: return None return {"sms_id": message["id"], "sms_kind": "receipt" if receipt else "message"} def procedure(name): if "Handover" in name or "Path Switch" in name or name.startswith("Xn "): return "handover" if "Authentication" in name or name in ("Authenticate Subscriber", "Get Authentication Data"): return "authentication" if any(word in name for word in ("PDU", "Session", "SM Context", "PFCP", "Policy")): return "pdu_session" if "Deregistration" in name: return "deregistration" if name.startswith("NF "): return "nf_discovery" return "registration" def sbi_name(path, method): if "/nnrf-nfm/" in path: if "/subscriptions" in path: return "NF Subscribe" if "/nf-status-notify" in path: return "NF Status Notification" if method == "GET": return "NF Read Profile" if method == "REQUEST": return "NF Management Request" return {"PUT": "NF Register", "PATCH": "NF Update", "DELETE": "NF Deregister"}.get(method, "NF Update") for fragment, name in [('/nnrf-disc/', 'NF Discover'), ('/5g-aka-confirmation', 'Confirm Authentication'), ('/nausf-auth/', 'Authenticate Subscriber'), ('/security-information', 'Get Authentication Data'), ('/auth-events', 'Confirm Subscriber Authentication'), ('/nudr-dr/', 'Read Subscription'), ('/nudm-sdm/', 'Get Subscriber Data'), ('/nudm-uecm/', 'Register Serving AMF'), ('/npcf-smpolicycontrol/', 'Create Session Policy'), ('/nnssf-nsselection/', 'Select Slice')]: if fragment in path: return name if '/nsmf-pdusession/' in path: return 'Release SM Context' if path.endswith('/release') else 'Update SM Context' if path.endswith('/modify') else 'Create SM Context' return f"SBI {method} {path.split('?')[0]}"[:200] class Decoder: def __init__(self, nodes=None): self.nodes = nodes or {} self.capture_stream = "main" self.ran_ues = {} self.amf_ues = {} self.xn_ues = {} self.seid_ues = {} self.ul_tunnel_ues = {} self.sm_context_ues = {} self.pfcp_requests = {} self.http_requests = {} self.http_bodies = {} self.flows = {} self.ue_ips = {row["tunnel_ip"]: "imsi-"+row["imsi"] for row in all_devices().values()} self.ue_ips.update({f"10.46.0.{int(p[2:])+1}": f"imsi-00101{int(p[2:]):010d}" for p in IMS_PHONES}) def event(self, ts, src, dst, plane, protocol, name, ue, size, raw): raw = {**raw, "capture_stream": self.capture_stream} explain = EXPLANATIONS.get(name) if not explain and name.endswith(" Response") and protocol == "HTTP2-SBI": explain = EXPLANATIONS["SBI Response"] if int(raw.get("status", 200)) < 400 else EXPLANATIONS["SBI Error Response"] return {"ts": ts, "src": self.nodes.get(src, RAN_NAMES.get(src, src)), "dst": self.nodes.get(dst, RAN_NAMES.get(dst, dst)), "plane": plane, "protocol": protocol, "name": name, "ue": ue, "explain": explain or "A decoded network message; inspect its fields for details.", "procedure": "voice" if protocol in ("SIP", "DIAMETER", "RTP") else "data" if plane == "user" else procedure(name), "bytes": size, "raw": raw} def decode(self, packet): layers = packet.get("layers", packet) # SCTP may bundle multiple NGAP PDUs; HTTP/2 may bundle several streams. for protocol in ("ngap", "xnap", "http2"): if isinstance(layers.get(protocol), list): events = [] parts = layers[protocol] if protocol == "http2": groups = defaultdict(list) for part in parts: groups[first(flatten(part), "http2.streamid")].append(part) parts = [flatten(group) for group in groups.values()] for part in parts: events.extend(self.decode({"layers": {**layers, protocol: part}})) return events f = packet.get("_fields") or flatten(packet.get("layers", packet)) ts = float(first(f, "frame.time_epoch", default="0")) ips, dsts = values(f, "ip.src"), values(f, "ip.dst") if not ips or not dsts: return [] src, dst = ips[0], dsts[0] size = number(first(f, "frame.len")) events = [] events.extend(self.ims_events(f, ts, ips, dsts, size)) ran = first(f, "ngap.RAN_UE_NGAP_ID") amf = first(f, "ngap.AMF_UE_NGAP_ID", "ngap.SourceAMF_UE_NGAP_ID") ran_ip = dst if src == "172.22.0.20" or self.nodes.get(src) == "amf" else src ran_key = (ran_ip, ran) ue = None msin = first(f, "nas_5gs.mm.suci.msin", "nas_5gs.mm.suci.supi.msin", "e212.imsi") if msin: digits = re.sub(r"\D", "", msin) ue = "imsi-" + (digits if len(digits) == 15 else "00101" + digits.zfill(10)) if ran: self.ran_ues[ran_key] = ue ngap = first(f, "ngap.procedureCode") if number(ngap) in (11, 12, 13, 25, 41): # A target's RAN slot can have belonged to another subscriber. # Handover AMF identity wins; N2 preparation below verifies its UL tunnel. ue = ue or self.amf_ues.get(amf) or self.ran_ues.get(ran_key) else: # Fresh registration can reuse an AMF ID after a core restart. ue = ue or self.ran_ues.get(ran_key) or self.amf_ues.get(amf) mm = first(f, "nas_5gs.mm.message_type") sm = first(f, "nas_5gs.sm.message_type") if ngap is not None: if sm is not None or mm is not None: name = NAS_SM.get(number(sm), f"NAS SM 0x{number(sm):02x}") if sm is not None else NAS_MM.get(number(mm), f"NAS MM 0x{number(mm):02x}") if name == "Authentication Failure" and number(first(f, "nas_5gs.mm.5gmm_cause")) == 21: name = "Authentication Synchronization Failure" protocol = "NAS" else: outcome = 1 if values(f, "ngap.successfulOutcome_element") else 2 if values(f, "ngap.unsuccessfulOutcome_element") else 0 name = NGAP.get(number(ngap), (f"NGAP procedure {ngap}",)*3)[outcome] protocol = "NGAP" # In this fixed single-UPF lab, the uplink TEID uniquely identifies # an admitted PDU session. N2 target AMF IDs can differ from source. teids = values(f, "ngap.gTP_TEID") if name == "Handover Request": identities = {self.ul_tunnel_ues.get(tunnel_key(teid)) for teid in teids} identities.discard(None) if len(identities) == 1: ue = identities.pop() if amf: self.amf_ues[amf] = ue if ue and amf: self.amf_ues[amf] = ue if ue and ran: self.ran_ues[ran_key] = ue setup_request = (number(ngap) in (15, 29) and (src == "172.22.0.20" or self.nodes.get(src) == "amf") and not values(f, "ngap.successfulOutcome_element", "ngap.unsuccessfulOutcome_element")) # A setup request carrying NAS is displayed by its NAS message # name; it still supplies the actual uplink tunnel reference. if ue and (setup_request or name == "Handover Request"): for teid in teids: self.ul_tunnel_ues[tunnel_key(teid)] = ue raw = {"ran_ue_id": ran, "amf_ue_id": amf, "ngap_procedure": ngap, "source": "pcap"} ue_ip = first(f, "nas_5gs.sm.pdu_addr_inf_ipv4", "nas_5gs.sm.pdu_address.ipv4", "nas_5gs.sm.pdu_address_information.ipv4") if ue_ip: raw["ue_ip"] = ue_ip events.append(self.event(ts, src, dst, "control", protocol, name, ue, size, raw)) xnap = first(f, "xnap.procedureCode") if xnap is not None: outcome = 1 if values(f, "xnap.successfulOutcome_element") else 2 if values(f, "xnap.unsuccessfulOutcome_element") else 0 name = XN.get(number(xnap), (f"XnAP procedure {xnap}",) * 3)[outcome] amf_ref = first(f, "xnap.ng_c_UE_reference") ue = self.amf_ues.get(amf_ref) if not ue: ue = next((self.ul_tunnel_ues[tunnel_key(teid)] for teid in values(f, "xnap.gTP_TEID") if tunnel_key(teid) in self.ul_tunnel_ues), None) xn_ids = values(f, "xnap.NG_RANnodeUEXnAPID") if xn_ids: key = (src if name == "Xn Handover Request" else dst, xn_ids[0]) ue = ue or self.xn_ues.get(key) or self.xn_ues.get((src, xn_ids[0])) if ue: self.xn_ues[key] = ue events.append(self.event(ts, src, dst, "control", "XnAP", name, ue, size, {"source": "pcap", "xn_procedure": xnap, "xn_ue_ids": xn_ids, "amf_ue_id": amf_ref, "sctp_ppid": first(f, "sctp.data_payload_proto_id")})) pfcp = first(f, "pfcp.msg_type") if pfcp is not None: ue_ip = first(f, "pfcp.ue_ip_addr_ipv4", "pfcp.ue_ip_addr_ipv4_addr", "pfcp.ue_ip_address_ipv4", "pfcp.ue_ip_address.ipv4") seid = first(f, "pfcp.seid") flags = first(f, "pfcp.flags") if flags is not None and not number(flags) & 1: # Without the header S bit, Wireshark's pfcp.seid field is # the sender's F-SEID IE, never a receiver session identity. seid = None sequence = first(f, "pfcp.seqno") # Wireshark omits a zero header SEID and exposes the F-SEID # under the same field even with S=1. Read the actual wire header. for encoded in values(f, "udp.payload"): try: payload = bytes.fromhex(encoded.replace(":", "")) if len(payload) >= 16 and payload[1] == number(pfcp): seid = str(int.from_bytes(payload[4:12], "big")) if payload[0] & 1 else None break except (ValueError, TypeError): continue response_ue = self.pfcp_requests.pop((dst, src, sequence), None) ue = self.ue_ips.get(ue_ip) or ue or response_ue or self.seid_ues.get((dst, number(seid))) if ue and number(pfcp) in (50, 52, 54): self.pfcp_requests[(src, dst, sequence)] = ue if len(self.pfcp_requests) > 4096: self.pfcp_requests.pop(next(iter(self.pfcp_requests))) if ue: # Header SEID belongs to the receiver; F-SEID belongs to the sender. # Equal numbers allocated by SMF and UPF need separate namespaces. if number(seid): self.seid_ues[(dst, number(seid))] = ue for identifier in f_seids(f): self.seid_ues[(src, identifier)] = ue name = "PFCP " + PFCP.get(number(pfcp), f"message {pfcp}") events.append(self.event(ts, src, dst, "control", "PFCP", name, ue, size, {"seid": seid, "ue_ip": ue_ip, "source": "pcap"})) paths = values(f, "http2.headers.path") methods = values(f, "http2.headers.method") stream = first(f, "http2.streamid") status = first(f, "http2.headers.status") body_parts = [] for data in values(f, "http2.data.data"): try: body_parts.append(bytes.fromhex(data.replace(":", "")).decode(errors="replace")) except ValueError: body_parts.append(data) body = " ".join(body_parts) http_key = (first(f, "tcp.stream"), stream) if not paths and body and http_key in self.http_requests: # HTTP/2 HEADERS and DATA normally arrive in separate frames. Retain # the observed body identity before the Location response binds the # SM-context URI; do not guess the UE from a shared time window. buffered = (self.http_bodies.get(http_key, "")+body)[-16384:] self.http_bodies[http_key] = buffered identities = set(re.findall(r"imsi-\d{15}", buffered)) if len(identities) == 1: prior_name, _, prior_ts = self.http_requests[http_key] self.http_requests[http_key] = (prior_name, identities.pop(), prior_ts) if paths: for path in paths: method = methods[0] if methods else "REQUEST" name = sbi_name(path, method) match = re.search(r"imsi-(\d{15})", path + " " + body) ue = match.group(0) if match else None context_path = path.split("/modify")[0].split("/release")[0] ue = ue or self.sm_context_ues.get(context_path) suci = re.search(r"suci-0-001-01-\d+-0-0-(\d{10})", body) if not ue and suci: ue = "imsi-00101" + suci.group(1) key = (first(f, "tcp.stream"), stream) self.http_requests[key] = (name, ue, ts) self.http_bodies[key] = body[-16384:] # Bound correlation memory during long-running captures. if len(self.http_requests) > 4096: oldest = next(iter(self.http_requests)) self.http_requests.pop(oldest) self.http_bodies.pop(oldest, None) events.append(self.event(ts, src, dst, "sbi", "HTTP2-SBI", name, ue, size, {"path": path, "method": method, "stream": stream, "source": "pcap"})) elif status and stream: name, ue, _ = self.http_requests.pop((first(f, "tcp.stream"), stream), ("SBI", None, ts)) self.http_bodies.pop(http_key, None) location = first(f, "http2.headers.location") if not location: location = next((value for header, value in zip(values(f, "http2.header.name"), values(f, "http2.header.value")) if header.lower() == "location"), None) if location and ue and "/sm-contexts/" in location: from urllib.parse import urlsplit self.sm_context_ues[urlsplit(location).path] = ue if len(self.sm_context_ues) > 4096: self.sm_context_ues.pop(next(iter(self.sm_context_ues))) events.append(self.event(ts, src, dst, "sbi", "HTTP2-SBI", f"{name} Response", ue, size, {"status": status, "stream": stream, "source": "pcap"})) gtp = first(f, "gtp.message", "gtp.message_type") if gtp is not None and number(gtp) == 255 and len(ips) > 1: inner_src, inner_dst = ips[-1], dsts[-1] # For UE-to-UE packets both inner addresses are subscribers. The # outer N3 leg determines whose uplink or downlink is being observed. if src == "upf" or ips[0] == "172.22.0.22": direction = "dl" elif dst == "upf" or dsts[0] == "172.22.0.22": direction = "ul" else: direction = "ul" if inner_src in self.ue_ips else "dl" ue = self.ue_ips.get(inner_src if direction == "ul" else inner_dst) if ue and direction == "ul": self.ul_tunnel_ues[tunnel_key(first(f, "gtp.teid"))] = ue key = (src, dst, inner_src, inner_dst, first(f, "gtp.teid")) if ue and (inner_src.startswith("10.45.1.") or inner_dst.startswith("10.45.1.")): # Fleet telemetry is aggregated per UE/N3 leg, rather than producing # 760 peer-flow rows every quarter second. Counts remain exact. key = (src, dst, ue, "factory", first(f, "gtp.teid")) if key not in self.flows: self.flows[key] = self.event(ts, src, dst, "user", "GTP-U", "GTP-U Data", ue, 0, {"inner_src": inner_src, "inner_dst": inner_dst, "teid": key[-1], "packets": 0, "direction": direction, "window_ms": 250, "source": "pcap"}) flow = self.flows[key] flow["ts"] = ts flow["bytes"] += number(values(f, "ip.len")[-1]) if values(f, "ip.len") else size flow["raw"]["packets"] += 1 transfer = sms_transfer(f, inner_src, inner_dst) if transfer: # Evidence rows are separate from the aggregate; count bytes/packets only there. events.append(self.event(ts, src, dst, "user", "GTP-U", f"SMS {'receipt' if transfer['sms_kind'] == 'receipt' else 'payload'} ยท N3 {direction}", ue, 0, {**transfer, "inner_src": inner_src, "inner_dst": inner_dst, "teid": key[-1], "direction": direction, "packets": 0, "source": "pcap"})) # N6 is measured on its own bridge, after NAT; never count it as GTP-U throughput. elif src in ("172.23.0.10", "172.23.0.20") or dst in ("172.23.0.10", "172.23.0.20"): key = (src, dst, "n6") if key not in self.flows: self.flows[key] = self.event(ts, src, dst, "user", "N6", "N6 Data", None, 0, {"packets": 0, "window_ms": 250, "source": "pcap"}) self.flows[key]["bytes"] += size self.flows[key]["ts"] = ts self.flows[key]["raw"]["packets"] += 1 return events def ims_events(self, f, ts, ips, dsts, size): """Decode IMS on N6 and inside GTP-U; never retain authentication values.""" src, dst = ips[-1], dsts[-1] if not any(ip.startswith("10.46.0.") or ip.startswith("172.23.0.") for ip in (src, dst)): return [] names = {f"10.46.0.{int(p[2:])+1}": p for p in IMS_PHONES} names.update({"172.23.0.41": "ims-rtpengine", "172.23.0.43": "ims-hss", "172.23.0.44": "ims-icscf", "172.23.0.45": "ims-scscf", "172.23.0.46": "ims-pcscf"}) ue = self.ue_ips.get(src) or self.ue_ips.get(dst) raw = {"source": "pcap", "inner_src": src, "inner_dst": dst, "outer_src": ips[0], "outer_dst": dsts[0], "path": "N3" if len(ips) > 1 else "N6"} method = first(f, "sip.Method") status = first(f, "sip.Status-Code") cseq_method = first(f, "sip.CSeq.method", default="") call_id = first(f, "sip.Call-ID") payloads = values(f, "udp.payload") payload = b"" if payloads: try: payload = bytes.fromhex(payloads[-1].replace(":", "")) except ValueError: pass if not method and not status and (payload.startswith(b"SIP/2.0") or payload.split(b" ", 1)[0] in (b"REGISTER", b"INVITE", b"ACK", b"BYE", b"CANCEL", b"OPTIONS", b"NOTIFY")): from apps.ims_sip import parse, header try: start, headers, _ = parse(payload) call_id = header(headers, "call-id") cseq_method = header(headers, "cseq").split()[-1] if start.startswith("SIP/2.0"): status = start.split()[1] else: method = start.split()[0] except (ValueError, IndexError): return [] if method or status: name = f"SIP {status} {cseq_method}" if status else f"SIP {method}" return [self.event(ts, names.get(src, src), names.get(dst, dst), "control", "SIP", name, ue, size, {**raw, "call_id": call_id, "method": method or cseq_method, "status": status})] command = first(f, "diameter.cmd.code") if command: codes = {257: "Capabilities Exchange", 280: "Device Watchdog", 300: "User Authorization", 301: "Server Assignment", 302: "Location Information", 303: "Multimedia Authentication"} if number(command) == 280: return [] request = first(f, "diameter.flags.request") in ("1", "True", "true") return [self.event(ts, names.get(src, src), names.get(dst, dst), "control", "DIAMETER", f"Cx {codes.get(number(command), command)} {'Request' if request else 'Answer'}", None, size, {**raw, "command": command, "request": request})] if (src.startswith("10.46.0.") and dst == "172.23.0.41") or (dst.startswith("10.46.0.") and src == "172.23.0.41"): if payload and payload[0] >> 6 == 2 and len(payload) >= 12: from apps.ims_sip import rtp_payload try: (sequence, timestamp, ssrc), audio = rtp_payload(payload) except ValueError: return [] key = (src, dst, "rtp", raw["path"]) if key not in self.flows: self.flows[key] = self.event(ts, names.get(src, src), names.get(dst, dst), "user", "RTP", "PCMU voice media", ue, 0, {**raw, "packets": 0, "window_ms": 250, "codec": "PCMU/8000", "ssrc": ssrc}) event = self.flows[key] event["ts"] = ts event["bytes"] += len(audio) event["raw"]["packets"] += 1 event["raw"]["last_sequence"] = sequence return [] def flush(self): events = list(self.flows.values()) self.flows.clear() return events