Skip to content

IMS on SD-Core with Kamailio, joined by SETU: voice, video and messaging

SD-Core is an open 5G standalone core. Kamailio is an open IMS. Put them side by side and you still cannot make a phone call.

Not because either is missing a feature - because nothing connects them, and nothing in the 3GPP specifications describes what that connection should look like. SETU is the piece that closes it.

The result is a working voice service: calls, video calls and text messages, verified on commercial handsets over a real 5G SA radio, an OCUDU gNB driving a LiteON O-RU, with no HSS, no policy server and no SMS centre anywhere in the deployment.

Architecture: SD-Core, SETU and the Kamailio IMS

SD-Core on the left, a stock Kamailio P/I/S-CSCF on the right, SETU in the middle. The SMF tells the handset where the IMS is and installs the reserved-bandwidth channel; the PCF authorizes the media. SETU answers the IMS on Rx and Cx and turns both into calls against the core. Along the bottom, the user plane: handset to radio to base station, into the UPF, with media riding a guaranteed channel while SIP rides the default one.

Why an open core and an open IMS cannot talk to each other

An IMS does not work out who a subscriber is on its own. It asks a database - the HSS, or Home Subscriber Server - for the credentials it needs to challenge the handset and prove the SIM is genuine. Nor does it decide by itself that a call deserves protected bandwidth: it asks a policy server, which then instructs the network to reserve it. Both of those conversations happen in Diameter, a protocol considerably older than 5G.

A 5G core holds the same information and makes the same decisions. It simply exposes them as modern service APIs over HTTP/2 - Nudr for subscriber data, N5 for media policy.

So the IMS asks questions the core can answer, in a language the core does not speak. Both sides are specified to the byte. The translation between them is specified nowhere.

The practical consequence is that every deployment writes that translation itself, by hand, against one core, once - and then writes it again for the next one.

How the three parts fit

PartWhat it doesWhat we changed
SD-Corethe 5G core - registration, sessions, policy, user plane25 files, three network functions
Kamailio 6.1the IMS - all the SIP handlingnothing, one config line at each end
SETUthe bridge between them-
OCUDU gNB and O-RUthe access networknothing, configuration only

SETU presents the IMS with exactly the interfaces it already expects: an HSS on Cx, a policy server on Rx. Kamailio has no idea it is talking to anything unusual, which is why Kamailio needed no code changes at all.

Behind that familiar face, SETU answers from the 5G core. It reads subscriber key material over Nudr and computes the authentication challenge itself, and it turns a media authorization request into the service API call the core understands. Every authorized session is journaled to a write-ahead log, so a restart mid-call does not leave reservations stranded inside the core.

One consequence is worth stating plainly: subscriber keys never leave the core. There is no second copy of anyone's credentials to provision, secure, or keep in step with the first.

What SETU replaces

A conventional IMS bolts an HSS, a policy server and an SMS centre onto the core, each holding its own subscriber state. SETU answers all three of those surfaces from the 5G core itself.

Because SETU speaks published 3GPP service APIs rather than one vendor's dialect, the same bridge should work against any core exposing them. SD-Core is where we proved it, not where it has to end.

SETU bridging any IMS to any 5G core

One shared model in the middle. Add a core, write a connector; add an external system, write an adapter.

Interfaces in this deployment

InterfaceProtocolPurpose
Gm (UE ↔ P-CSCF)SIP over IPsecregistration and call setup from the handset
Mw (CSCF ↔ CSCF)SIPP-CSCF ↔ I-CSCF ↔ S-CSCF
Rx (P-CSCF ↔ SETU)Diameter:3868 - media authorization
Cx (I/S-CSCF ↔ SETU)Diameter:3869 - registration and authentication
SMS ingest (S-CSCF ↔ SETU)HTTP:8090 - both directions
N5 (SETU ↔ PCF)HTTP/2TS 29.514 - reserve and release media
Nudr (SETU ↔ UDR)HTTP/2TS 29.505 - subscriber and key data
N2 (gNB ↔ AMF)NGAP / SCTPregistration and mobility
N3 (gNB ↔ UPF)GTP-UUDP 2152, with the PSC extension header
N4 (SMF ↔ UPF)PFCPUDP 8805
N6 (UPF ↔ network)SGiegress to the data network

The network functions that changed

The IMS needed nothing. The OCUDU gNB needed nothing. The core needed work: twenty-five files across three of its network functions.

