Bring up a complete 5G Standalone network — the OCUDU gNB (built on srsRAN Project), a native Open5GS core, and the srsRAN srsue software UE — entirely in software on one node over a ZMQ virtual radio. No SDR, no RF, no radio reservation. Register the UE, open a PDU session, reach the internet, and measure throughput.
This is the fastest way to see 5G SA work end-to-end. The OCUDU gNB and the srsRAN srsue exchange baseband samples over ZMQ (a software radio transport) instead of a USRP, so the whole stack — gNB + Open5GS core + UE — runs on a single COSMOS server node. It is license-free and needs no sb1/grid radio hardware, so it runs on any COSMOS node.
You will image one node with ocudu.ndz, start Open5GS, start the gNB, attach srsue, and pass IP traffic. For the over-the-air version with a real USRP and a commercial modem, see the companion OCUDU cross-vendor OTA tutorial.
After completing this tutorial you will be able to:
srsue in software over ZMQ on one node.192.168.100.0/22 pool, and reach the internet.| Difficulty | Beginner–Intermediate |
| Estimated time | 20–30 min |
| Domain / sandbox | any COSMOS server node (no radio needed) |
| Topic group | Cellular (4G/5G/O-RAN) |
| Last verified | 2026-09-14 — three-mode parity green on srv18.osc from the published bundle zip (first verified 2026-07-15) |
Background knowledge
ssh on Linux.Account & access
Devices / nodes
| Resource | Role | Qty | Notes |
|---|---|---|---|
| any COSMOS server node | gNB + Open5GS + srsue (all-in-one) |
1 | no SDR — pure software |
Disk images
| Image | Load onto | Provides |
|---|---|---|
ocudu.ndz |
your node | OCUDU gnb (/opt/ocudu), srsRAN_4G srsue (/opt/srsran4g), Open5GS (/opt/open5gs), launch scripts |
Software components
| Component | Version | Source |
|---|---|---|
| OCUDU gNB (srsRAN Project) | preinstalled | /opt/ocudu/build/apps/gnb/gnb — no license needed |
srsRAN_4G srsue |
preinstalled | /opt/srsran4g/build/srsue/src/srsue (ZMQ RF plugin built) |
| Open5GS 5GC | build-tree | /opt/open5gs, launcher /root/open5gs/start.sh |
Spectrum / RF / special
Everything on one node; gNB ↔ UE over ZMQ TCP sockets, UE ↔ core over the loopback:
any COSMOS node
┌─────────────────────────────────────────────┐
│ Open5GS 5GC (AMF 127.0.0.5, ogstun │
│ 192.168.100.1/22, NAT→internet) │
│ ▲ N2/N3 (loopback) │
│ OCUDU gnb ──ZMQ tx:2000 / rx:2001──► srsue│
│ (band 3, 10 MHz) (software radio) │
└─────────────────────────────────────────────┘
-r 0):ssh <username>@console.<domain>.cosmos-lab.org
omf load -i ocudu.ndz -t <node> -r 0 -o 1200
omf tell -a on -t <node>
ocudu.ndz is a symlink to the latest OCUDU build.ssh root@<node>
/root/open5gs/start.sh # NFs + ogstun 192.168.100.1/22 + NAT + provisions subscribers
ss -lnp -A sctp | grep 38412 # AMF listening for the gNB
LISTEN 0 0 127.0.0.5:38412 0.0.0.0:* users:(("open5gs-amfd",...))
The launcher provisions the subscriber (001010123456780), sets up ogstun on 192.168.100.1/22, and adds a MASQUERADE rule so UE traffic reaches the internet.
The gNB writes two outputs: its own log (log.filename in the config, /tmp/gnb.log) and a short console summary. Send the console to a different file, or the two interleave into one. Keep its stdin open (sleep infinity |) — the gNB exits when stdin closes, for example when your SSH session ends.
Read readiness from the log file: the console summary is block-buffered when redirected, so it stays empty until the gNB exits.
cd /opt/ocudu/build
setsid bash -c 'sleep infinity | ./apps/gnb/gnb -c /root/gnb-zmq.yml' >/tmp/gnb-console.log 2>&1 &
sleep 10
grep -aE 'NG Setup Procedure" finished|Cell was activated|gNB started' /tmp/gnb.log
2026-09-10T10:25:27.621293 [NGAP ] [I] "NG Setup Procedure" finished successfully
2026-09-10T10:25:27.676279 [MAC ] [I] [ 0.0] cell=0: Cell was activated
2026-09-10T10:25:27.676455 [GNB ] [I] ==== gNB started ===
NG Setup Procedure" finished successfully = the gNB is registered with the AMF, and Cell was activated = the (virtual) cell is on air. The config carries the srsUE-match knobs (pdcch.common.coreset0_index: 6, ss0_index: 0, prach.prach_config_index: 1, tx_gain/rx_gain: 0) — do not change these or srsue won't find the cell. Stop the gNB later with pkill -x gnb, never pkill -f apps/gnb/gnb (which matches — and kills — your own SSH shell).
The shipped /root/ue-zmq.conf puts the UE's data interface in a network namespace called ue1 ([gw] netns = ue1), so UE traffic really crosses the stack instead of short-circuiting inside the host. Nothing on the image creates that namespace — create it first, or srsUE attaches over the air perfectly and then fails with Failed to setup/configure GW interface and never gets an IP:
ip netns add ue1
cd /opt/srsran4g/build/srsue/src
setsid bash -c './srsue /root/ue-zmq.conf' >/tmp/ue.log 2>&1 &
sleep 8
grep -aE '^Random Access|^RRC Connected|^PDU Session' /tmp/ue.log
Random Access Complete. c-rnti=0x4601, ta=0
RRC Connected
PDU Session Establishment successful. IP: 192.168.100.2
srsue searches the ZMQ cell, RACHes, registers, and gets an IP in 192.168.100.0/22. (After any UE detach, restart the gNB too — a stale ZMQ tx stream stops the next UE RACHing.)
The UE's interface lives in the ue1 namespace, so run the UE side with ip netns exec ue1:
ip netns exec ue1 ip -br addr show tun_srsue
ip netns exec ue1 ip route replace default dev tun_srsue # the internet is reached through the UE
ip netns exec ue1 ping -c 4 192.168.100.1 # core gateway
ip netns exec ue1 ping -c 4 8.8.8.8 # the internet
tun_srsue UNKNOWN 192.168.100.2/24
4 packets transmitted, 4 received, 0% packet loss, time 3003ms # 192.168.100.1
rtt min/avg/max/mdev = 19.416/29.450/37.841/6.679 ms
4 packets transmitted, 4 received, 0% packet loss, time 3004ms # 8.8.8.8
rtt min/avg/max/mdev = 20.931/29.084/37.069/6.363 ms
The step-by-step above is Mode 1 (manual CLI). The same experiment ships as an Ansible playbook and a Jupyter notebook that read one parameter file, share one log parser and emit one JSON result contract, so the three can be checked against each other.
On the console of your domain (for example console.sb1.cosmos-lab.org or console.osc.cosmos-lab.org):
cd ~ && wget -q https://www.cosmos-lab.org/files/cosmos-tutorials-ocudu-soft.zip
unzip -o cosmos-tutorials-ocudu-soft.zip # extracts into ~/tutorials/
cd ~/tutorials && ls ocudu-soft
| File | Purpose |
|---|---|
ocudu-soft_params.yml |
the single parameter source (paths on the image, PLMN/IMSI, UE netns, timeouts, iperf window) |
ocudu-soft.yml |
Mode 2 — the Ansible playbook (cosmos_preflight, Open5GS, gNB and srsUE as named systemd units, measure, contract) |
ocudu-soft_manual.sh |
Mode 1 scripted: the exact commands of this page |
ocudu-soft_tutorial.ipynb |
Mode 3 — the notebook (kernel on the console, papermill-runnable) |
ocudu-soft_lib.py |
shared parser + result contract + the parity comparison spec |
ocudu-soft_parity.sh |
runs all three modes on one node and asserts they agree |
ocudu-soft_teardown.yml |
clean teardown (stops the named units, removes the UE namespace, stops Open5GS) |
OMF_NODESis required. The inventory refuses to run without it, so a playbook can never touch a node you did not name. This tutorial takes exactly one node.
cd ~/tutorials
export OMF_NODES="srv18" # your reserved node with the ocudu image
ansible-playbook ocudu-soft/ocudu-soft.yml
export OMF_NODES="srv18"
/opt/cosmos-jupyter/venv/bin/papermill ocudu-soft/ocudu-soft_tutorial.ipynb ocudu-soft/ocudu-soft_tutorial-out.ipynb
bash ocudu-soft/ocudu-soft_parity.sh srv18
=== ocudu-soft parity ===
mode ue_ipv4 ping_rtt_ms dl_mbps ul_mbps node
----------------------------------------------------
ansible 192.168.100.229.41 26.2 4.82 srv18
manual 192.168.100.231.75 26.1 5.02 srv18
jupyter 192.168.100.230.99 26.2 4.72 srv18
dl_mbps spread max/min = 1.00 (tol 1.35)
ul_mbps spread max/min = 1.06 (tol 1.40)
PARITY: PASS (same experiment in all modes; all checks green)
artifacts kept in: /tmp/ocudu-soft-parity-seskar.GvLz0P
(Real output. The table runs the UE address into the RTT column: 192.168.100.2, 29.41 ms.)
(run on srv18.osc, 2026-09-14, from the published cosmos-tutorials-ocudu-soft.zip exactly as downloaded).
⚠️ The parity harness does not tear down. It leaves the last mode's
ocudu-gnb,ocudu-srsueandocudu-iperfunits running on the node (found still running two hours after a 2026-09-14 run). Always follow it with the teardown:cd ~/tutorials && OMF_NODES=srv18 ansible-playbook ocudu-soft/ocudu-soft_teardown.yml ``` Pass criteria: every mode attaches and reports RRC connected; ping loss ≤ 5 %; DL in 5–60 and UL in 1–20 Mbit/s and within 35 %/40 % of each other across modes. ZMQ is a software transport, so these numbers measure the host and the protocol stack, never radio performance. Green parity is what sets this page's **Last verified**; re-running it after an image refresh is the regression test.
/tmp/gnb.log): "NG Setup Procedure" finished successfully and Cell was activated.srsue reaches RRC Connected + PDU Session Establishment successful.tun_srsue has an IP in 192.168.100.0/22; ping 192.168.100.1 and ping 8.8.8.8 = 0% loss.Over the ZMQ virtual radio, throughput reflects the configured 10 MHz cell (not a real air link). Use a netns so the UE traffic doesn't short-circuit via rpfilter:
# in the UE config, set [gw] netns = ue1 and: ip netns add ue1
iperf3 -s -B 192.168.100.1 -D
ip netns exec ue1 iperf3 -c 192.168.100.1 # UL
ip netns exec ue1 iperf3 -c 192.168.100.1 -R # DL
Measured (band 3, 10 MHz, ZMQ). Over ZMQ the numbers measure the host and the protocol stack, so they are listed against the CPU platform that produced them:
| Date | Host CPU platform | Image | Method | DL | UL | RTT |
|---|---|---|---|---|---|---|
| 2026-09-14 | 1× AMD EPYC 9355P, 32 cores / 32 threads | ocudu-20260909b (ocudu.ndz) |
parity bundle, three modes | 27.2 Mbit/s | 5.03 Mbit/s | 27.7–31.5 ms |
| 2026-09-14 | 2× Intel Xeon Gold 6226 @ 2.70 GHz, 24 cores / 24 threads | ocudu-20260909b (ocudu.ndz) |
parity bundle, three modes | 26.2–26.6 Mbit/s | 4.62–4.82 Mbit/s | 29.1–32.4 ms |
| 2026-09-14 | 2× Intel Xeon Gold 6126 @ 2.60 GHz, 24 cores / 48 threads | ocudu-20260909b (ocudu.ndz) |
parity bundle, three modes | 26.1–26.2 Mbit/s | 4.72–5.02 Mbit/s | 29.4–31.8 ms |
| 2026-09-08 | same host | ocudu.ndz of that date |
parity bundle, three modes | 25.7–26.2 Mbit/s | 4.62–4.92 Mbit/s | 30.0–31.9 ms |
| 2026-07-08 | same host | ocudu-20260708 |
manual, iperf3 in netns |
≈ 25.6 Mbit/s | ≈ 5.3 Mbit/s | ≈ 28 ms |
After the bundle (any mode, and always after the parity harness):
cd ~/tutorials && OMF_NODES=<node> ansible-playbook ocudu-soft/ocudu-soft_teardown.yml
After the manual path:
pkill -x gnb; pkill -x srsue; ip netns del ue1; /root/open5gs/stop.sh
omf tell -a offh -t <node> # from the console
| Symptom | Likely cause | Fix |
|---|---|---|
srsue loops "No cells found" |
missing [rat.nr] carrier knobs / wrong ZMQ ports |
use the shipped /root/ue-zmq.conf verbatim (nof_carriers=1, max_nof_prb=52, srate 11.52e6) |
srsue RF backend <none> |
ZMQ plugin not built | cd /opt/srsran4g; cmake -B build -GNinja -DENABLE_ZMQ=ON -DENABLE_RF_PLUGINS=OFF && ninja -C build srsue |
| gNB relaunch → SSH exit 255, no output | pkill -f apps/gnb/gnb killed your shell |
use pkill -x gnb |
open5gs/start.sh provisions no subscribers |
python3-pymongo missing |
apt-get install -y python3-pymongo (baked into ocudu.ndz) |
SMF crashes on missing freeDiameter/smf.conf |
5G-SA build-tree quirk | comment the freeDiameter: line in /opt/open5gs/etc/smf.yaml |
| A gNB / srsUE / iperf3 is still running long after a parity run | the parity harness ends without a teardown | run ocudu-soft/ocudu-soft_teardown.yml (see Cleanup) |
sb1/grid).omf-ocudu; srsRAN-Project YAML — skill ocudu-gnb (docs.srsran.com).open5gs-core. Upstream: docs.ocudu.org, gitlab.com/ocudu/ocudu_docs.Author(s): COSMOS team · Last verified: 2026-09-14 (three-mode parity on srv18.osc from the published bundle; first verified 2026-07-15) · Tested image/release: ocudu.ndz (OCUDU srsRAN + srsRAN_4G srsue + Open5GS) · Bundle: cosmos-tutorials-ocudu-soft.zip · Tags: ocudu, srsran, srsue, open5gs, 5g, sa, zmq, soft, cross-vendor