FunctionRepositoryBranchWhat it learned to do
SMF (sessions)sdcore-smfsetu-ims-rel1tell the handset where the IMS is; turn a policy decision into a reserved-bandwidth channel a handset will accept
PCF (policy)sdcore-pcfsetu-ims-rel1match a call to the right subscriber's session; treat voice and video as guaranteed rather than best effort; release what it reserved
AMF (mobility)sdcore-amfsetu-ims-rel1resilience only, no voice logic - one bad message must not take down every subscriber

One rule held throughout: every change stays inside the network function it belongs to. No forked shared libraries, and dependency files identical to the upstream release. That sounds like housekeeping, but it decides whether this work survives - upgrading to a newer SD-Core release is a rebase, not a fresh start.

Each branch begins with the untouched upstream tree as its first commit, so the difference between upstream and ours is one git diff away. Each repository's README explains its own changes in detail.

Building the images

You do not need to clone anything, check out a branch, or run four builds by hand.

git clone https://github.com/TOSSI-Foundation/TI.git
cd TI/CORE/sdcore-ims
./build-nf-images.sh --verify

That fetches the three network functions at setu-ims-rel1 and SETU at main, then builds 5gc-smf, 5gc-pcf, 5gc-amf and setu - everything except Kamailio, which runs stock from its own published image.

Useful variations:

./build-nf-images.sh --load-containerd        # load straight into k3s/rke2, no registry needed
./build-nf-images.sh -r ghcr.io/acme/ --push  # build and publish
./build-nf-images.sh --nf setu                # just the bridge
./build-nf-images.sh --dry-run                # show the plan, change nothing

It needs only git and docker. The network functions' Makefile vendors dependencies with Go before building; if Go is not installed, that step runs inside a container instead, so the upstream build path is still what executes. SETU compiles inside its own Dockerfile and needs neither.

--verify is worth running at least once. It reaches into each finished image, pulls the binary back out, and checks that code which exists only because of this work is actually present - the difference between "an image built" and "the right image built". It catches the most common mistake, which is building from the wrong branch.

Deploying the stack

export NODE=203.0.113.10       # this host's IP - the CSCFs, SETU and the gNB all reference it
export CORE_NS=aether-ims      # your SD-Core namespace
export DOM=ims.mnc001.mcc001.3gppnetwork.org

You need: a single-node Kubernetes (RKE2) host with an SCTP-capable CNI, the aether-onramp repository, a 5G SA gNB with a radio unit (we used OCUDU with a LiteON O-RU), and at least one programmable SIM.

ComponentVersion here
KubernetesRKE2, Canal CNI, SCTP enabled
SD-Core (via aether-onramp)4.1.3
SMF / PCF / AMFTOSSI-Foundation, branch setu-ims-rel1
UDM / UDR / AUSF / NRF / NSSFupstream SD-Core 4.1.3
UPFbess-UPF, af_packet, gtppsc: true
Kamailio P/I/S-CSCF6.1 upstream, configuration only
gNBOCUDU, configuration only
Radio unitLiteON O-RU, 7.2 fronthaul split
SETUcoranlabs/SETU

The 5G core

Install SD-Core via aether-onramp, PLMN 001/01:

helm -n $CORE_NS upgrade --install sd-core-ims ./sd-core \
  --values ims-values.yaml --create-namespace
kubectl -n $CORE_NS get pods

Point the three deployments at your new images. With --load-containerd they are already in the node's containerd:

for nf in smf pcf amf; do
  kubectl -n $CORE_NS set image deploy/$nf $nf=5gc-$nf:setu-ims-rel1
done

Keep these images somewhere you control. Locally-imported images are eligible for kubelet garbage collection, and an eviction under disk pressure takes voice down while data keeps working - a genuinely confusing failure to walk into.

Three settings enable voice:

  • SMF - pcscfInfos.ipv4 set to the P-CSCF address, so it reaches the handset when the data session is set up. Without it a phone attaches happily and never registers. Point the PFCP node-id at the UPF's Service ClusterIP so a UPF restart does not break the association.
  • UPF (upf.jsonc) - gtppsc: true. Real base stations add a small header to user traffic; without this the forwarding element discards every packet from the handset.
  • PCF - enable the SelfHeal orphan purge, so a call that ends badly does not leave reservations behind.

Restart the UPF adapter before the SMF, so the association re-forms in the right order:

kubectl -n $CORE_NS rollout restart deploy/upf-adapter deploy/smf

Provision subscribers through the omec sub-provision simapp with provision-network-slice: true - key material, an MSISDN, a device group tied to the slice, the ims data network for voice and internet for data.

The user plane reaches the network over a host macvlan. Wire the subscriber pool out through your uplink:

sudo ip link add core-ims link <n6-nic> type macvlan mode bridge 2>/dev/null || true
sudo ip addr add 192.168.244.1/24 dev core-ims 2>/dev/null; sudo ip link set core-ims up
sudo sysctl -qw net.ipv4.ip_forward=1 net.ipv4.conf.all.rp_filter=0 net.ipv4.conf.core-ims.rp_filter=0
sudo ip route replace 192.168.111.0/24 via 192.168.244.3 dev core-ims
sudo iptables -t nat -C POSTROUTING -s 192.168.111.0/24 -o <uplink-nic> -j MASQUERADE 2>/dev/null \
  || sudo iptables -t nat -A POSTROUTING -s 192.168.111.0/24 -o <uplink-nic> -j MASQUERADE

The IMS

Stock Kamailio 6.1 on host networking, with rtpengine anchoring media and MySQL behind the S-CSCF. Ordinary IMS configuration throughout, with av_mode=0 on the S-CSCF so it takes real authentication vectors over Cx rather than computing them locally.

docker run -d --name kam-mysql --network host --restart unless-stopped \
  -e MYSQL_ROOT_PASSWORD=<choose-one> -e MYSQL_ROOT_HOST=% mysql:8.0

docker run -d --name rtpengine --network host --restart unless-stopped \
  --entrypoint /usr/local/bin/rtpengine drachtio/rtpengine:latest \
  --interface=$NODE --listen-ng=127.0.0.1:2223 --table=-1 \
  --port-min=30000 --port-max=30100 --foreground --log-level=6

Load the ims_usrloc_pcscf / ims_usrloc_scscf, ims_icscf, domain, ims_dialog, usrloc and dispatcher schemas shipped in the Kamailio image, seed the domain and the S-CSCF URI, then start the three CSCFs. Each mounts its config tree at /cfg; host aliases resolve the IMS domain to $NODE, because there is no SRV or NAPTR anywhere in this deployment:

KIMG=ghcr.io/kamailio/kamailio-ci:6.1-alpine

docker run -d --name scscf --network host --restart unless-stopped \
  --add-host hss.$DOM:$NODE --add-host scscf.$DOM:$NODE --add-host $DOM:$NODE \
  -v $PWD/kam-scscf:/cfg --entrypoint sh $KIMG \
  -c 'mkdir -p /var/run/kamailio_scscf; exec kamailio -f /cfg/kamailio.cfg -DD -E'

docker run -d --name icscf --network host --restart unless-stopped \
  --add-host hss.$DOM:$NODE --add-host scscf.$DOM:$NODE --add-host icscf.$DOM:$NODE --add-host $DOM:$NODE \
  -v $PWD/kam-icscf:/cfg --entrypoint sh $KIMG \
  -c 'mkdir -p /var/run/kamailio_icscf; exec kamailio -f /cfg/kamailio.cfg -DD -E'

docker run -d --name pcscf --network host --restart unless-stopped --cap-add NET_ADMIN \
  --add-host rxgw.$DOM:$NODE --add-host icscf.$DOM:$NODE --add-host scscf.$DOM:$NODE \
  --add-host pcscf.$DOM:$NODE --add-host $DOM:$NODE \
  -v $PWD/kam-pcscf:/cfg --entrypoint sh $KIMG \
  -c 'mkdir -p /var/run/kamailio_pcscf; exec kamailio -f /cfg/kamailio.cfg -DD -E'

Point the seam at SETU. In the P-CSCF config set the Rx peer to $NODE:3868; in the I-CSCF and S-CSCF configs set the Cx peer to $NODE:3869. Nothing else on the IMS side changes.

The bridge

SETU runs alongside the cluster rather than in it, on host networking, because it binds the Diameter ports and the SMS ingest:

PCF=$(kubectl -n $CORE_NS get svc pcf -o jsonpath='{.spec.clusterIP}')
UDR=$(kubectl -n $CORE_NS get svc udr -o jsonpath='{.spec.clusterIP}')

cat > setu.json <<JSON
{
  "plmn": { "mcc": "001", "mnc": "01" },
  "core": "sdcore",
  "sdcore": { "pcf": "https://$PCF:29507", "udr": "https://$UDR:29504", "insecure": true,
              "notifURI": "http://$NODE:7777/notif", "notifListen": ":7777" },
  "rx":  { "listen": ":3868", "hostIP": "$NODE", "walPath": "/var/lib/setu/rx-grants.wal" },
  "cx":  { "listen": ":3869", "hostIP": "$NODE", "scscf": "sip:$NODE:6060", "admin": ":9102" },
  "sms": { "listen": "127.0.0.1:8090", "scscf": "$NODE:6060", "selfIP": "$NODE" }
}
JSON

docker run -d --name setu --network host --restart unless-stopped --stop-timeout 15 \
  -v $PWD/setu.json:/etc/setu/setu.json:ro -v /var/lib/setu:/var/lib/setu \
  setu:setu-ims-rel1 -config /etc/setu/setu.json -apps rx,cx,sms

The --stop-timeout is load-bearing: SETU releases what it holds when asked to shut down, and cutting that short leaves reservations stranded on the core. The repository carries a systemd unit if you would rather not run it in Docker.

The radio

Any 5G SA gNB works, and none of this needs a code change to the radio software. We drove a LiteON O-RU over a 7.2 fronthaul split with OCUDU, and the only edits were to its configuration file: the AMF's N2 address, the gNB's own bind address, the network identity, tracking area and slice, and the user-plane bind address toward the UPF.

sudo ./gnb -c <your-gnb-config>.yml

Confirm the setup handshake succeeds and the gNB reports the served network, then attach a provisioned SIM.

Checking it works

Commercial handsets on a real 5G SA radio: an OCUDU gNB driving a LiteON O-RU. Not a simulator, and not UE emulation.

Result
Registrationfull challenge-and-response, accepted by the handset, no HSS on the network
Voicededicated reserved channel per call, released on hangup
Videosecond reserved channel alongside voice
Messagingboth directions, plain text and emoji, with delivery reports
Teardownclean across repeated calls, nothing accumulating
Restart safetySETU killed mid-session and restarted, nothing stranded on the core
kubectl -n $CORE_NS logs -f deploy/pcf   # media reserved and released, per call
kubectl -n $CORE_NS logs -f deploy/smf   # the reserved channel being set up
docker logs -f setu                      # REGISTER , AUDIO/VIDEO , SMS , CALL-END
docker logs -f pcscf 2>&1                # the SIP exchange

The single check that separates a working call from one that merely looks right: the reserved channel must appear when the call starts and disappear when it ends. A channel that comes up and stays is a call that never really hung up, and the residue builds until calls start failing for reasons that look unrelated.

Failures worth knowing about

Each of these cost us about a day.

Reserving bandwidth of zero. A reserved channel with no bandwidth figure attached is still a reserved channel - the network sets it up, reports success, and the handset then refuses the call because the guarantee it was promised never materialised. Everything looks correct except the outcome.

Two systems sharing one counter. IMS and 5G authentication draw on the same anti-replay counter. Use it without advancing it and the next attempt is rejected as a replay. Registration then fails in a way that looks exactly like poor radio coverage, and you will spend a day on the radio.

A user plane that fails in silence. With gtppsc off, every packet from the handset is discarded while the call setup, the reserved channel and every log you would think to check all report success. A reserved channel carrying no audio looks precisely like a working call that nobody can hear.

Benign noise from Kamailio. The P-CSCF logs to stderr and emits a steady stream of timeouts from idle connections to sleeping handsets. These are not faults - filter them at the viewing layer rather than chasing them.

Unreachable session addresses. A policy function that builds its session address from its own internal service name hands back something the bridge cannot dial. Releases fail quietly and reservations pile up. SETU re-attaches the path to an address it knows is reachable, but the core-side lesson is to make that address resolvable in the first place.

How a call, a registration and an SMS flow end to end

End-to-end call flow across SD-Core, SETU and the IMS

A call. A subscriber presses dial. The INVITE reaches the P-CSCF, which asks SETU over Rx to authorize the media. SETU turns that into an app-session on the PCF, the PCF instructs the SMF, and the SMF brings up the reserved channel on the radio. The call connects and audio rides the guarantee. On hangup the reverse happens: the release travels back through SETU and the reservation is torn down.

A registration. The same shape over Cx. The I-CSCF asks who should serve the subscriber, the S-CSCF asks for an authentication challenge, and SETU answers both from the core's own subscriber data. The handset is challenged, answers, and is registered, with no HSS in the path.

An SMS. Skips the reserved channel entirely. The message arrives as SIP, SETU decodes and rebuilds it, delivers it to the recipient, and returns a delivery report to the sender.

Reference

  • coranlabs/SETU - the bridge
  • TOSSI-Foundation/TI - this guide and the build script, at CORE/sdcore-ims/
  • Network functions - sdcore-smf, sdcore-pcf, sdcore-amf, all on setu-ims-rel1
  • Standards - TS 29.214 (Rx), TS 29.228/29.229 (Cx), TS 29.514 (N5), TS 29.505 (Nudr), TS 24.501 (NAS and PCO), TS 24.011 / 23.040 (SMS), TS 38.413 (NGAP)

Built on SD-Core from the OMEC project and the Open Networking Foundation, deployed with aether-onramp, and on Kamailio and rtpengine. Our thanks to their maintainers.