Compare commits

..
Author SHA1 Message Date
2b5de7a60b Update README.md
All checks were successful
package / package (push) Successful in 1m51s
2026-10-16 20:37:28 +00:00
547361a4d4
imsd 1.1.0
All checks were successful
package / package (push) Successful in 1m48s
2026-10-16 22:25:49 +02:00
e31887bf21
sdp, engine, imsd, media: the far end's telephone-event packets are played as the tones they stand for
Keys pressed on the other phone reached imsd-media as RFC 4733 events and
were dropped with every packet that is not the audio's. The playout now
takes them: the gap before an event's start (a stock sender's DTX silence
often precedes a key press) is filled as before an audio frame, each
packet reports the event's duration so far from its start stamp, and what
the clock has not covered yet is synthesized as the event's Q.23
frequency pair at the packet's volume, so a retransmitted end packet adds
nothing, a lost update is caught up by the next, and a far end whose
audio resumes past the event (the stock phone's does) meets the clock
with the usual fill. An answer may renumber telephone-event from what we
offered (RFC 3264: the answerer sends with the offer's numbers, §6.1,
and the offerer with the answer's, §5.1), so the leg gets both: DTMF_PT
to send with, DTMF_RX_PT to take events at as well — our own SDP's
number: the offer's 98 or 100 on a call we placed, the offer's number we
echoed on one we answered; when the answer gave the audio our offer's
number, that number is the leg's audio and DTMF_RX_PT is the answer's
telephone-event number instead — and takes events at either number,
never at the audio's. --selftest pins the frequency table; the stats
gain rx_dtmf, tones and dtmf_rx_pt.

Tested: --selftest, tests/Sdp and tests/Engine (the eight suites pass);
the loopback's --dtmf-rx arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c3-rx.txt; the tx, both,
plain and collision runs beside it); the dev phone's dtmf benches ran in
builds of this series.
2026-10-16 22:25:49 +02:00
92fba40da0
sdp, engine, imsd: SendDtmf sends the tones as RFC 4733 events over the media leg
SendDtmf was a stub: GNOME Calls' SendTones and Plasma Dialer's sendDtmf
reached imsd and went nowhere. The SDP parser reads the telephone-event
payload type at the chosen codec's clock into the answer, among the
m-line's formats only — an rtpmap the line does not list is not
negotiated, and nothing is sent with a format the answer does not carry
(RFC 3264 §8.3.2); an event's rate is its rtpmap clock (RFC 4733
§2.4.1), an offer with two codec clocks lists telephone-event at each,
and the name is matched as every encoding name is, case-insensitively,
with the clock whole. The media leg carries it, and imsd-media gets it
as DTMF_PT with its stdin on a pipe from imsd, the control pipe. The
SDP answer imsd sends to an inbound offer carries telephone-event by the
same rule, where it echoed any telephone-event rtpmap with a lower-case
name and a clock prefix before: an rtpmap off the offer's m-line is no
longer echoed, an upper-case name now is. The decision is the engine's:
OnDtmf lets the tones through on an active call that negotiated
telephone-event (the answer's number on a call we placed, the offer's on
one we answered) and drops them otherwise with a line that says why:
before the answer, after the call, no telephone-event at the codec's
clock in the SDP, a character that is no tone or more than 128 tones
(then the whole string, never part of what was pressed); letters are
upper-cased. The shell writes one `dtmf <tones>` line per SendDtmf,
whole or not at all: the bound keeps the line within one atomic pipe
write, the write end is non-blocking so that a leg that stopped reading
fails the write instead of holding the engine thread, and a longer line
is refused; SIGPIPE is ignored so that a write after the leg died fails
with EPIPE instead of ending the daemon. The command queue's one
argument is `arg` now — the number to dial, or the tones. The `media …`
start line gains the telephone-event pt at its end.

Tested: tests/Sdp and tests/Engine (the eight suites pass); the dev
phone's dtmf benches ran in builds of this series
(journal/ims/captures/2026-10-14-dtmf-bench-1/ onward).
2026-10-16 22:25:49 +02:00
29d624fc55
media: RFC 4733 telephone-event uplink, the tones from dtmf <tones> lines on stdin
The keypad during a call (a voicemail menu, an IVR) needs DTMF, and on an
IMS network it travels as RFC 4733 telephone-event packets on the call's
RTP stream. imsd-media reads `dtmf <tones>` lines on its stdin: each
queues the tones (0-9, *, #, A-D), and a tone takes seven 20 ms slots in
place of the audio frames — the event's five (100 ms at -10 dBm0: the
marker bit on the first packet, the duration growing a slot at a time,
the end bit on the fifth packet) and two in which the end packet goes out
again (§2.5.1.4) — then three slots of audio before the next tone. Events
and audio share the stream's sequence and timestamp space (§2.1), and one
sender keeps it: the mic thread hands each captured frame to the DTMF
state, which under one lock sends either the event packet the slot
carries or the frame (the main loop's silence tick does the same without
a mic, and so does the latch burst), so one packet leaves per slot, the
clock stays the capture's, and no frame can land inside an event
whichever thread holds the slot. The payload type comes from DTMF_PT in
the environment; unset means no DTMF and a dtmf line is dropped with a
message; a line of more than 4096 bytes is dropped through its end and
the next line goes. Nothing in imsd supplies the lines or the variable at
this commit; a hand run or the loopback does here, and a hand run behind
a job-control terminal is not stopped by pending input (the reader blocks
SIGTTIN, its read fails and it ends). pw-record no longer inherits stdin.
The stats sidecar gains tx_dtmf and dtmf_pt.

Tested: --selftest, and the fp6 journal's utilities/media_loopback.py
--dtmf arm on a host build of this commit's tree
(journal/ims/captures/2026-10-15-dtmf-loopback-c1-tx.txt; the plain, the
two overflow, the no-mic and the early-tones runs beside it) and its
ttyin.py pty test of the background hand run
(journal/ims/captures/2026-10-16-dtmf-ttyin-hand-run/); the dev phone's
dtmf benches ran in builds of this series.
2026-10-16 22:25:48 +02:00
108afcce96
README: trim the intro, drop the status section
The intro keeps one sentence; what works is the device and carrier table,
under "Calls verified by community members to be fully working".
2026-10-16 22:25:48 +02:00
78685c4b41 Update README.md
All checks were successful
package / package (push) Successful in 1m41s
2026-10-16 14:53:08 +00:00
d4a0759e57
imsd 1.0.0
All checks were successful
package / package (push) Successful in 1m54s
2026-10-13 22:28:03 +02:00
7a3dcc8951
packaging, ims-pdn-up, imsd, README: imsd runs without /etc/imsd.env and idles without a SIM
With the P-CSCF coming from the PDN's PCO, the configuration file has no
required line left, so the apk's ConditionPathExists=/etc/imsd.env drop-in
goes: the unit starts on every phone with the package, and VoLTE is on by
default. Installing the package and inserting a SIM is the opt-in; the file
holds overrides only, and its existence changes nothing. By the versioning
rule (AGENTS §11, the first commit of this series) that is what moves the
major number: the release is 1.0.0, and §11 now records it. What used to be the
file's job — keeping a phone nobody configured out of a failing unit — is now
a rule on facts, the same for every phone: a modem in the failed state with
a SIM reason makes the script write IMSD_IDLE=no-sim to /run/imsd.env and
exit 0; a profile-indexed connect series the network refuses ten times in a
row — each a persistent 3GPP cause in ModemManager's typed error (not
subscribed, unknown APN, barred, not authorised in the PLMN, authentication
failed, the service option not supported, the wrong PDN type) with packet
service at the time; counted only while the modem reads registered,
connecting or connected with packet service not detached — stricter than
the daemon's PacketServiceUp; any other read, a timeout or a transient cause
starts the count again — writes IMSD_IDLE=no-ims-pdn; and a bring-up that
ends with no P-CSCF — none configured, none discovered: no IPv6 P-CSCF read
from the PCO (PCSCF_DISCOVERY=none in the hand-off, the reader's exit 2: the
list absent, empty, unreadable or IPv4/FQDN only), discovery skipped (no
rmnet mux id) or no hand-off at all (a manual run) — and no registration of
imsd's own to resume is the daemon's own idle, decided before it takes the bus name (SaPeer and the
holds' old_gone say what can be resumed; a pair the holds wrote off is taken
out of the kernel there) and at the no-target bring-up, where a pair nothing
can resume is written off and taken out of the kernel first. imsd reads the key, logs idle: … and exits 0, which Restart=on-failure
leaves alone, so the unit stays inactive until the next boot, a systemctl
start or a start of imsd-ofonod where that runs (its Wants starts an idle
imsd again). What is not a fact keeps the retry: no coverage, a SIM still
waiting for its PIN, a modem without an ims profile (the APN-string connect
is tried), an APN-string series (the modem's own wall), a resumable pair
whose attempt failed (the fatal), and a P-CSCF reader that did not answer
(PCSCF_DISCOVERY=failed: the daemon exits 1 and the restart runs discovery
again). A phone that never had the file keeps its stopped unit through the
upgrade and starts at its next boot, registering where the rule above lets
it — at the upgrade itself where imsd-ofonod runs, whose restart pulls imsd
in through its Wants (systemd queues the marked unit's try-restart, collapses
it to a restart and starts what the unit Wants in the same transaction:
job_type_collapse, transaction_add_job_and_dependencies, 262~rc3); the
opt-out is systemctl mask --now imsd, since a plain disable is undone by
that Wants. imsd-ofonod.service loses its After=imsd.service: with imsd
starting on every phone, GNOME Calls' backend would otherwise wait behind
the ims PDN bring-up at every boot and at its marked restart at an upgrade,
for the whole bring-up (up to its TimeoutStartSec=900); it starts at once and republishes when imsd's name
appears (OnImsdAppeared).
The configuration file's path is the IMSD_ENV seam the mock bench uses.
AGENTS §6's lint figures are this tree's (109 findings in 16 of 20 files,
2026-10-13; the 72 of 2026-10-06 predate the Qmi files).

Tested: crafter-build test, 8 passed; on the host build the daemon's idle
exit for the script's keys and, with no pair, for every discovery verdict
but failed (none, skipped, no hand-off) — exit 0 — and the exit 1 for
failed; the script's arms on the mock bench; on the dev phone (KPN), in
builds of this series: the daemon's idle at the no-target bring-up with the
dead pair removed and before the bus name for a pair the holds had written
off, the discovery-failed exit 1 and its restart, the start without the file, the pull-in by an imsd-ofonod restart
and the mask holding against it and a reboot, and imsd-ofonod starting at
once with imsd stopped and republishing when imsd's name appeared.
2026-10-13 22:28:03 +02:00
1d28063700
ims-pdn-up: discover the P-CSCF list once the PDN is up and hand it to imsd
With the netdev configured, the script runs imsd-pcscf against the
netdev's rmnet mux id (and the ims profile index when it knows one) and
hands the IPv6 addresses it prints to imsd as PCSCF_DISCOVERED= in
/run/imsd.env and gives the first — the daemon's fresh target — a host
route (the SA peer of a registration imsd resumes is routed by imsd itself,
as the configured address's arm already left it); IPv4 addresses and names
are logged and dropped (the PDN is IPv6-only), at most eight are handed
over, and nothing usable is logged with its cause and is not the
script's failure: imsd resumes a registration of its own when it can, else
says so and exits, and the restart runs discovery again. PCSCF= still wins and skips discovery; a list in it is refused.
The script does not read the variable from its environment: the unit
hands it the previous run's file, and a stale discovered value must not
pass for the admin's setting. The reader's path is the IMSD_PCSCF seam
the mock bench uses.

Tested: on the mock bench against this commit's script (the twelve 0.3.10
scenarios; this step's discovery arms S13–S17 and S19–S22, with S18 the
pre-existing non-IPv6 refusal; S55, the reader's rc-0 answer without an
address), and in builds of this series on the dev phone (KPN), whose PCO —
two IPv6 addresses, nothing else — reaches the plain found arm; the
configured skip, the bracketed refusal and, through the IMSD_PCSCF seam, the
reader's failure and its exit 2 ran on the phone too; the v4/FQDN line, the
eight-address cap, the IPv4-only and no-mux-id skips, the comma-list refusal
and the rc-0 arm on the mock only. On a tip build of this series (daemon
2ec1a02f62831edb; bench 40, 2026-10-13) a fresh registration reached a target
this script had not routed — the reader stubbed through the IMSD_PCSCF seam to
find none, the address in /etc/imsd.env as PCSCF_DISCOVERED= — over imsd's own
route, and a resume stood with the peer's route removed first.
2026-10-13 22:28:03 +02:00
bd52afd931
imsd: P-CSCF candidates from PCSCF_DISCOVERED when PCSCF= is not set
imsd reads PCSCF_DISCOVERED= — a comma-separated IPv6 list — from its
environment; nothing in this tree writes it yet. FreshPcscfFromEnv picks
the fresh target: PCSCF= when set — one address, it wins — else the first
entry of the list CanonicalIp6 accepts; the journal says which, and names
the entries it refused. imsd routes the P-CSCF it uses itself — the fresh
target before a fresh registration (BringUp, FreshRecover), the resumed SA
peer once its SAs are confirmed — with the same idempotent `ip -6 route
replace` as a call's media peer: the bring-up script routes only the configured
PCSCF=, so a target taken from PCSCF_DISCOVERED= (set by hand in this tree),
or a route lost with the PDN, had no route. The start line prints that target; a resume still follows the
kernel's SA peer and now says whether that peer is in today's list. With
neither variable and nothing to resume, the fatal names both.

Tested: crafter-build test, 8 passed; the configured, discovered, differing (the
discovered list's order flipped: the resume stays on the SA peer, the next
fresh registration's target is the other address) and empty arms in builds
of this series on the dev phone (KPN).
2026-10-13 22:28:02 +02:00
a89ca5a60c
qmi, imsd-pcscf: read the ims PDN's P-CSCF list from the modem's runtime settings
The network hands the P-CSCF addresses to the modem in the PDN's Protocol
Configuration Options; ModemManager's bearer code does not ask the modem
for them, qmicli (libqmi 1.39) prints none of them, and libqmi has no TLV
for the IPv6 list (0x2E), which is why PCSCF= was a required setting. A
second QMI WDS client reads them once it is bound to the bearer's mux
data port (the binding ModemManager gives its own clients): Bind Mux
Data Port, Set IP Family 6, Get Current Settings, over one AF_QIPCRTR
socket. Imsd:Qmi holds the bytes and the readers (0x22 the flag, 0x23
IPv4, 0x24 FQDN, 0x2E IPv6 in Qualcomm's layout: a count, then 16 bytes
each); imsd-pcscf is the socket, the service lookup and the sequence, and
prints the list, one address per line; nothing runs it yet. It sends
no Start Network and no Stop Network; the bearer stayed connected and the
registration stood after its runs (the fp6 journal's probe capture 04,
benches 38 and 39). The
build, the CI cross-compile, the tarball and the apk gain the product;
linux-headers is named in the sysroot for <linux/qrtr.h>.

Tested: crafter-build test, 8 passed; the aarch64 build of this commit's
helper on the dev phone (KPN) against the live ims bearer.
2026-10-13 22:28:02 +02:00
40a960f749
ipsec, engine, imsd: the state file records the P-CSCF and a resume follows the kernel's SA peer
A resume compared PCSCF= against the kernel's SAs and, when they did not
match, registered fresh without a word — a second initial REGISTER from an
address whose registration is live; on KPN a fresh pair to the same P-CSCF
meets the 21-minute refusal that costs the live registration (§13), what
another P-CSCF does with it is unmeasured. The registration was made to
whatever P-CSCF the SAs name (the SA setup takes imsd's previous pair
out first), so that is where a resume belongs: SaPeer reads
it from the dir-out transport policies — the one address a reqid-1 and a
reqid-2 policy both name, each with imsd's fixed protected port as its
selector's sport (reqid and mode alone are anyone's): the state text cannot tell the UE from the
P-CSCF, a lone policy is not ours and a VPN's beside the pair changes
nothing — TryResume resumes there, routes it, and says so when PCSCF=
differs; the next fresh registration moves to PCSCF= and moves back when
it fails before the SA setup and the old registration still stands (its
address still on the link), as it already did between addresses, and
its fallback Route is the P-CSCF it registers at, set before the 200's
Service-Route is looked for — an empty Service-Route value counts as
none — so a move never keeps the previous node's route. A file left by an attempt that got through the SA setup and did not live
to record — to this or another P-CSCF — is recognised by its SA pair: the
file's spi_uc/spi_us (a 0.3.x file carries them too) against the kernel's
(FileNamesAnotherPair, Imsd:Ipsec, pinned in tests/Ipsec), instead of
resumed with the wrong dialog; the state file gains pcscf, named in that
line. Both fresh paths — the bring-up's and recovery's — now set
their target themselves: with no PCSCF=, a bring-up that did not resume
fails with its own message, and since no fresh one follows: a resumable
pair whose attempt failed stays for the restart to resume, a pair nothing
can resume — one the engine's rule wrote off during the attempt among them,
a refresh refused with a 403 that carries a Retry-After or a binding a NOTIFY
terminated under the re-REGISTER wait, decided once after both catch arms by
its verdict (Reconnectable(), and with no fresh target Fresh(): a 401, a
refusal with a Retry-After, the second refusal — with no fresh target
every refusal reaches the reducer and its count travels in the holds, so
the restart's refusal is the second, while with a target only a
Retry-After does, as before — a refusal without one is not counted and the
fresh registration follows at once; without the count a refused no-target resume would re-REGISTER on the
same Call-ID at every RestartSec, the F16 loop) — is removed so the next
bring-up finds nothing — and a recovery step that needs one restarts the daemon; the "PCSCF not set" check at the top of BringUp is gone, so a
registration can be resumed with no setting at all. Three journal lines of
the fresh-target path change with it: `resume connect failed; falling back
to fresh` and `state file transport differs from SIP_TRANSPORT; fresh` end
in `the next registration is a fresh one`, and a resume the engine writes
off (a 403 with a Retry-After, a binding a NOTIFY terminated under the wait)
logs `resume failed: … — written off by the network: the next registration
is a fresh one` (§7.8). `no reg state file; resume falls back to fresh`
keeps its text with a fresh target and ends in `nothing can resume it: the
pair is removed` without one; the new `the state file names another SA pair
(0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another attempt's; …`
line takes the same two tails.

Tested: crafter-build test, 7 passed (tests/Engine: the refusal count's travel
through the holds); the resume on the SA peer with
the fresh target absent in builds of this series on the dev
phone (KPN), and the no-target bring-up there with a resume that failed on
a transport mismatch (the pair kept, the restart resumed) and with the
state file removed (the pair removed, the next bring-up found nothing); the
two dead-resume lines' no-target tail — a missing file, a file with another
pair's SPIs — and the pair's removal on the tip's host build (bench 23f: a
fake modem on PATH, a real xfrm in a namespace; a file with the pair's SPIs
but another P-CSCF's address passes the check and the resume runs — `RESUME
via existing SA`, the connect then failing in the namespace); on the dev
phone (bench 41, the tip's build) a resume on a file without its pcscf key,
the live pair's SPIs.
2026-10-13 22:28:02 +02:00
21cb0f870d
ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm
A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.

OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).

Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
2026-10-13 22:28:02 +02:00
b60a3633c1
ipsec, imsd, ims-pdn-up: the SA reader and the holds key compare in canonical form; LOCAL is canonicalised, PCSCF= accepted in RFC 5952 §4 form only
ParseExistingSa and SaLocals compared PCSCF= and LOCAL= as text against
`ip xfrm` output, which prints RFC 5952 form, so an upper-case or
uncompressed PCSCF= found no SAs and the restart registered fresh — on
KPN into the live-SA refusal; the holds file's pdn key compared the same
way. Both sides of each of those compares now go through
imsd::util::CanonicalAddr, LOCAL is canonicalised once on the way in, and PCSCF= is accepted in
that one spelling only — upper case, leading zeros or an uncompressed run
are refused at bring-up with the spelling to use, a dotted-quad tail (the
form ip prints for an IPv4-mapped address, which CanonicalIp6 does not read)
with the rule alone, a bracketed one by the bring-up script, which routes
the value as typed, with the rule (no brackets) — and a comma-separated
PCSCF= (a list) with its own message instead of reaching the socket layer: one spelling,
instead of a leniency both consumers of the setting would have to share.

Tested: crafter-build test, 7 passed; the upper-case PCSCF= refused with the spelling to use, a bracketed one
refused by the script, and the spelling they name resuming, in builds of
this series on the dev phone (KPN).
2026-10-13 22:28:02 +02:00
146d4368d0
util: canonical IPv6 text (RFC 5952 §4) and an address-list parser
imsd compares addresses as text against `ip xfrm` output, which prints
RFC 5952 form, while PCSCF= and LOCAL= arrive in whatever spelling the
user typed. CanonicalIp6 produces the §4 form from an IPv6 literal
(brackets accepted; a zone, a port, a dotted-quad tail, a name or an IPv4
address are refused; an IPv4-mapped address spelled in hex comes back in
hex, where inet_ntop writes it dotted), CanonicalAddr applies it to IPv6 only, and
ParseAddressList turns a comma-separated list into canonical,
deduplicated IPv6 literals in order, keeping the entries CanonicalIp6
accepts and naming every entry it refuses — an IPv4 address, a name, an
IPv6 literal with a zone or a dotted-quad tail — to a caller who asks for
them (duplicates and blanks are dropped
silently). Pure std, so the partitions stay free of inet_pton; nothing calls them yet.

Tested: crafter-build test, 7 passed.
2026-10-13 22:28:02 +02:00
9bdbb96b02
AGENTS.md: the versioning rule gains its major number
0.3.12's rule (§11) said the major number stays 0 and that what 1.0 would
mark is not decided. It is now: the major number moves when a release
changes what an installed phone does without being asked — not with an
ABI change (the ABI is frozen) and not with the size of a release. The
rule comes first so the commits that follow can name the release they are
part of.
2026-10-13 22:28:02 +02:00
a7caceb1b0
imsd 0.3.12
All checks were successful
package / package (push) Successful in 1m37s
2026-10-11 02:56:47 +02:00
f3cd70d05e
AGENTS.md: the versioning rule, as practised
0.3.12 is a fix release and stays in 0.3. The patch number counts
releases, and the minor number last moved with emergency calling
(f2a4048, 0.3.0). Nothing in the repository said so; §11 now does, so a
release's number is not re-decided each time.
2026-10-11 02:56:47 +02:00
729ccaa01d
imsd: a resumed registration echoes the 401's Security-Server list from the state file
A warm resume rebuilt its Security-Verify from the kernel's SA: one
mechanism with hmac-sha-1-96 and aes-cbc written in, where RFC 3329
§2.3.1 has the client mirror the server's whole list. The fresh
registration kept the 401's list in memory, so a registration this
process made fresh echoed it; after a restart, the resumed
registration's re-REGISTER, its keepalives, its reg-event SUBSCRIBE and
its INVITEs carried the rebuilt entry until the next fresh registration.
KPN's one-entry answer to the default offer is the rebuilt entry byte
for byte; against a six-entry list (O2's) the mirror is broken, which
the RFC has the server answer with a 494, and a refused resume
registers fresh.

The fresh registration now stores the joined Security-Server value in the
state file (security_server, next to the challenge answer) and the resume
takes it from there, logging the mechanism count, when the entry
carrying its SPIs names the SA pair in the kernel: a file an attempt the
process did not live to record left behind (its SAs installed, its 200
not written) names another. A file from before this change holds none.
In both cases the resume falls back to the rebuilt entry, says which,
and the registration's next state write stores that entry, which is what
it echoes; the choice is Imsd:Ipsec's, pinned in tests/Ipsec with O2's
recorded list and KPN's. A value with a quote, a backslash or a newline in it is
written empty, which the resume treats as absent; RFC 3329's token
syntax produces none of these, a quoted-string extension parameter
could carry the first two.

Tested on the dev phone (KPN) with the build of this tree: a resume from
a state file the 0.3.11 daemon wrote logs the fallback and succeeds, so
do one from a file whose list names another SA pair and one whose list
is empty; two keepalives at a 120-s interval; a fresh registration
stores KPN's list byte-for-byte as the journal shows it received; a
restart resumes with the list from the file; an outgoing call.
2026-10-11 02:56:47 +02:00
53b8c5c8e7
imsd 0.3.11
All checks were successful
package / package (push) Successful in 1m37s
2026-10-10 21:43:08 +02:00
799fd1a802
engine, messages, util, imsd: the reregistration carries the last challenge answer and new SA parameters
The keepalive refresh and the resume's re-REGISTER carried an initial
registration's first-REGISTER Authorization — an empty nonce and
response — in the second REGISTER's frame, with a Security-Client
naming the SPIs and ports in use. TS 24.229 5.1.1.4.2 has a
reregistration carry the Authorization
of the last successful authentication — the last received nonce and
the last calculated response — and a Security-Client with the new
parameter values for the two SA pairs a re-authentication would set
up; TS 33.203 7.4.0 keeps the server ports and changes the client
ports. O2 UK refused the refresh of the old shape with 503 on every
occasion on record (alyx, 2026-09-24 to 10-08, a stationary phone
included) and accepted the fresh registrations that followed; KPN
accepted the old shape without a challenge.

The fresh registration now records the 401's nonce, our cnonce and
the response in the registration state (auth_nonce, auth_cnonce,
auth_response in the state file); a reregistration sends them
verbatim, mints new SPIs and proposes PortUcNext (45063) as the
client port with the server port unchanged. A registration resumed from a
state file written before this change has no answer: its
reregistrations carry the empty digest, as before, until its next
fresh registration, and the resume says so. A 401 to a reregistration asks
for re-authentication over temporary SAs on the proposed ports (TS
24.229 5.1.1.5.1), which imsd does not implement: the refresh throws
it as a refusal and RegRecovery makes the next step the fresh
registration, replacing the in-place answer over the old SAs.

Tested with the Messages and Engine suites and on the dev phone (KPN)
with a build of this commit's tree: a resume from the previous state
file, a fresh registration, a resume that sent the stored answer,
three refreshes at a 120-s interval, an outgoing call to a bench line
and an incoming call's ring.
2026-10-10 21:43:07 +02:00
a00b14a5a9
engine: a refused refresh with a Retry-After escalates to a fresh registration at once
RegRecovery retried a refused refresh once over the same registration
before escalating, on the reading that one 503 may be a hiccup. A
refusal whose Retry-After is at least a second is the network's
considered answer: on O2 UK (2026-10-08, imsd 0.3.10) the keepalive was
refused 503 with Retry-After 414 and the refresh after that wait 503
with Retry-After 772, so 0.3.10 scheduled the fresh registration no
earlier than 414 + 772 s after the keepalive's refusal. Refusals
without such a Retry-After (none, 0, unparsable) keep the two-refusal
rule, so KPN's bare 403 is unchanged.

Tested with the Engine suite, whose checks of a refusal carrying a
Retry-After now expect the fresh step.
2026-10-10 21:00:28 +02:00
27 changed files with 3363 additions and 427 deletions

930
AGENTS.md

File diff suppressed because it is too large Load diff

View file

@ -6,36 +6,26 @@
Userspace IMS/VoLTE daemon for mainline Linux phones. Userspace IMS/VoLTE daemon for mainline Linux phones.
Most Linux phones drive the modem's internal IMS engine. Most Linux phones drive the modem's internal IMS engine.
imsd implements the whole IMS client in userspace instead: imsd implements the whole IMS client in userspace instead wired into
SIP registration with USIM AKA authentication over kernel
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh PipeWire so a stock mobile dialer like Plasma Dialer or GNOME Calls under Phosh
can place and receive real VoLTE calls on a commercial network. can place and receive real VoLTE calls on a commercial network.
## Status Calls verified by community members to be fully working:
Call-capable, both directions: outgoing calls place, answer, carry full-duplex
AMR-WB audio and tear down. Incoming calls ring, answer and carry media the
same way, driven end-to-end from a stock dialer over a frozen D-Bus ABI.
Not there yet: DTMF (`SendDtmf` is a stub), hold/multiparty/call waiting,
USSD, P-CSCF discovery (the address is configured by hand).
Verified by community members to be fully working:
| Device | OS | Carrier | | Device | OS | Carrier |
|---|---|---| |---|---|---|
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL | | The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | KPN NL |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER | | The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telekom Deutschland GER |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero | | The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Phonero |
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge | | The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telia Norge |
| The Fairphone (Gen. 6) | Nura, Linux 7.2.0 | o2-uk |
## What it needs ## What it needs
imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs
its security associations with `ip xfrm` and plays audio through PipeWire, so its security associations with `ip xfrm` and plays audio through PipeWire, so
in practice: a Qualcomm modem on a mainline kernel with working in practice: a Qualcomm modem on a mainline kernel with working
WWAN/ModemManager integration, kernel ESP, PipeWire, systemd, polkit and WWAN/ModemManager integration, kernel ESP, PipeWire, systemd and polkit.
your carrier's P-CSCF address.
## Install and configure ## Install and configure
@ -49,25 +39,6 @@ Both can be found in the packages tab:
https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd https://forgejo.catcrafts.net/Catcrafts/-/packages/alpine/imsd
``` ```
Configuration is environment variables in `/etc/imsd.env`; the one required
setting is the P-CSCF:
```sh
# your carrier's P-CSCF address, capture via: `adb shell dumpsys telephony.registry` on Android
PCSCF=2001:db8::105
```
The apk's `imsd.service` is enabled by preset and starts at boot once that
file exists (the condition is checked when the unit starts, so after writing
the file run `systemctl start imsd`). It first brings the `ims` PDN up through ModemManager, then registers:
resuming an existing registration when it can, registering fresh otherwise,
and waiting out whatever the network tells it to wait out. Check on it with:
```sh
journalctl -u imsd -f
busctl --system call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus
```
## Dialers ## Dialers
imsd itself is desktop-agnostic: everything a dialer needs is on the system imsd itself is desktop-agnostic: everything a dialer needs is on the system
@ -96,16 +67,20 @@ session and root may place, answer and end calls; an ssh login may not.
## Full config: ## Full config:
Configuration is environment variables in `/etc/imsd.env`, none are required.
| Variable | Default | Meaning | | Variable | Default | Meaning |
|---|---|---| |---|---|---|
| `PCSCF` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error | | `PCSCF` | *(the first of `PCSCF_DISCOVERED`)* | P-CSCF address (IPv6), exactly one — not a comma-separated list — in RFC 5952 §4 form (lowercase, leading zeros dropped, the longest zero run compressed, no brackets — what `ip` prints for a plain IPv6 address; an IPv4-mapped address only as hex, `::ffff:a00:1` — the dotted spelling RFC 5952 §5 and `ip` use for it is refused); upper case, leading zeros, an uncompressed run or brackets are refused at start with the spelling to use (in the packaged unit the bring-up script refuses brackets first, with the rule), a dotted-quad tail with the rule; before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` matched no SA in `ip xfrm`'s text and registered fresh at every restart — an upgraded phone with such a setting stops at start with the spelling to use. Set, it wins over `PCSCF_DISCOVERED`. It is where a fresh registration goes; a registration resumed from the kernel's SAs stays on the P-CSCF it was made to until its next fresh registration. With neither this nor `PCSCF_DISCOVERED` there is no fresh registration: a phone with nothing to resume idles (`idle: no P-CSCF …`), unless a resume failed with its pair still resumable, or the reader did not answer (`PCSCF_DISCOVERY=failed`): those exit non-zero for systemd's restart |
| `PCSCF_DISCOVERED` | *(from the PDN's PCO)* | the IPv6 P-CSCF list the network put in the ims PDN's PCO, comma-separated in the order the modem reports it; the first entry `CanonicalIp6` accepts is used when `PCSCF` is unset. The packaged service gets it via `/run/imsd.env` from `ims-pdn-up.sh`, which reads it from the modem with `imsd-pcscf` (at most eight; it routes the first; imsd routes the P-CSCF it uses — the fresh target, the resumed SA peer — itself as well); entries `CanonicalIp6` does not read — an IPv4 address, a name, an IPv6 literal with a zone or a dotted-quad tail (an IPv4-mapped entry in hex only) — are ignored (logged by the engine when the daemon goes on to register); `PCSCF_DISCOVERY=found|none|failed|skipped` beside it says how the list came to be — `failed` (the reader did not answer) makes a daemon with nothing to resume exit 1 for the restart instead of idling. In `/etc/imsd.env` it is a bench seam |
| `IMSD_PCSCF` | `/usr/libexec/imsd-pcscf` | the P-CSCF reader `ims-pdn-up.sh` runs — a bench seam (the mock bench's fake reader; a stub through a unit drop-in on the dev phone), from the environment |
| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER | | `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER |
| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` | | `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
| `LOCAL` | *(auto)* | UE address. The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) | | `LOCAL` | *(auto)* | UE address (compared in RFC 5952 form). The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) |
| `USER_AGENT` | `imsd/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header | | `USER_AGENT` | `imsd/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header |
| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume | | `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume |
| `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands and whether the old registration is gone (`old_gone`), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too | | `HOLDS_FILE` | `/run/imsd.holds` (root) / `$XDG_RUNTIME_DIR/imsd.holds` (`/tmp/imsd.holds` without a runtime dir) | the waits the network imposed (a `Retry-After`, the refusal schedule), where that schedule stands whether the old registration is gone (`old_gone`) and the refusal count (`refusals`: a resume refused without a Retry-After at a start with no fresh target is one, the restart's the second), kept across a restart of imsd for the PDN they were set on — not across a reboot; `/dev/null` switches the file off (only a regular file is ever removed; anything else is emptied). `imsd-handover.sh` honours it too |
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | | `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) where there is a fresh target; with none the untried pair is removed and the bring-up ends as AGENTS §7.2 (the daemon's own bring-up) step 1 says |
| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] | | `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`). From the 401's `Security-Server` list imsd selects the highest-preference `ipsec-3gpp` mechanism that matches this offer (RFC 3329 §2.3.1) and takes the SPIs/ports from the entry carrying them (some cores list every mechanism they support and put the SPIs on the one they applied); when no entry matches, the P-CSCF's SPI-carrying entry is installed as listed (`hmac-md5-96`, an absent `ealg` = null) and the journal says so. The line, the selection and the SA parameters are logged at every fresh registration |
| `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports | | `SIP_TRANSPORT` | `auto` | transport of the protected leg (the second REGISTER and everything after it): `tcp`, `udp`, or `auto` = TCP, falling back to a fresh registration over UDP when the P-CSCF leaves two TCP connects unanswered — but only on a phone that has never registered over TCP: once the state file records a TCP registration, a silent connect is treated as an outage, not a policy (delete the file or set `udp` to override, e.g. after a SIM change). The challenge (first REGISTER) is always UDP. Over UDP a REGISTER or INVITE larger than ~1.2 KB leaves as IPv6 fragments at the ims PDN's 1280-byte MTU (the journal says so once per flow); there are no SIP retransmission timers over UDP yet, so a lost datagram costs a timeout. The SAs, the listener and the firewall rule cover both transports |
@ -116,6 +91,7 @@ session and root may place, answer and end calls; an ssh login may not.
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers | | `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200 dumps, the last refused REGISTER or SUBSCRIBE final (`imsd-register-fail.raw`, `imsd-subscribe-fail.raw`), the last inbound INVITE (`imsd-invite-in.raw`) and the last outgoing INVITE (`imsd-invite-out.raw`), mode 0600, for debugging — they contain your IMSI/MSISDN and addresses. A refused REGISTER or SUBSCRIBE is logged with its status line and any Retry-After / Warning / Reason / Server headers |
| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land | | `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land |
| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) | | `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) |
| `IMSD_ENV` | `/etc/imsd.env` | the configuration file's path — a bench seam for manual runs of `ims-pdn-up.sh` (the packaged unit reads `/etc/imsd.env` through `EnvironmentFile=`). The file's existence changes nothing |
The protected client/server ports are fixed at 45061/45062 The protected client/server ports are fixed at 45061/45062
(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires (`imsd::util::PortUc`/`PortUs`) — changing them in source also requires

View file

@ -31,6 +31,7 @@ reach the bus via g_idle_add, where the calls table + status snapshot live
#include <gio/gio.h> #include <gio/gio.h>
#include <glib-unix.h> #include <glib-unix.h>
#include <ifaddrs.h> #include <ifaddrs.h>
#include <limits.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <poll.h> #include <poll.h>
#include <pthread.h> #include <pthread.h>
@ -48,12 +49,13 @@ import ImsdBus;
namespace { namespace {
// ---- static config (env-overridable, same knobs as imsd.py) --------------- // ---- static config (env-overridable, same knobs as imsd.py) ---------------
constexpr const char* Version = "0.3.10"; constexpr const char* Version = "1.1.0";
constexpr const char* BusName = "net.catcrafts.IMS1"; constexpr const char* BusName = "net.catcrafts.IMS1";
constexpr const char* ObjPath = "/net/catcrafts/IMS1"; constexpr const char* ObjPath = "/net/catcrafts/IMS1";
constexpr const char* Iface = "net.catcrafts.IMS1"; constexpr const char* Iface = "net.catcrafts.IMS1";
constexpr int PortUc = imsd::util::PortUc; constexpr int PortUc = imsd::util::PortUc;
constexpr int PortUs = imsd::util::PortUs; constexpr int PortUs = imsd::util::PortUs;
constexpr int PortUcNext = imsd::util::PortUcNext;
constexpr int InitPort = 5060; constexpr int InitPort = 5060;
std::string EnvOr(const char* k, std::string d) { std::string EnvOr(const char* k, std::string d) {
@ -113,6 +115,35 @@ std::string RedactKeys(const std::vector<std::string>& argv) {
return s; return s;
} }
// imsd's own xfrm entries out of the kernel, each by its key (the
// OwnXfrmDeleteCommands list — never `ip xfrm … flush`: another IPsec user's
// entries, a policy-based VPN's, stay, §12); a delete that fails (an entry
// gone between the read and the delete) is logged and the rest go on. At
// file scope, as LoadHolds and PersistHolds are: the Engine and main() both
// run it (main() through RemoveDeadPair).
void RunOwnXfrmDeletes(const std::vector<std::vector<std::string>>& cmds) {
for (auto& cmd : cmds) {
int rc = RunCmd(cmd);
if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc));
}
}
// The kernel's SA pair when nothing can resume it and no fresh registration
// replaces it, and whatever else of imsd's own is left (a policy-less state
// on the UE's address): taken out by exact key, as the SA setup takes imsd's
// previous pair out (RunOwnXfrmDeletes — nothing else in the kernel is
// touched, §12), so the next bring-up finds nothing to resume. Run by the
// no-target bring-up and by main()'s idle before the bus name.
void RemoveDeadPair(std::string_view local) {
auto state = RunCapture({"ip", "xfrm", "state"});
auto policy = RunCapture({"ip", "xfrm", "policy"});
auto cmds = imsd::ipsec::OwnXfrmDeleteCommands(state.out, policy.out, local);
if (cmds.empty()) return;
if (auto peer = imsd::ipsec::SaPeer(policy.out)) Log(std::format("the SA pair to {} is nothing to resume: removing it", *peer));
else Log(std::format("{} xfrm entr{} of imsd's own with no pair to resume: removing them", cmds.size(), cmds.size() == 1 ? "y" : "ies"));
RunOwnXfrmDeletes(cmds);
}
// ---- small text extractors (401 header params, qmicli "completed:") ------- // ---- small text extractors (401 header params, qmicli "completed:") -------
std::optional<std::string> QuotedAfter(std::string_view text, std::string_view key) { std::optional<std::string> QuotedAfter(std::string_view text, std::string_view key) {
@ -266,6 +297,31 @@ void LogAudioSession() {
Log(AudioSessionLine(s) + (s.name.empty() ? "; looked up again at each call, set AUDIO_USER to pin one" : "")); Log(AudioSessionLine(s) + (s.name.empty() ? "; looked up again at each call, set AUDIO_USER to pin one" : ""));
} }
// The P-CSCF a fresh registration goes to, from the environment: PCSCF=
// when set (one address, any spelling — a list is left as it came and
// refused at bring-up), else the first address of PCSCF_DISCOVERED=, the
// list the bring-up read from the PDN's PCO (comma-separated IPv6, PCO
// order). `discovered` and `rejected` get the list and what was not an
// IPv6 literal, for the caller's log lines.
std::string FreshPcscfFromEnv(std::vector<std::string>* discovered = nullptr, std::vector<std::string>* rejected = nullptr) {
std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
std::vector<std::string> list = imsd::util::ParseAddressList(EnvOr("PCSCF_DISCOVERED", ""), rejected);
if (discovered) *discovered = list;
if (!configured.empty()) return configured;
return list.empty() ? std::string() : list.front();
}
// The no-target bring-up's two outcomes, told apart by the script's word in
// /run/imsd.env (§7.3). The PCO naming no P-CSCF is a fact
// (PCSCF_DISCOVERY=none): a daemon with nothing to resume idles on it until
// a boot or a start. A reader that did not answer is not
// (PCSCF_DISCOVERY=failed): exit 1, and the unit's restart runs discovery
// again. Nothing here depends on a configuration file existing — installing
// the package and inserting a SIM is the opt-in.
bool DiscoveryFailed() { return EnvOr("PCSCF_DISCOVERY", "") == "failed"; }
constexpr std::string_view NoPcscfIdleText = "no P-CSCF — none configured (PCSCF=), none discovered (see the ims-pdn-up lines) — and nothing to resume; set PCSCF= in /etc/imsd.env to name one";
constexpr std::string_view NoPcscfDiscoveryFailed = "no P-CSCF: discovery failed (see the ims-pdn-up lines) and nothing to resume";
// ---- sockaddr helper ------------------------------------------------------ // ---- sockaddr helper ------------------------------------------------------
socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) { socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) {
std::memset(&ss, 0, sizeof ss); std::memset(&ss, 0, sizeof ss);
@ -296,10 +352,12 @@ constexpr std::string_view TransportName(Transport t) { return t == Transport::U
// live). Handled in-process: a // live). Handled in-process: a
// systemd restart every RestartSec would never clear it. // systemd restart every RestartSec would never clear it.
struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; }; struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; };
// A REGISTER the network refused: the protected one, or a refresh, answered // A REGISTER the network refused: the protected one, or a reregistration,
// with a final other than 200 (a refresh's first 401 is its challenge); the // answered with a final other than 200 (a reregistration's 401 asks for a
// unprotected initial one answered with a final from 300 up (its 401 is the // re-authentication imsd does not do over the old SAs: a refusal carrying
// challenge). The status and its Retry-After feed imsd::engine::RegRecovery. // 401); the unprotected initial one answered with a final from 300 up (its
// 401 is the challenge). The status and its Retry-After feed
// imsd::engine::RegRecovery.
struct RefusedError : std::runtime_error { struct RefusedError : std::runtime_error {
int status; int status;
long retryAfter; long retryAfter;
@ -635,6 +693,20 @@ struct RegState {
// Contact user-part of the stored binding (UUID since the s53 oracle // Contact user-part of the stored binding (UUID since the s53 oracle
// diff; empty = legacy IMSI binding from an older state file). // diff; empty = legacy IMSI binding from an older state file).
std::string contactUser; std::string contactUser;
// The answer to the last challenge — the 401's nonce, our cnonce and
// the response — which every reregistration carries verbatim (TS
// 24.229 5.1.1.4.2 a). Empty = a registration resumed from a pre-0.3.11
// state file; its reregistrations carry an empty digest, the pre-0.3.11
// shape, until the next fresh registration.
std::string authNonce, authCnonce, authResponse;
// The Security-Verify this registration's protected REGISTERs, its
// reg-event SUBSCRIBE and its INVITEs echo: the 401's whole
// Security-Server list (RFC 3329 §2.3.1). Set on the fresh 200 and at
// the resume, never from an attempt in flight:
// the member ss_ holds a fresh attempt's 401 before that attempt can
// still abort (the throttle, an unsupported mechanism), and the
// standing registration's state writes must not store it.
std::string securityServer;
}; };
// Writable directory for the daemon's own files: the persisted registration // Writable directory for the daemon's own files: the persisted registration
@ -649,13 +721,21 @@ std::string StateDir() {
// tiny JSON reader/writer for the persisted registration context. // tiny JSON reader/writer for the persisted registration context.
std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); } std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); }
void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport) { // `security_server` is the registration's Security-Verify (RegState's
// securityServer: the 401's joined list); a resume takes it from here
// since 0.3.12. The file's values are quoted without escaping; a value
// with a quote, a backslash or a newline in it (none RFC 3329's token
// syntax produces) is written empty, and the resume falls back to the SA.
void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport, const std::string& pcscf) {
std::string ss = r.securityServer.find_first_of("\"\\\n") == std::string::npos ? r.securityServer : std::string();
std::string j = std::format( std::string j = std::format(
"{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, "
"\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", "
"\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\"}}", "\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", "
"\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\", "
"\"security_server\": \"{}\", \"pcscf\": \"{}\"}}",
r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor, r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor,
r.contactUser, transport); r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse, ss, pcscf);
std::string path = StatePath(); std::string path = StatePath();
std::error_code ec; std::error_code ec;
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
@ -679,7 +759,7 @@ void DumpRaw(std::string_view name, std::string_view msg) {
} }
struct PersistedState { struct PersistedState {
std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport; std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer, pcscf;
std::optional<long> cseq, spiUc, spiUs, expiry; std::optional<long> cseq, spiUc, spiUs, expiry;
}; };
std::optional<PersistedState> LoadState() { std::optional<PersistedState> LoadState() {
@ -697,6 +777,11 @@ std::optional<PersistedState> LoadState() {
ps.aor = QuotedAfter(s, "\"aor\": \""); ps.aor = QuotedAfter(s, "\"aor\": \"");
ps.contactUser = QuotedAfter(s, "\"contact_user\": \""); ps.contactUser = QuotedAfter(s, "\"contact_user\": \"");
ps.transport = QuotedAfter(s, "\"transport\": \""); // absent in pre-0.3.5 files: TCP ps.transport = QuotedAfter(s, "\"transport\": \""); // absent in pre-0.3.5 files: TCP
ps.authNonce = QuotedAfter(s, "\"auth_nonce\": \""); // the three absent in pre-0.3.11 files
ps.authCnonce = QuotedAfter(s, "\"auth_cnonce\": \"");
ps.authResponse = QuotedAfter(s, "\"auth_response\": \"");
ps.securityServer = QuotedAfter(s, "\"security_server\": \""); // absent in pre-0.3.12 files
ps.pcscf = QuotedAfter(s, "\"pcscf\": \""); // absent in pre-1.0.0 files
ps.cseq = IntAfter(s, "\"cseq\": "); ps.cseq = IntAfter(s, "\"cseq\": ");
ps.spiUc = IntAfter(s, "\"spi_uc\": "); ps.spiUc = IntAfter(s, "\"spi_uc\": ");
ps.spiUs = IntAfter(s, "\"spi_us\": "); ps.spiUs = IntAfter(s, "\"spi_us\": ");
@ -720,11 +805,11 @@ struct StatusSnapshot {
// engine -> main-loop event // engine -> main-loop event
struct Event { struct Event {
enum class Type { Added, State, Deleted, Registration, Status, Fatal } type; enum class Type { Added, State, Deleted, Registration, Status, Fatal, Idle } type;
CallInfo info; // Added CallInfo info; // Added
std::string uni, state, reason; // State/Deleted std::string uni, state, reason; // State/Deleted
StatusSnapshot status; // Registration/Status StatusSnapshot status; // Registration/Status
std::string text; // Fatal std::string text; // Fatal, Idle
}; };
GDBusConnection* DbusConn = nullptr; GDBusConnection* DbusConn = nullptr;
@ -791,8 +876,8 @@ void PersistHolds(const imsd::engine::RegRecovery::Held& h, const std::string& p
else if (std::filesystem::exists(st)) { if (std::FILE* f = std::fopen(path.c_str(), "w")) std::fclose(f); } else if (std::filesystem::exists(st)) { if (std::FILE* f = std::fopen(path.c_str(), "w")) std::fclose(f); }
return; return;
} }
std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}}}\n", std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}, \"refusals\": {}}}\n",
pdn, BootId(), BootSeconds(), static_cast<long>(std::ceil(h.hold)), static_cast<long>(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false"); pdn, BootId(), BootSeconds(), static_cast<long>(std::ceil(h.hold)), static_cast<long>(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false", h.refusals);
if (std::FILE* f = std::fopen(path.c_str(), "w")) { if (std::FILE* f = std::fopen(path.c_str(), "w")) {
std::fwrite(j.data(), 1, j.size(), f); std::fwrite(j.data(), 1, j.size(), f);
std::fclose(f); std::fclose(f);
@ -806,7 +891,7 @@ std::optional<imsd::engine::RegRecovery::Held> LoadHolds(const std::string& pdn)
char buf[512]; std::size_t n; char buf[512]; std::size_t n;
while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n); while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n);
std::fclose(f); std::fclose(f);
if (QuotedAfter(s, "\"pdn\": \"").value_or("") != pdn) return std::nullopt; if (imsd::util::CanonicalAddr(QuotedAfter(s, "\"pdn\": \"").value_or("")) != imsd::util::CanonicalAddr(pdn)) return std::nullopt;
if (QuotedAfter(s, "\"boot\": \"").value_or("") != BootId()) return std::nullopt; if (QuotedAfter(s, "\"boot\": \"").value_or("") != BootId()) return std::nullopt;
long elapsed = BootSeconds() - IntAfter(s, "\"written_at\": ").value_or(0); long elapsed = BootSeconds() - IntAfter(s, "\"written_at\": ").value_or(0);
imsd::engine::RegRecovery::Held h; imsd::engine::RegRecovery::Held h;
@ -814,6 +899,7 @@ std::optional<imsd::engine::RegRecovery::Held> LoadHolds(const std::string& pdn)
h.freshHold = std::max<long>(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed); h.freshHold = std::max<long>(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed);
h.throttleMinutes = static_cast<int>(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes)); h.throttleMinutes = static_cast<int>(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes));
h.oldGone = s.find("\"old_gone\": true") != std::string::npos; h.oldGone = s.find("\"old_gone\": true") != std::string::npos;
h.refusals = static_cast<int>(IntAfter(s, "\"refusals\": ").value_or(0));
if (!h.Any()) return std::nullopt; if (!h.Any()) return std::nullopt;
return h; return h;
} }
@ -871,6 +957,12 @@ gboolean OnIdleEvent(gpointer data) {
FatalExit = true; // non-zero exit, or Restart=on-failure never fires FatalExit = true; // non-zero exit, or Restart=on-failure never fires
g_main_loop_quit(MainLoop); g_main_loop_quit(MainLoop);
break; break;
case Event::Type::Idle:
// nothing to do (§7.3): a clean exit, which Restart=on-failure
// leaves alone
Log(std::format("idle: {} — nothing to do; systemctl start imsd when that changes", ev->text));
g_main_loop_quit(MainLoop);
break;
} }
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
@ -883,10 +975,25 @@ void PostEvent(std::unique_ptr<Event> ev) {
class Engine { class Engine {
public: public:
Engine() { Engine() {
// No baked-in default: the P-CSCF is carrier infrastructure, learned // The P-CSCF a fresh registration goes to: PCSCF= when set, else the
// from the IMS PDN's PCO on a stock stack. Until PCO discovery is // first address the bring-up read from the PDN's PCO
// implemented, PCSCF= must be configured (see README). // (PCSCF_DISCOVERED=); no baked-in default — carrier infrastructure.
pcscf_ = EnvOr("PCSCF", ""); // A resume follows the kernel's SAs instead (TryResume), whatever
// this says today.
{
std::vector<std::string> rejected;
pcscfTarget_ = FreshPcscfFromEnv(&pcscfCandidates_, &rejected);
pcscf_ = pcscfTarget_;
std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
std::string list;
for (const std::string& c : pcscfCandidates_) list += (list.empty() ? "" : ", ") + c;
if (!configured.empty())
Log(pcscfCandidates_.empty() ? std::format("pcscf: configured {}", configured)
: std::format("pcscf: configured {}; discovery gave {} (ignored: PCSCF= wins)", configured, list));
else if (!pcscfCandidates_.empty())
Log(std::format("pcscf: discovered {} (using {})", list, pcscfTarget_));
for (const std::string& r : rejected) Log(std::format("pcscf: PCSCF_DISCOVERED entry '{}' is not an IPv6 address; ignored", r));
}
pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str()); pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str());
dev_ = EnvOr("DEV", "qmapmux0.0"); dev_ = EnvOr("DEV", "qmapmux0.0");
outDir_ = EnvOr("OUT_DIR", StateDir()); outDir_ = EnvOr("OUT_DIR", StateDir());
@ -927,7 +1034,7 @@ public:
} }
} }
void SetLocal(std::string l) { local_ = l; pdnLocal_ = std::move(l); } void SetLocal(std::string l) { local_ = imsd::util::CanonicalAddr(l); pdnLocal_ = local_; }
void SetDevMode(bool d) { devMode_ = d; } void SetDevMode(bool d) { devMode_ = d; }
const std::string& Local() const { return local_; } const std::string& Local() const { return local_; }
@ -946,6 +1053,10 @@ public:
std::scoped_lock g(cmdLock_); std::scoped_lock g(cmdLock_);
cmds_.push_back({Cmd::Kind::Accept, uni, {}}); cmds_.push_back({Cmd::Kind::Accept, uni, {}});
} }
void SendDtmf(const std::string& uni, const std::string& tones) {
std::scoped_lock g(cmdLock_);
cmds_.push_back({Cmd::Kind::Dtmf, uni, tones});
}
void Stop() { void Stop() {
{ std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); } { std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); }
quit_.store(true); quit_.store(true);
@ -1034,7 +1145,7 @@ public:
} }
private: private:
struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; }; struct Cmd { enum class Kind { Dial, HangUp, Accept, Dtmf, Stop } kind; std::string uni, arg; }; // arg: the number to dial, or the tones
struct Stopping {}; // the engine was told to stop while a bring-up waited struct Stopping {}; // the engine was told to stop while a bring-up waited
// A bring-up the network deferred (a fresh-SA throttle) or refused: wait // A bring-up the network deferred (a fresh-SA throttle) or refused: wait
@ -1070,6 +1181,20 @@ private:
} }
void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); } void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); }
// what follows a resume that failed: with a fresh target the fresh
// registration, without one the restart's new resume attempt
std::string ResumeFailedNext() const { return pcscfTarget_.empty() ? "the restart tries the resume again" : "the next registration is a fresh one"; }
// a pair nothing can resume (no state file, the file another attempt's): with a fresh target the fresh registration replaces it; with none BringUp writes it off and takes it out of the kernel
std::string ResumeDeadNext() const { return pcscfTarget_.empty() ? "nothing can resume it: the pair is removed" : "resume falls back to fresh"; }
// the failed resume's line tail by the engine's verdict: a registration it
// wrote off is removed (no fresh target) or replaced by the fresh one; with
// no fresh target a resume it gives up (a 401, a refusal with a Retry-After,
// the second refusal) is removed too; else what ResumeFailedNext says
std::string ResumeFailedTail(const std::string& next) {
if (!recovery_.Reconnectable()) return pcscfTarget_.empty() ? "written off by the network: the pair is removed" : "written off by the network: the next registration is a fresh one";
if (pcscfTarget_.empty() && recovery_.Fresh()) return "the resume is given up (the engine's rule): the pair is removed";
return next;
}
// dev/session mode without a modem: process commands so the ABI answers, // dev/session mode without a modem: process commands so the ABI answers,
// but there is no SIP socket — a Dial collapses to terminated/error. // but there is no SIP socket — a Dial collapses to terminated/error.
@ -1082,6 +1207,15 @@ private:
// config // config
std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_;
// The P-CSCF a fresh registration goes to (PCSCF=, else the first of
// PCSCF_DISCOVERED=) and the whole discovered list. pcscf_ is the one in
// use: the same, or the kernel's SA peer while a resumed registration
// made to another one lives (TryResume) — the next fresh registration
// moves to the target, and back when it fails before the SA setup
// (FreshRecover), as local_ does against pdnLocal_.
std::string pcscfTarget_;
std::vector<std::string> pcscfCandidates_;
static constexpr std::string_view NoFreshPcscf = "no P-CSCF for a fresh registration: none configured (PCSCF=) and none discovered (PCSCF_DISCOVERED empty — see the ims-pdn-up lines)";
// The PDN's own address (LOCAL, else DetectLocal) — every fresh // The PDN's own address (LOCAL, else DetectLocal) — every fresh
// registration's. local_ differs from it only while a resume runs on the // registration's. local_ differs from it only while a resume runs on the
// address an older registration was made on (TryResume). // address an older registration was made on (TryResume).
@ -1118,6 +1252,7 @@ private:
bool dropCall_ = false; bool dropCall_ = false;
bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call
pid_t mediaPid_ = -1; pid_t mediaPid_ = -1;
int mediaCtl_ = -1; // our end of the leg's control pipe (its stdin)
double deadline_ = 0; double deadline_ = 0;
// reply_ answers the inbound request being dispatched right now; // reply_ answers the inbound request being dispatched right now;
// callReply_ is the call's durable response channel — refreshed from // callReply_ is the call's durable response channel — refreshed from
@ -1169,7 +1304,15 @@ private:
// ---- bring-up: warm resume if an SA + state file exist, else fresh ---- // ---- bring-up: warm resume if an SA + state file exist, else fresh ----
void BringUp() { void BringUp() {
if (pcscf_.empty()) throw std::runtime_error("PCSCF not set — configure the carrier's P-CSCF address " "(P-CSCF discovery from the PDN's PCO is not implemented yet)"); if (pcscfTarget_.contains(',')) throw std::runtime_error(std::format("PCSCF='{}' must be one address", pcscfTarget_));
// PCSCF= is accepted in one spelling only: the one `ip` prints (RFC
// 5952 — what the journal and `ip xfrm` show). Another spelling is
// refused with the one to use rather than normalised, so the bring-up
// script, which routes the value as typed, and the daemon agree on it.
if (std::string raw = EnvOr("PCSCF", ""); !raw.empty()) {
auto canon = imsd::util::CanonicalIp6(raw);
if (!canon || *canon != raw) throw std::runtime_error(std::format("PCSCF='{}' is not written as ip prints it; use PCSCF={}", raw, canon.value_or("<one bare IPv6 address, RFC 5952 form>")));
}
auto card = RunCapture({"qmicli", "-d", "qrtr://0", "--uim-get-card-status"}); auto card = RunCapture({"qmicli", "-d", "qrtr://0", "--uim-get-card-status"});
auto sel = imsd::aka::ParseCardStatus(card.out); auto sel = imsd::aka::ParseCardStatus(card.out);
if (!sel) throw std::runtime_error("no ready USIM found"); if (!sel) throw std::runtime_error("no ready USIM found");
@ -1198,25 +1341,67 @@ private:
std::string resume = EnvOr("RESUME", "auto"); std::string resume = EnvOr("RESUME", "auto");
// A resume that the network refuses (403 after another process // A resume that the network refuses (403 after another process
// registered fresh for the same IMPI, a stale binding, …) is not // registered fresh for the same IMPI, a stale binding, …) is not
// fatal: the fresh registration below replaces its SAs. Only a // fatal while there is a fresh target: the fresh registration below
// failed FRESH registration is. // replaces its SAs, and only a failed FRESH registration is. With no
// fresh target the bring-up ends as §7.2 step 1 says.
// The network's word holds at bring-up as it does in recovery: a // The network's word holds at bring-up as it does in recovery: a
// hold from before this start is waited out before the resume (a // hold from before this start is waited out before the resume (a
// refresh's Retry-After) or the fresh registration (a fresh attempt's // refresh's Retry-After) or the fresh registration (a fresh attempt's
// schedule or Retry-After, a terminated binding's wait), and so is // schedule or Retry-After, a terminated binding's wait), and so is
// one the resume itself earns — a refusal's Retry-After, a binding // one the resume itself earns — a refusal's Retry-After, a binding
// a NOTIFY terminated under it. // a NOTIFY terminated under it.
ResumeOutcome outcome = ResumeOutcome::Dead; // not tried (RESUME=0, or written off before this start): a pair nothing resumes
if (resume != "0") { if (resume != "0") {
if (!WaitForPolicy()) throw Stopping{}; if (!WaitForPolicy()) throw Stopping{};
// a registration the network wrote off before this start (the // a registration the network wrote off before this start (the
// holds carry it) is not resumed: the wait above was the fresh one // holds carry it) is not resumed: the wait above was the fresh one
try { if (recovery_.Reconnectable() && TryResume()) return; } const std::string next = ResumeFailedNext();
try { if (recovery_.Reconnectable()) { outcome = TryResume(); if (outcome == ResumeOutcome::Done) return; } }
catch (const RefusedError& e) { catch (const RefusedError& e) {
Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); outcome = ResumeOutcome::Failed;
CloseFlowAfterWait(); CloseFlowAfterWait();
if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status); // the network's word (a Retry-After) reaches the reducer as before;
// with no fresh target every refusal does — its count travels in
// the holds, so a restart's refusal is the second (§7.4). With a
// target a refusal without one feeds nothing: the fresh
// registration follows at once, not after a self-imposed backoff
// the fresh path would log as the network's hold
if (e.retryAfter > 0 || pcscfTarget_.empty()) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status);
Log(std::format("resume failed: {} — {}", e.what(), ResumeFailedTail(next)));
} }
catch (const std::exception& e) { Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); } catch (const std::exception& e) { outcome = ResumeOutcome::Failed; CloseFlowAfterWait(); Log(std::format("resume failed: {} — {}", e.what(), ResumeFailedTail(next))); }
// the engine's verdict decides, in one place for both arms: a
// registration it wrote off (a 403 once a fresh registration is
// due; a binding a NOTIFY terminated under the re-REGISTER wait,
// Imsd-Engine.cppm) is nothing to resume any more, and so, with no
// fresh target, is one whose resume it gives up (a 401, a refusal
// with a Retry-After, the second refusal — RefusalsBeforeFresh,
// the count surviving the restart): Dead, taken out below. With a
// target the fresh registration follows either way.
if (outcome == ResumeOutcome::Failed && (!recovery_.Reconnectable() || (pcscfTarget_.empty() && recovery_.Fresh()))) outcome = ResumeOutcome::Dead;
}
if (pcscfTarget_.empty()) {
// No fresh registration follows, so a resumable pair is not written
// off: one whose attempt failed (a silent connect, an
// unanswered or refused refresh — its Retry-After is in the
// holds) stays for the restart to resume (the fatal and the
// retry) — unless the engine's rule wrote the registration off or
// gave the resume up (a 403 once fresh is due, a NOTIFY-terminated
// binding; a 401, a refusal with a Retry-After, the second refusal —
// the count travels in the holds; Dead above); a pair nothing can
// resume (its UE address left the link, no
// state file, the file another attempt's, written off before this
// start) is written off and taken out of the kernel by exact key
// — the fresh registration that would have replaced it does not
// come — so the next bring-up finds nothing to resume, and the
// daemon idles (RequestIdle: a clean exit, §7.3): the PCO named no
// P-CSCF and nothing is configured, a fact until a boot or a
// start. A discovery that FAILED (the reader did not answer) is
// no fact: the fatal, and the restart runs it again.
if (outcome == ResumeOutcome::Dead) { recovery_.OldGone(); RemoveDeadPair(pdnLocal_); }
KeepHolds();
if (outcome == ResumeOutcome::Failed || DiscoveryFailed()) throw std::runtime_error(std::string(NoFreshPcscf));
RequestIdle(std::string(NoPcscfIdleText)); throw Stopping{};
} }
// a resume that failed found the stored SAs unusable (and after a // a resume that failed found the stored SAs unusable (and after a
// reboot there are none): nothing to reconnect over while a deferred // reboot there are none): nothing to reconnect over while a deferred
@ -1225,14 +1410,39 @@ private:
portPs_ = 0; portPs_ = 0;
local_ = pdnLocal_; ctx_.local = local_; local_ = pdnLocal_; ctx_.local = local_;
KeepHolds(); KeepHolds();
pcscf_ = pcscfTarget_;
// the target's host route is imsd's own: the bring-up script routes the
// first address of its own read, not a list set in /etc/imsd.env nor a
// route lost with the PDN — the same idempotent replace as a media peer's
RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_});
if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep())); if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep()));
if (!WaitForPolicy()) throw Stopping{}; if (!WaitForPolicy()) throw Stopping{};
FreshRegister(); FreshRegister();
} }
bool TryResume() { // What a bring-up's resume attempt found: the registration (resumed),
// no pair of ours in the kernel, a pair nothing can resume, or a
// resumable pair whose attempt failed — worth a retry.
enum class ResumeOutcome { Done, None, Dead, Failed };
ResumeOutcome TryResume() {
auto state = RunCapture({"ip", "xfrm", "state"}); auto state = RunCapture({"ip", "xfrm", "state"});
auto policy = RunCapture({"ip", "xfrm", "policy"}); auto policy = RunCapture({"ip", "xfrm", "policy"});
// The registration was made to whatever P-CSCF the kernel's SAs name
// (the SA setup takes imsd's previous pair out), whatever PCSCF= says
// today: resume there and move at the next fresh registration — a
// fresh one now would cost the registration, on KPN with the
// 21-minute refusal. A route to it exists while the netdev lives;
// the replace covers a target that changed under a running phone.
auto peer = imsd::ipsec::SaPeer(policy.out);
if (!peer) return ResumeOutcome::None;
if (*peer != pcscf_) {
bool listed = *peer == pcscfTarget_ || std::ranges::find(pcscfCandidates_, *peer) != pcscfCandidates_.end();
Log(pcscfTarget_.empty()
? std::format("pcscf: resuming on {}, the registration's SA peer (no P-CSCF configured or discovered)", *peer)
: std::format("pcscf: resuming on {}, the registration's SA peer; the next fresh registration goes to {}{}", *peer, pcscfTarget_, listed ? "" : " (the peer is not in today's list)"));
pcscf_ = *peer;
}
auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_); auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_);
// The registration may sit on another address of the link: before // The registration may sit on another address of the link: before
// 0.3.10 imsd registered on the kernel's temporary one (ims ledger // 0.3.10 imsd registered on the kernel's temporary one (ims ledger
@ -1254,20 +1464,59 @@ private:
break; break;
} }
} }
if (!sa) return false; if (!sa) { Log(std::format("the SA pair to {} has no SA on an address of {}: nothing to resume", pcscf_, dev_)); return ResumeOutcome::Dead; }
// the peer's host route is imsd's own once the SAs are confirmed (a peer
// named by a pair that is not ours gets no route into the PDN), whether
// or not the bring-up script routed the same address — idempotent
RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_});
auto ps = LoadState(); auto ps = LoadState();
if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; } if (!ps || !ps->callid) { Log(std::format("no reg state file; {}", ResumeDeadNext())); return ResumeOutcome::Dead; }
// A file another attempt left behind — one that got through the SA
// setup and did not live to record, to this or another P-CSCF — names
// another SA pair than the kernel's (its spi_uc/spi_us; a 0.3.x file
// carries them too): its dialog is that attempt's, and a Call-ID
// cannot be rebuilt from an SA (the Security-Verify can —
// ChooseResumeSecurityVerify).
if (imsd::ipsec::FileNamesAnotherPair(ps->spiUc, ps->spiUs, sa->spiUc, sa->spiUs)) {
Log(std::format("the state file names another SA pair ({:#x}/{:#x}{}) than the kernel's ({:#x}/{:#x} to {}): another attempt's; {}",
static_cast<std::uint32_t>(*ps->spiUc), static_cast<std::uint32_t>(*ps->spiUs),
ps->pcscf && !ps->pcscf->empty() ? std::format(" to {}", imsd::util::CanonicalAddr(*ps->pcscf)) : std::string(),
sa->spiUc, sa->spiUs, pcscf_, ResumeDeadNext()));
return ResumeOutcome::Dead;
}
// The registration's transport is what the P-CSCF knows; a resume // The registration's transport is what the P-CSCF knows; a resume
// must reuse it. A forced policy that disagrees means a fresh start // must reuse it. A forced policy that disagrees means a fresh start
// — decided before anything from the file is adopted. // — decided before anything from the file is adopted.
Transport t = ps->transport.value_or("TCP") == "UDP" ? Transport::Udp : Transport::Tcp; Transport t = ps->transport.value_or("TCP") == "UDP" ? Transport::Udp : Transport::Tcp;
if (sipPolicy_ != "auto" && t != transport_) { Log("state file transport differs from SIP_TRANSPORT; fresh"); return false; } if (sipPolicy_ != "auto" && t != transport_) { Log(std::format("state file transport differs from SIP_TRANSPORT; {}", ResumeFailedNext())); return ResumeOutcome::Failed; }
route_ = ps->route.value_or(std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, sa->portPs))); route_ = ps->route.value_or(std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, sa->portPs)));
// No fallback identity: an empty ppi omits P-Preferred-Identity and // No fallback identity: an empty ppi omits P-Preferred-Identity and
// is re-learned from the next 200's P-Associated-URI. // is re-learned from the next 200's P-Associated-URI.
ppi_ = ps->ppi.value_or(""); ppi_ = ps->ppi.value_or("");
ss_ = sa->securityServer; // The Security-Verify the protected REGISTERs, the reg-event
// SUBSCRIBE and the INVITEs echo: the 401's whole Security-Server
// list (RFC 3329 §2.3.1), from the state file since 0.3.12, or the
// one-mechanism entry rebuilt from the SA — Imsd:Ipsec chooses
// (ChooseResumeSecurityVerify). The rebuilt entry is KPN's answer to
// the default offer in the same bytes, not a list such as O2's six,
// which the RFC has the server answer with a 494; a refused resume
// registers fresh where there is a fresh target (§7.2 step 1 otherwise).
auto rsv = imsd::ipsec::ChooseResumeSecurityVerify(ps->securityServer.value_or(""), *sa, ealgOffer_);
ss_ = rsv.value;
using Source = imsd::ipsec::ResumeSecurityVerify::Source;
switch (rsv.source) {
case Source::Stored:
Log(std::format("Security-Verify from the state file ({} mechanism{})", rsv.mechanisms, rsv.mechanisms == 1 ? "" : "s"));
break;
case Source::NoneStored:
Log("state file holds no Security-Server (a pre-0.3.12 file, or a list the file cannot hold): Security-Verify rebuilt from the SA as one mechanism");
break;
case Source::OtherSa:
Log(std::format("state file's Security-Server names another SA pair than the one in the kernel (spi-s {} vs {}): Security-Verify rebuilt from the SA as one mechanism",
rsv.storedSpiPs ? std::to_string(*rsv.storedSpiPs) : "none", sa->spiPs));
break;
}
// The public identity our requests are signed with (From) — restored // The public identity our requests are signed with (From) — restored
// here so a call placed before the refresh 200 re-learns it does not // here so a call placed before the refresh 200 re-learns it does not
// fall back to the barred temporary IMPU. // fall back to the barred temporary IMPU.
@ -1281,8 +1530,8 @@ private:
// Local refusals (TIME_WAIT after a restart) keep retrying; two // Local refusals (TIME_WAIT after a restart) keep retrying; two
// silences mean the flow is not coming back — fresh instead. // silences mean the flow is not coming back — fresh instead.
if (!sip_.Connect(local_, PortUc, pcscf_, sa->portPs, transport_, 10, 2)) { if (!sip_.Connect(local_, PortUc, pcscf_, sa->portPs, transport_, 10, 2)) {
Log("resume connect failed; falling back to fresh"); Log(std::format("resume connect failed; {}", ResumeFailedNext()));
return false; return ResumeOutcome::Failed;
} }
reg_.callid = *ps->callid; reg_.callid = *ps->callid;
reg_.ftag = ps->ftag.value_or(""); reg_.ftag = ps->ftag.value_or("");
@ -1295,14 +1544,17 @@ private:
reg_.spiUc = static_cast<std::uint32_t>(ps->spiUc.value_or(sa->spiUc)); reg_.spiUc = static_cast<std::uint32_t>(ps->spiUc.value_or(sa->spiUc));
reg_.spiUs = static_cast<std::uint32_t>(ps->spiUs.value_or(sa->spiUs)); reg_.spiUs = static_cast<std::uint32_t>(ps->spiUs.value_or(sa->spiUs));
reg_.expiry = ps->expiry.value_or(0); reg_.expiry = ps->expiry.value_or(0);
auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); reg_.securityServer = ss_;
reg_.authNonce = ps->authNonce.value_or(""); reg_.authCnonce = ps->authCnonce.value_or(""); reg_.authResponse = ps->authResponse.value_or("");
if (reg_.authNonce.empty()) Log("state file holds no challenge answer (pre-0.3.11): the refresh carries an empty digest");
auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
reg_.cseq = used; reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e;
UpdateRouteAndPpi(okMsg); UpdateRouteAndPpi(okMsg);
LogBindings(okMsg); LogBindings(okMsg);
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
Log("REGISTERED (resumed + true-refreshed)"); Log("REGISTERED (resumed + true-refreshed)");
return true; return ResumeOutcome::Done;
} }
// The registrar's 200 OK echoes EVERY current binding for the implicit // The registrar's 200 OK echoes EVERY current binding for the implicit
@ -1408,6 +1660,13 @@ private:
sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc; sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc;
sp.alg = alg; sp.alg = alg;
sp.ealg = ealg; sp.ealg = ealg;
// imsd's previous pair out first, each entry by its key — not `ip xfrm …
// flush`: another IPsec user's entries (a policy-based VPN's) stay (§12)
{
auto state = RunCapture({"ip", "xfrm", "state"});
auto policy = RunCapture({"ip", "xfrm", "policy"});
RunOwnXfrmDeletes(imsd::ipsec::OwnXfrmDeleteCommands(state.out, policy.out, local_));
}
saReplaced_ = true; // from here on the old registration's SAs are gone saReplaced_ = true; // from here on the old registration's SAs are gone
for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) { for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) {
int rc = RunCmd(cmd); int rc = RunCmd(cmd);
@ -1438,7 +1697,7 @@ private:
if (autoTcp) { if (autoTcp) {
// Two unanswered TCP connects: treat the P-CSCF as UDP-only // Two unanswered TCP connects: treat the P-CSCF as UDP-only
// and register again over UDP — a new challenge and a new SA // and register again over UDP — a new challenge and a new SA
// pair (the SA setup flushes this one), same ports. // pair (the SA setup takes this one out), same ports.
Log("protected TCP connect unanswered; registering fresh over UDP"); Log("protected TCP connect unanswered; registering fresh over UDP");
SetTransport(Transport::Udp); SetTransport(Transport::Udp);
FreshRegisterAttempt(); FreshRegisterAttempt();
@ -1461,13 +1720,19 @@ private:
reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2; reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2;
reg_.contactUser = contactUser; reg_.contactUser = contactUser;
reg_.spiUc = spiUc; reg_.spiUs = spiUs; reg_.spiUc = spiUc; reg_.spiUs = spiUs;
reg_.authNonce = *nonceB64; reg_.authCnonce = cnonce; reg_.authResponse = response;
reg_.securityServer = ss_;
reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0);
DumpRaw("imsd-register-200.raw", ok); DumpRaw("imsd-register-200.raw", ok);
// The Route for everything over this registration is the P-CSCF it
// was made at — never a previous one's, after a move from the
// resumed peer — unless the 200 carries a Service-Route, which
// UpdateRouteAndPpi puts in its place.
route_ = std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, portPs));
UpdateRouteAndPpi(ok); UpdateRouteAndPpi(ok);
LogBindings(ok); LogBindings(ok);
if (route_.empty()) route_ = std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, portPs));
ctx_.route = route_; ctx_.ppi = ppi_; ctx_.route = route_; ctx_.ppi = ppi_;
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
Log("REGISTERED (fresh)"); Log("REGISTERED (fresh)");
} }
@ -1551,39 +1816,43 @@ private:
throw std::runtime_error("no 401 to unprotected REGISTER"); throw std::runtime_error("no 401 to unprotected REGISTER");
} }
// re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq). // A reregistration over the (resumed or live) SA, TS 24.229 5.1.1.4.2:
std::pair<std::string, long> Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) { // the same Call-ID and Contact, CSeq+1, the Authorization of the last
std::string auth = imsd::msg::AuthEmpty(ctx_); // successful authentication, a Security-Client with new SPIs and a new
long cseq = startCseq; // client port for the SA pairs a re-authentication would set up (the
bool challenged = false; // server port stays, TS 33.203 7.4.0), the Security-Verify (the 401's
for (;;) { // list, kept in the state file since 0.3.12 so a resumed registration
std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth); // echoes it too; a resume from an older file sends the one entry
// rebuilt from the kernel's SA). Returns (200 msg, used cseq). A 401
// asks for re-authentication over temporary SAs on the proposed ports,
// which imsd does not do: it is thrown as a refusal whose recovery is a
// fresh registration (RegRecovery's 401 rule). Every refresh of the
// pre-0.3.11 shape on record from O2 UK — an initial registration's
// empty digest, the in-use SPIs — was refused 503 (2026-09-24 under
// 0.3.9, 2026-10-08 under 0.3.10) while the fresh registrations that
// followed were accepted; KPN accepted both shapes without a challenge.
std::pair<std::string, long> Reregister(const std::string& callid, const std::string& ftag, long cseq) {
std::string auth = reg_.authNonce.empty() ? imsd::msg::AuthEmpty(ctx_)
: imsd::msg::AuthAka(ctx_, reg_.authNonce, reg_.authCnonce, reg_.authResponse);
std::uint32_t spiUc = 0, spiUs = 0;
do spiUc = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUc == reg_.spiUc || spiUc == reg_.spiUs);
do spiUs = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUs == reg_.spiUc || spiUs == reg_.spiUs || spiUs == spiUc);
std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth, PortUcNext);
if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed");
std::string m = AwaitFinal(callid, cseq, "re-REGISTER"); std::string m = AwaitFinal(callid, cseq, "re-REGISTER");
auto st = imsd::sip::Status(m); auto st = imsd::sip::Status(m);
if (st == 200) return {m, cseq}; if (st == 200) return {m, cseq};
if (st == 401 && !challenged) {
challenged = true;
auto nonce = QuotedAfter(m, "nonce=\"");
auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt;
if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh");
std::vector<std::uint8_t> rnd(raw->begin(), raw->begin() + 16);
std::vector<std::uint8_t> autn(raw->begin() + 16, raw->begin() + 32);
auto aka = Authenticate(rnd, autn);
if (!aka) throw std::runtime_error("AKA failed on refresh");
std::string cnonce = rng_.Token(16);
std::string response = imsd::aka::DigestAkav1(*nonce, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain);
auth = imsd::msg::AuthAka(ctx_, *nonce, cnonce, response);
cseq++;
continue;
}
DumpRaw("imsd-register-fail.raw", m); DumpRaw("imsd-register-fail.raw", m);
if (st == 401) throw RefusedError(std::format("re-REGISTER challenged: {}; re-authentication asked, the next registration is a fresh one", imsd::sip::RefusalSummary(m)), 401, imsd::sip::RetryAfter(m).value_or(0));
throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0)); throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0));
} }
}
void UpdateRouteAndPpi(const std::string& okMsg) { void UpdateRouteAndPpi(const std::string& okMsg) {
auto svc = imsd::sip::Headers(okMsg, "Service-Route"); // an empty Service-Route value carries no route: ignored like a
// missing header, so the fallback Route the caller set stands
auto svcAll = imsd::sip::Headers(okMsg, "Service-Route");
decltype(svcAll) svc;
for (auto& v : svcAll) if (!v.empty()) svc.push_back(v);
if (svc.empty()) Log("200: no Service-Route (route stays P-CSCF)"); if (svc.empty()) Log("200: no Service-Route (route stays P-CSCF)");
if (!svc.empty()) { if (!svc.empty()) {
std::string r; std::string r;
@ -1662,17 +1931,20 @@ private:
if (Mono() - lastRefresh_ < interval) return; if (Mono() - lastRefresh_ < interval) return;
try { try {
RefreshPani(); RefreshPani();
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
reg_.cseq = used; reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); Log(std::format("keepalive re-REGISTER ok (cseq {})", used));
OnRegistered(); OnRegistered();
} catch (const RefusedError& e) { } catch (const RefusedError& e) {
// The network refused the refresh: the recovery policy counts it // The network refused the refresh: the recovery policy counts it
// (a second refusal means a fresh registration) and the flow is // — a refusal with a Retry-After of at least a second, or a 401
// dropped so the reconnect step retries the refresh after the // (the refresh throws a challenge as a refusal), makes the fresh
// wait (Retry-After when given). // registration the next step, behind any wait the network set; a
// refusal with neither gets one more refresh first, a second
// refusal then escalates — and the flow is dropped so the next
// step runs after the wait.
Log(std::format("keepalive re-REGISTER failed: {}", e.what())); Log(std::format("keepalive re-REGISTER failed: {}", e.what()));
CloseFlowAfterWait(); CloseFlowAfterWait();
RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status); RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status);
@ -1768,12 +2040,12 @@ private:
} }
try { try {
RefreshPani(); RefreshPani();
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
reg_.cseq = used; reg_.cseq = used;
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
UpdateRouteAndPpi(msg); UpdateRouteAndPpi(msg);
LogBindings(msg); LogBindings(msg);
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport); PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
Log("reconnected + re-registered"); Log("reconnected + re-registered");
OnRegistered(); OnRegistered();
} catch (const RefusedError& e) { } catch (const RefusedError& e) {
@ -1805,7 +2077,7 @@ private:
} }
// The rest of what a restart does, in place: a new Call-ID, a new // The rest of what a restart does, in place: a new Call-ID, a new
// challenge, a new SA pair (FreshRegister flushes the old ones), then // challenge, a new SA pair (FreshRegister takes the old ones out), then
// the post-registration steps. A refused or throttled attempt waits on // the post-registration steps. A refused or throttled attempt waits on
// the throttle schedule (a wait no Dial cuts short — on KPN a refused // the throttle schedule (a wait no Dial cuts short — on KPN a refused
// attempt costs the old registration) and a silent one on the // attempt costs the old registration) and a silent one on the
@ -1826,12 +2098,21 @@ private:
// a registration resumed on an older address (a pre-0.3.10 temporary // a registration resumed on an older address (a pre-0.3.10 temporary
// one) moves to the PDN's own address here, server ports included; // one) moves to the PDN's own address here, server ports included;
// an attempt that fails before the SA setup leaves that registration // an attempt that fails before the SA setup leaves that registration
// standing, so it moves back to it // standing, so it moves back to it — the P-CSCF too, once the address
// verdict below says the registration still stands
if (pcscfTarget_.empty()) { RequestRestart(std::string(NoFreshPcscf)); return; }
std::string fromPcscf = pcscf_;
if (pcscf_ != pcscfTarget_) {
Log(std::format("the fresh registration moves from P-CSCF {} to {}", pcscf_, pcscfTarget_));
pcscf_ = pcscfTarget_;
}
std::string from = local_; std::string from = local_;
if (local_ != pdnLocal_) { if (local_ != pdnLocal_) {
Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_)); Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_));
MoveTo(pdnLocal_); MoveTo(pdnLocal_);
} }
// the fresh target's host route, imsd's own (BringUp's fresh path does the same)
RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_});
bool ok = false; bool ok = false;
try { try {
RefreshPani(); RefreshPani();
@ -1857,6 +2138,12 @@ private:
Log(std::format("{} has left {}: the registration on it is gone; staying on {}; {}", from, dev_, local_, NextStep())); Log(std::format("{} has left {}: the registration on it is gone; staying on {}; {}", from, dev_, local_, NextStep()));
} }
} }
// after the address verdict: a registration just declared gone has
// no P-CSCF to go back to
if (!ok && !quit_.load() && recovery_.Reconnectable() && pcscf_ != fromPcscf) {
Log(std::format("back to P-CSCF {}: the old registration stands until the next fresh attempt", fromPcscf));
pcscf_ = fromPcscf;
}
} }
// Our address for everything from here on: the flow, the server ports, // Our address for everything from here on: the flow, the server ports,
@ -1911,6 +2198,16 @@ private:
PostEvent(std::move(ev)); PostEvent(std::move(ev));
} }
// Leave cleanly: nothing to do (§7.3); the
// unit stays inactive until the next boot, a `systemctl start imsd` or a
// start of imsd-ofonod (its Wants=imsd.service, the Phosh image).
void RequestIdle(const std::string& why) {
quit_.store(true);
auto ev = std::make_unique<Event>();
ev->type = Event::Type::Idle; ev->text = why;
PostEvent(std::move(ev));
}
// After a registration succeeds — bring-up, a keepalive refresh, a // After a registration succeeds — bring-up, a keepalive refresh, a
// reconnect + refresh, or a fresh recovery: the status (a signal only // reconnect + refresh, or a fresh recovery: the status (a signal only
// when it changes), the timers, the recovery policy, the reg-event // when it changes), the timers, the recovery policy, the reg-event
@ -2077,8 +2374,8 @@ private:
bool ClearForDial(const Cmd& c, bool emergency) { bool ClearForDial(const Cmd& c, bool emergency) {
if (!call_) return true; if (!call_) return true;
if (!emergency) { if (!emergency) {
Log(std::format("dial({}) refused: call in progress", c.number)); Log(std::format("dial({}) refused: call in progress", c.arg));
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; CallInfo bad{c.uni, c.arg, "terminated", "error", NowEpoch(), 0};
PostAdded(bad); PostAdded(bad);
PostState(c.uni, "terminated", "error"); PostState(c.uni, "terminated", "error");
PostDeleted(c.uni); PostDeleted(c.uni);
@ -2094,7 +2391,7 @@ private:
void HandleCmd(const Cmd& c) { void HandleCmd(const Cmd& c) {
if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; } if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; }
if (c.kind == Cmd::Kind::Dial) { if (c.kind == Cmd::Kind::Dial) {
bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_); bool emergency = imsd::msg::IsEmergency(c.arg, emergencyExtra_);
if (!ClearForDial(c, emergency)) return; if (!ClearForDial(c, emergency)) return;
// a dead client flow fails the INVITE instantly — give the // a dead client flow fails the INVITE instantly — give the
// recovery path one immediate shot first: user action cuts the // recovery path one immediate shot first: user action cuts the
@ -2113,9 +2410,9 @@ private:
if (emergency) if (emergency)
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, " Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
"fallback plain INVITE — stage 1, no emergency registration/PDN; " "fallback plain INVITE — stage 1, no emergency registration/PDN; "
"carrier-side behavior UNVERIFIED", c.number)); "carrier-side behavior UNVERIFIED", c.arg));
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, codecs_); call_.emplace(ctx_, rng_, c.uni, c.arg, rtpPort_, precond_, emergency, codecs_);
CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; CallInfo info{c.uni, c.arg, "dialing", "outgoing", NowEpoch(), 0};
PostAdded(info); PostAdded(info);
bool ok = Execute(call_->Start()); bool ok = Execute(call_->Start());
if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); } if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); }
@ -2125,6 +2422,9 @@ private:
} else if (c.kind == Cmd::Kind::Accept) { } else if (c.kind == Cmd::Kind::Accept) {
if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); } if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); }
else Log(std::format("Accept({}) ignored: no such ringing call", c.uni)); else Log(std::format("Accept({}) ignored: no such ringing call", c.uni));
} else if (c.kind == Cmd::Kind::Dtmf) {
if (call_ && call_->Uni() == c.uni) Execute(call_->OnDtmf(c.arg));
else Log(std::format("SendDtmf({}, '{}') ignored: no such call", c.uni, c.arg));
} }
} }
@ -2224,6 +2524,7 @@ private:
break; break;
case T::StartMedia: StartMedia(a.media); break; case T::StartMedia: StartMedia(a.media); break;
case T::StopMedia: StopMedia(); break; case T::StopMedia: StopMedia(); break;
case T::SendDtmf: SendDtmfToMedia(a.text); break;
case T::State: case T::State:
if (announce) PostState(call_->Uni(), a.state, a.reason); if (announce) PostState(call_->Uni(), a.state, a.reason);
break; break;
@ -2250,7 +2551,9 @@ private:
void StartMedia(const imsd::engine::MediaLeg& leg) { void StartMedia(const imsd::engine::MediaLeg& leg) {
if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; } if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; }
Log(std::format("media {} pt={} octet={} -> [{}]:{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort)); Log(std::format("media {} pt={} octet={} -> [{}]:{}, telephone-event pt {}{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort,
leg.dtmfPt ? std::to_string(*leg.dtmfPt) : std::string("none"),
leg.dtmfRxPt && leg.dtmfRxPt != leg.dtmfPt ? std::format(" (theirs {})", *leg.dtmfRxPt) : std::string()));
if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_}); if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_});
else else
RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_}); RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_});
@ -2264,8 +2567,21 @@ private:
session = ResolveAudioSession(); session = ResolveAudioSession();
Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : "")); Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : ""));
} }
// the control pipe: imsd-media reads `dtmf <tones>` lines on its
// stdin (§7.6); our end closes with the leg, no other child inherits
// it, and it never blocks the engine thread — a leg that stopped
// reading fails the write (EAGAIN once the pipe is full) instead
int ctl[2] = {-1, -1};
if (pipe(ctl) != 0) {
Log(std::format("media control pipe: {}; this call has no DTMF", std::strerror(errno)));
} else {
fcntl(ctl[0], F_SETFD, FD_CLOEXEC);
fcntl(ctl[1], F_SETFD, FD_CLOEXEC);
fcntl(ctl[1], F_SETFL, O_NONBLOCK);
}
pid_t pid = fork(); pid_t pid = fork();
if (pid == 0) { if (pid == 0) {
if (ctl[0] >= 0) dup2(ctl[0], STDIN_FILENO); // dup2 clears the close-on-exec
if (geteuid() == 0) { if (geteuid() == 0) {
// no session is said with an empty value: imsd-media reads a // no session is said with an empty value: imsd-media reads a
// missing variable as a caller older than 0.3.10 // missing variable as a caller older than 0.3.10
@ -2279,16 +2595,43 @@ private:
setenv("DTX", EnvOr("DTX", "0").c_str(), 1); setenv("DTX", EnvOr("DTX", "0").c_str(), 1);
setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1); setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1);
setenv("CODEC", leg.codec.c_str(), 1); setenv("CODEC", leg.codec.c_str(), 1);
setenv("DTMF_PT", leg.dtmfPt ? std::to_string(*leg.dtmfPt).c_str() : "", 1);
setenv("DTMF_RX_PT", leg.dtmfRxPt ? std::to_string(*leg.dtmfRxPt).c_str() : "", 1);
std::vector<char*> c; std::vector<char*> c;
for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str())); for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str()));
c.push_back(nullptr); c.push_back(nullptr);
execvp(c[0], c.data()); execvp(c[0], c.data());
_exit(127); _exit(127);
} }
if (ctl[0] >= 0) close(ctl[0]);
if (pid < 0 && ctl[1] >= 0) { close(ctl[1]); ctl[1] = -1; }
mediaPid_ = pid; mediaPid_ = pid;
mediaCtl_ = ctl[1];
}
// The engine's SendDtmf action: one `dtmf <tones>` line down the
// control pipe, whole or not at all — a write of at most PIPE_BUF bytes
// to a pipe is atomic, and the engine's DtmfMaxTones keeps the line
// under it (a longer line is refused here too, never half-written to
// be joined with the next). The leg may have died since the action was
// decided (its exit is reaped on this thread, after the actions ran):
// then the write fails with EPIPE — SIGPIPE is ignored in main() — and
// the tones are lost with the call, which the reap ends.
void SendDtmfToMedia(const std::string& tones) {
if (mediaCtl_ < 0) { Log(std::format("DTMF '{}' not sent: no control pipe to the media leg", tones)); return; }
const std::string line = std::format("dtmf {}\n", tones);
if (line.size() > PIPE_BUF) { Log(std::format("DTMF of {} tones not sent: longer than the control pipe's atomic write", tones.size())); return; }
const ssize_t n = write(mediaCtl_, line.data(), line.size());
if (n != static_cast<ssize_t>(line.size())) Log(std::format("DTMF '{}' not sent: {}", tones, n < 0 ? std::strerror(errno) : "short write to the media leg"));
}
void CloseMediaCtl() {
if (mediaCtl_ >= 0) close(mediaCtl_);
mediaCtl_ = -1;
} }
void StopMedia() { void StopMedia() {
CloseMediaCtl();
if (mediaPid_ <= 0) return; if (mediaPid_ <= 0) return;
kill(mediaPid_, SIGTERM); kill(mediaPid_, SIGTERM);
for (int i = 0; i < 100; i++) { for (int i = 0; i < 100; i++) {
@ -2308,6 +2651,7 @@ private:
if (r == 0) return; // still running if (r == 0) return; // still running
int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
mediaPid_ = -1; // reaped; don't re-terminate mediaPid_ = -1; // reaped; don't re-terminate
CloseMediaCtl();
Execute(call_->OnMediaExit(code)); Execute(call_->OnMediaExit(code));
MaybeDropCall(); MaybeDropCall();
} }
@ -2385,7 +2729,10 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar*
return; return;
} }
if (m == "SendDtmf") { if (m == "SendDtmf") {
Log("SendDtmf accepted but not yet sent (TODO RFC4733)"); const gchar* uni = nullptr;
const gchar* tones = nullptr;
g_variant_get(params, "(&s&s)", &uni, &tones);
TheEngine->SendDtmf(uni ? uni : "", tones ? tones : "");
g_dbus_method_invocation_return_value(inv, nullptr); g_dbus_method_invocation_return_value(inv, nullptr);
return; return;
} }
@ -2449,6 +2796,22 @@ int main(int argc, char** argv) {
std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)"); std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)");
return 1; return 1;
} }
// A write to the media leg's control pipe after the leg died must fail
// with EPIPE, not end the daemon (the client flow's sends carry
// MSG_NOSIGNAL; an inbound connection's reply is a plain write and is
// covered by this too).
signal(SIGPIPE, SIG_IGN);
// The bring-up found nothing to do (IMSD_IDLE= in /run/imsd.env — the
// script's arms: no SIM, a refused ims PDN): say so and
// leave cleanly. Restart=on-failure leaves a clean exit alone, so the
// unit stays inactive until the next boot, a manual start or a start of
// imsd-ofonod (its Wants=imsd.service).
if (std::string idle = EnvOr("IMSD_IDLE", ""); !idle.empty()) {
Log(std::format("idle: {} — nothing to do; systemctl start imsd when that changes",
idle == "no-sim" ? "no SIM" : idle == "no-ims-pdn" ? "no ims PDN from the network" : idle));
return 0;
}
std::string dev = EnvOr("DEV", "qmapmux0.0"); std::string dev = EnvOr("DEV", "qmapmux0.0");
std::string local = EnvOr("LOCAL", ""); std::string local = EnvOr("LOCAL", "");
@ -2459,7 +2822,31 @@ int main(int argc, char** argv) {
std::println(std::cerr, "imsd: no global IPv6 on {} — is the ims PDN up?", dev); std::println(std::cerr, "imsd: no global IPv6 on {} — is the ims PDN up?", dev);
return 1; return 1;
} }
Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, EnvOr("PCSCF", "(unset)"), EnvOr("PCSCF_PORT", "5060"))); local = imsd::util::CanonicalAddr(local);
std::string freshPcscf = FreshPcscfFromEnv();
Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, freshPcscf.empty() ? "(none)" : freshPcscf, EnvOr("PCSCF_PORT", "5060")));
// The daemon's own dead end, decided here, before the bus name, so the
// exit is clean and the unit stays inactive (§7.3): nothing to register
// to — no PCSCF=, nothing discovered — and no registration of its own to
// resume (SaPeer finds no pair, or the holds' write-off for this PDN and
// boot says it cannot). The script decides the dead ends only it can see
// (no SIM, a refused ims PDN). A discovery that failed is no dead end:
// exit 1, and the unit's restart runs it again.
if (!session && freshPcscf.empty()) {
auto policy = RunCapture({"ip", "xfrm", "policy"});
auto holds = LoadHolds(local);
auto peer = imsd::ipsec::SaPeer(policy.out);
const bool writtenOff = holds && holds->oldGone;
if (!peer || writtenOff) {
// a pair the holds wrote off before this start is nothing to
// resume: taken out of the kernel here, as the no-target bring-up
// takes a dead pair out (§7.2 step 1), so no start of this boot
// finds it — the holds keep the write-off
if (peer && writtenOff) RemoveDeadPair(local);
if (DiscoveryFailed()) { Log(std::format("{} — exiting for systemd restart", NoPcscfDiscoveryFailed)); return 1; }
Log(std::format("idle: {} — nothing to do; systemctl start imsd when that changes", NoPcscfIdleText)); return 0;
}
}
if (!session) LogAudioSession(); if (!session) LogAudioSession();
imsd::bus::AuthorizeCalls = !session; imsd::bus::AuthorizeCalls = !session;

View file

@ -26,11 +26,23 @@ DTX silence (every missing 20 ms slot is decoded as a NO_DATA frame for
CNG/PLC; zeros for G.711). MIC=1 feeds live pw-record audio through the CNG/PLC; zeros for G.711). MIC=1 feeds live pw-record audio through the
encoder. encoder.
DTMF goes out as RFC 4733 telephone-event packets at the payload type the
SDP negotiated for the codec's clock (DTMF_PT; unset = none): stdin is the
daemon's control pipe, and each `dtmf <tones>` line queues its tones (0-9,
*, #, A-D). A tone takes seven 20 ms slots of the uplink in place of the
audio frames — the event's five (100 ms at -10 dBm0) and two in which its
end packet goes out again — then three slots of audio run before the next
tone. The far end's events — taken at DTMF_RX_PT (default DTMF_PT) and
at DTMF_PT, never at the audio's payload type — are played as the tones
they stand for, in step with the audio clock.
Usage: imsd-media <local-ip> <rtp-port> <remote-ip> <remote-port> <pt> <secs> <out-base> Usage: imsd-media <local-ip> <rtp-port> <remote-ip> <remote-port> <pt> <secs> <out-base>
imsd-media --selftest (payload pack/depay roundtrip, both formats, imsd-media --selftest (payload pack/depay roundtrip, both formats,
both AMR codecs; G.711 table roundtrip) both AMR codecs; G.711 table roundtrip; the
telephone-event payload, codes and Q.23
frequency pairs)
Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP
OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP DTMF_PT DTMF_RX_PT
(OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the (OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the
daemon sets it from the negotiated SDP — 1 is the default/legacy behavior. daemon sets it from the negotiated SDP — 1 is the default/legacy behavior.
AMR_MODE is the encoder mode of whichever AMR codec is active; default 2 AMR_MODE is the encoder mode of whichever AMR codec is active; default 2
@ -45,6 +57,7 @@ with no PipeWire and no network.)
#include <dlfcn.h> #include <dlfcn.h>
#include <fcntl.h> #include <fcntl.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <poll.h>
#include <pwd.h> #include <pwd.h>
#include <signal.h> #include <signal.h>
#include <sys/socket.h> #include <sys/socket.h>
@ -471,8 +484,13 @@ Child SpawnPw(bool play, bool toChild, int rate) {
if (toChild) { dup2(pipefd[0], STDIN_FILENO); } if (toChild) { dup2(pipefd[0], STDIN_FILENO); }
else { dup2(pipefd[1], STDOUT_FILENO); } else { dup2(pipefd[1], STDOUT_FILENO); }
close(pipefd[0]); close(pipefd[1]); close(pipefd[0]); close(pipefd[1]);
int devnull = open("/dev/null", O_WRONLY); int devnull = open("/dev/null", O_RDWR);
if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); } if (devnull >= 0) {
dup2(devnull, STDERR_FILENO);
// our stdin is the daemon's control pipe: not pw-record's to hold
if (!toChild) dup2(devnull, STDIN_FILENO);
close(devnull);
}
std::vector<char*> cargv; std::vector<char*> cargv;
for (auto& s : argv) cargv.push_back(const_cast<char*>(s.c_str())); for (auto& s : argv) cargv.push_back(const_cast<char*>(s.c_str()));
cargv.push_back(nullptr); cargv.push_back(nullptr);
@ -507,19 +525,32 @@ struct TxState {
sockaddr_storage latched{}; socklen_t latchedLen = 0; sockaddr_storage latched{}; socklen_t latchedLen = 0;
std::uint64_t tx = 0; std::uint64_t tx = 0;
std::uint64_t txMic = 0; std::uint64_t txMic = 0;
std::uint64_t txDtmf = 0;
std::atomic<bool> micOn{false}; std::atomic<bool> micOn{false};
}; };
void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) { // One packet on the uplink, audio or event. Every packet stands in one
// 20 ms slot of the stream's single timestamp base (RFC 4733 §2.1: events
// share the audio's sequence and timestamp space), so the next slot's
// timestamp advances whichever kind went out; an audio frame is stamped
// with its slot (`ts` empty), an event packet with the event's start
// (`ts` given — constant over the event, §2.5.1.2). Returns the timestamp
// used.
std::uint32_t RtpSendPacket(int sock, TxState& st, int pt, bool marker, std::optional<std::uint32_t> ts, std::span<const std::uint8_t> payload) {
std::uint8_t hdr[12]; std::uint8_t hdr[12];
sockaddr_storage a1, a2; socklen_t l1, l2; sockaddr_storage a1;
sockaddr_storage a2;
socklen_t l1;
socklen_t l2;
std::uint32_t stamp;
{ {
std::scoped_lock g(st.lock); std::scoped_lock g(st.lock);
stamp = ts.value_or(st.ts);
hdr[0] = 0x80; hdr[0] = 0x80;
hdr[1] = static_cast<std::uint8_t>(st.pt & 0x7F); hdr[1] = static_cast<std::uint8_t>((marker ? 0x80 : 0) | (pt & 0x7F));
hdr[2] = (st.seq >> 8) & 0xFF; hdr[3] = st.seq & 0xFF; hdr[2] = (st.seq >> 8) & 0xFF; hdr[3] = st.seq & 0xFF;
hdr[4] = (st.ts >> 24) & 0xFF; hdr[5] = (st.ts >> 16) & 0xFF; hdr[4] = (stamp >> 24) & 0xFF; hdr[5] = (stamp >> 16) & 0xFF;
hdr[6] = (st.ts >> 8) & 0xFF; hdr[7] = st.ts & 0xFF; hdr[6] = (stamp >> 8) & 0xFF; hdr[7] = stamp & 0xFF;
hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF; hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF;
hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF; hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF;
st.seq = (st.seq + 1) & 0xFFFF; st.seq = (st.seq + 1) & 0xFFFF;
@ -537,8 +568,126 @@ void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) {
sendTo(a1, l1); sendTo(a1, l1);
// avoid double-send when latched == dst // avoid double-send when latched == dst
if (l2 && (l2 != l1 || std::memcmp(&a1, &a2, l1) != 0)) sendTo(a2, l2); if (l2 && (l2 != l1 || std::memcmp(&a1, &a2, l1) != 0)) sendTo(a2, l2);
return stamp;
} }
// an audio frame: the stream's payload type, stamped with its slot
void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) {
RtpSendPacket(sock, st, st.pt, false, std::nullopt, payload);
}
// ---- DTMF: RFC 4733 telephone-event packets on the uplink -----------------
// A tone is one event of five slots (100 ms): an event packet in each in
// place of the audio frame, the duration growing by a slot, the marker bit
// on the first, the end bit on the fifth, which goes out three times, the
// repeats in two slots of their own (§2.5.1.4) — then three slots of audio
// before the next tone. The gateway's detector sees a 100 ms tone and a
// 100 ms gap: what a keypad press produces, and Q.24's 40 ms minimums
// with margin. The audio's own senders carry the events — per slot, a
// captured frame in the mic thread or a silence tick in the main loop
// without a mic (and the latch burst), `DtmfSender::SendSlot` sends
// either the event packet the slot carries or the frame, under one lock —
// so one packet leaves per slot, no frame can land inside an event
// whichever thread holds the slot, and the stream's clock stays the
// capture's; the slots an event took advanced it like frames
// (RtpSendPacket), so the audio that follows is stamped as if it had run
// through it.
constexpr int DtmfToneSlots = 5;
constexpr int DtmfEndRepeats = 2;
constexpr int DtmfGapSlots = 3;
constexpr std::uint8_t DtmfVolume = 10; // -10 dBm0 (§2.3.4: 0 to -63)
// The event code of a keypad character (§3.2: 0-9, * = 10, # = 11, A-D =
// 12-15, either case); nullopt for anything else.
std::optional<int> DtmfEvent(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c == '*') return 10;
if (c == '#') return 11;
if (c >= 'A' && c <= 'D') return 12 + (c - 'A');
if (c >= 'a' && c <= 'd') return 12 + (c - 'a');
return std::nullopt;
}
// §2.3: event, E | R | volume, the duration in timestamp units, big-endian.
std::array<std::uint8_t, 4> DtmfPayload(int event, bool end, std::uint16_t duration) {
return {static_cast<std::uint8_t>(event),
static_cast<std::uint8_t>((end ? 0x80 : 0) | DtmfVolume),
static_cast<std::uint8_t>(duration >> 8),
static_cast<std::uint8_t>(duration & 0xFF)};
}
// The tone an event stands for (ITU-T Q.23 / RFC 4733 §3.2): the keypad's
// row and column frequencies of events 0-15; nullopt for any other event.
struct DtmfPair { int low; int high; };
std::optional<DtmfPair> DtmfFrequencies(int event) {
static constexpr std::array<int, 4> lows = {697, 770, 852, 941};
static constexpr std::array<int, 4> highs = {1209, 1336, 1477, 1633};
// keypad rows 1 2 3 A / 4 5 6 B / 7 8 9 C / * 0 # D, indexed by event
static constexpr std::array<std::pair<int, int>, 16> cell = {{
{3, 1}, {0, 0}, {0, 1}, {0, 2}, {1, 0}, {1, 1}, {1, 2}, {2, 0},
{2, 1}, {2, 2}, {3, 0}, {3, 2}, {0, 3}, {1, 3}, {2, 3}, {3, 3}}};
if (event < 0 || event > 15) return std::nullopt;
const auto [row, col] = cell[static_cast<std::size_t>(event)];
return DtmfPair{lows[static_cast<std::size_t>(row)], highs[static_cast<std::size_t>(col)]};
}
// Sample `k` of a tone at `rate`: the two sines at `amp` each.
std::int16_t DtmfSample(DtmfPair f, std::uint32_t k, int rate, double amp) {
const double t = static_cast<double>(k) / rate;
const double v = amp * (std::sin(2 * std::numbers::pi * f.low * t) + std::sin(2 * std::numbers::pi * f.high * t));
return static_cast<std::int16_t>(std::lround(std::clamp(v, -32768.0, 32767.0)));
}
// The tones the control pipe queued, paid out one slot at a time by the
// audio's own senders: one sender per slot, so no frame can land inside an
// event and the stream's clock is the capture's.
class DtmfSender {
public:
void Queue(std::string_view tones) {
std::scoped_lock g(lock_);
for (char c : tones)
if (auto ev = DtmfEvent(c)) queue_.push_back(*ev);
}
// Called by the audio's sender for every uplink slot, with the frame
// it would send: under one lock the slot is decided and sent — the
// event packet the slot carries (returns true: the frame stays unsent),
// or the frame (false; an empty frame, a DTX gap, sends nothing). One
// decision and one send per slot, so no frame can land inside an event
// whichever thread holds the slot.
bool SendSlot(int sock, TxState& st, int pt, std::span<const std::uint8_t> frame) {
std::scoped_lock g(lock_);
if (gap_ > 0) {
gap_--;
} else if (event_ >= 0 || !queue_.empty()) {
if (event_ < 0) {
event_ = queue_.front();
queue_.pop_front();
slot_ = 0;
start_.reset();
}
slot_++;
const int reported = std::min(slot_, DtmfToneSlots);
const auto payload = DtmfPayload(event_, slot_ >= DtmfToneSlots, static_cast<std::uint16_t>(reported * st.tsStep));
start_ = RtpSendPacket(sock, st, pt, slot_ == 1, start_, payload);
{ std::scoped_lock s(st.lock); st.txDtmf++; }
if (slot_ == DtmfToneSlots + DtmfEndRepeats) {
event_ = -1;
gap_ = DtmfGapSlots;
}
return true;
}
if (!frame.empty()) RtpSend(sock, st, frame);
return false;
}
private:
std::mutex lock_;
std::deque<int> queue_;
int event_ = -1; // the event on the wire; -1 = none
int slot_ = 0; // its packets sent so far
int gap_ = 0; // slots of audio owed before the next event
std::optional<std::uint32_t> start_; // the event's timestamp
};
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>>
Depay(Codec c, std::span<const std::uint8_t> pl, bool octetAlign) { Depay(Codec c, std::span<const std::uint8_t> pl, bool octetAlign) {
return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl); return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl);
@ -573,12 +722,14 @@ std::vector<std::int16_t> G711DecodeFrame(Codec c, std::span<const std::uint8_t>
std::atomic<bool> Quit{false}; std::atomic<bool> Quit{false};
void OnSig(int) { Quit.store(true); } void OnSig(int) { Quit.store(true); }
// playout queue item // playout queue item: an audio packet, or (`event`) a telephone-event one
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; }; struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; bool event = false; };
// --selftest: pack->depay roundtrip of every frame type, both payload // --selftest: pack->depay roundtrip of every frame type, both payload
// formats, both AMR codecs; G.711 digital zero, idempotence over the whole // formats, both AMR codecs; G.711 digital zero, idempotence over the whole
// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR. // 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR;
// the RFC 4733 telephone-event payload bytes, every keypad character's
// event code, every event's Q.23 frequency pair and no tone past D.
int SelfTest() { int SelfTest() {
for (Codec c : {Codec::AmrWb, Codec::AmrNb}) { for (Codec c : {Codec::AmrWb, Codec::AmrNb}) {
for (int ft = 0; ft < 16; ft++) { for (int ft = 0; ft < 16; ft++) {
@ -603,6 +754,42 @@ int SelfTest() {
std::println(std::cerr, "selftest FAIL G.711 digital zero"); std::println(std::cerr, "selftest FAIL G.711 digital zero");
return 1; return 1;
} }
// RFC 4733 §2.3: event, E | R | volume (-10 dBm0), duration big-endian
if (DtmfPayload(1, false, 160) != std::array<std::uint8_t, 4>{0x01, 0x0A, 0x00, 0xA0}
|| DtmfPayload(11, true, 1600) != std::array<std::uint8_t, 4>{0x0B, 0x8A, 0x06, 0x40}) {
std::println(std::cerr, "selftest FAIL telephone-event payload");
return 1;
}
// §3.2: every keypad character's event code, the letters in either case,
// and no code for anything else
for (const char* keys : {"0123456789*#ABCD", "0123456789*#abcd"}) {
for (int ev = 0; ev < 16; ev++) {
if (DtmfEvent(keys[ev]) != ev) {
std::println(std::cerr, "selftest FAIL telephone-event code of '{}'", keys[ev]);
return 1;
}
}
}
if (DtmfEvent('E') || DtmfEvent('e') || DtmfEvent(' ') || DtmfEvent('\n')) {
std::println(std::cerr, "selftest FAIL telephone-event codes");
return 1;
}
// Q.23: every event's pair — rows 697/770/852/941 Hz for 1 2 3 A, 4 5 6 B,
// 7 8 9 C, * 0 # D; columns 1209/1336/1477/1633 Hz — and no tone past D
static constexpr std::array<std::pair<int, int>, 16> q23 = {{
{941, 1336}, {697, 1209}, {697, 1336}, {697, 1477}, {770, 1209}, {770, 1336}, {770, 1477}, {852, 1209},
{852, 1336}, {852, 1477}, {941, 1209}, {941, 1477}, {697, 1633}, {770, 1633}, {852, 1633}, {941, 1633}}};
for (int ev = 0; ev < 16; ev++) {
const auto f = DtmfFrequencies(ev);
if (!f || f->low != q23[static_cast<std::size_t>(ev)].first || f->high != q23[static_cast<std::size_t>(ev)].second) {
std::println(std::cerr, "selftest FAIL telephone-event tone {}", ev);
return 1;
}
}
if (DtmfFrequencies(16) || DtmfFrequencies(-1)) {
std::println(std::cerr, "selftest FAIL telephone-event tones past D");
return 1;
}
for (Codec c : {Codec::Pcma, Codec::Pcmu}) { for (Codec c : {Codec::Pcma, Codec::Pcmu}) {
for (int v = -32768; v <= 32767; v++) { for (int v = -32768; v <= 32767; v++) {
std::uint8_t b = G711Encode(c, static_cast<std::int16_t>(v)); std::uint8_t b = G711Encode(c, static_cast<std::int16_t>(v));
@ -661,6 +848,8 @@ int main(int argc, char** argv) {
bool rtpDump = EnvBool("RTP_DUMP", false); bool rtpDump = EnvBool("RTP_DUMP", false);
std::string micSrc = EnvOr("MIC_SRC", ""); std::string micSrc = EnvOr("MIC_SRC", "");
bool pcmDump = EnvBool("PCM_DUMP", false); bool pcmDump = EnvBool("PCM_DUMP", false);
const int dtmfPt = std::atoi(EnvOr("DTMF_PT", "0").c_str());
const int dtmfRxPt = std::atoi(EnvOr("DTMF_RX_PT", EnvOr("DTMF_PT", "0").c_str()).c_str());
constexpr int ExitMediaTimeout = 3; constexpr int ExitMediaTimeout = 3;
constexpr int PrimeFrames = 8; constexpr int PrimeFrames = 8;
constexpr int MaxFill = 25; constexpr int MaxFill = 25;
@ -693,8 +882,13 @@ int main(int argc, char** argv) {
signal(SIGINT, OnSig); signal(SIGINT, OnSig);
signal(SIGPIPE, SIG_IGN); signal(SIGPIPE, SIG_IGN);
// ---- DTMF: the tones the control pipe (below) queues; the audio's
// senders pay them out slot by slot
DtmfSender dtmf;
// ---- mic uplink thread: pw-record (or the MIC_SRC file, real-time paced) // ---- mic uplink thread: pw-record (or the MIC_SRC file, real-time paced)
// through the codec's encoder, one RTP packet per 20 ms frame // through the codec's encoder, one RTP packet per 20 ms frame — or the
// event packet whose slot it is
std::atomic<bool> stop{false}; std::atomic<bool> stop{false};
std::jthread micThread; std::jthread micThread;
if (mic) { if (mic) {
@ -731,23 +925,23 @@ int main(int argc, char** argv) {
next += std::chrono::milliseconds(20); next += std::chrono::milliseconds(20);
std::this_thread::sleep_until(next); std::this_thread::sleep_until(next);
} }
st.micOn.store(true);
std::memcpy(samples.data(), raw.data(), raw.size()); std::memcpy(samples.data(), raw.data(), raw.size());
if (gain != 1.0) if (gain != 1.0)
for (auto& sample : samples) { for (auto& sample : samples) {
int v = static_cast<int>(sample * gain); int v = static_cast<int>(sample * gain);
sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
} }
std::vector<std::uint8_t> payload; std::vector<std::uint8_t> payload; // empty: a DTX gap, nothing to send in the slot
if (amr) { if (amr) {
std::vector<std::uint8_t> frame = enc.Encode(samples.data(), amrMode, dtx); std::vector<std::uint8_t> frame = enc.Encode(samples.data(), amrMode, dtx);
if (frame.empty()) continue; if (!frame.empty()) payload = PayloadFromFrame(codec, frame, octetAlign);
payload = PayloadFromFrame(codec, frame, octetAlign);
} else { } else {
payload = G711Payload(codec, samples); payload = G711Payload(codec, samples);
} }
st.micOn.store(true); // the slot's packet: the event's, or this frame
if (dtmf.SendSlot(sock, st, dtmfPt, payload) || payload.empty()) continue;
{ std::scoped_lock g(st.lock); st.txMic++; } { std::scoped_lock g(st.lock); st.txMic++; }
RtpSend(sock, st, payload);
} }
st.micOn.store(false); st.micOn.store(false);
close(fd); close(fd);
@ -783,6 +977,8 @@ int main(int argc, char** argv) {
std::uint64_t cng = 0; std::uint64_t cng = 0;
std::uint64_t late = 0; std::uint64_t late = 0;
std::uint64_t qdrop = 0; std::uint64_t qdrop = 0;
std::uint64_t rxDtmf = 0; // telephone-event packets from the far end
std::uint64_t tones = 0; // events of theirs rendered as a tone
std::jthread playThread; std::jthread playThread;
if (decodeOn) { if (decodeOn) {
playThread = std::jthread([&] { playThread = std::jthread([&] {
@ -814,6 +1010,7 @@ int main(int argc, char** argv) {
}; };
std::vector<std::int16_t> zero(frameSamples, 0); std::vector<std::int16_t> zero(frameSamples, 0);
std::optional<std::uint32_t> expect; std::optional<std::uint32_t> expect;
std::optional<std::uint32_t> toneTs; // the event being rendered, by its start stamp
for (;;) { for (;;) {
PktItem item; PktItem item;
{ {
@ -823,6 +1020,55 @@ int main(int argc, char** argv) {
item = std::move(playq.front()); item = std::move(playq.front());
playq.pop_front(); playq.pop_front();
} }
if (item.event) {
// The far end's key press, played as the tone it stands
// for (§2.5.2.2 leaves rendering to the receiver). The
// gap before the event's start — a stock sender's DTX
// silence often precedes a key press (dev phone,
// 2026-10-15: 0 to 7 slots) — is filled as before an
// audio frame, so the tone starts where the event does.
// Each packet reports the event's duration so far from
// its start stamp; what the clock has not covered yet is
// synthesized — a retransmitted end packet adds nothing,
// a lost update is caught up by the next. A far end
// whose audio resumes past the event (the stock phone's
// does) meets the clock with the usual fill.
if (item.payload.size() < 4) continue;
const auto freqs = DtmfFrequencies(item.payload[0]);
if (!freqs) continue;
const std::uint32_t dur = (static_cast<std::uint32_t>(item.payload[2]) << 8) | item.payload[3];
const double amp = 16383.0 * std::pow(10.0, -(item.payload[1] & 0x3F) / 20.0);
if (!expect) {
for (int i = 0; i < PrimeFrames; i++)
if (!writePcm(zero)) return;
expect = item.ts;
}
const std::uint32_t before = (item.ts - *expect) & 0xFFFFFFFF;
if (before < 0x80000000u) {
int fill = static_cast<int>(before / frameSamples);
if (fill > MaxFill) fill = 0;
for (int i = 0; i < fill; i++) {
cng++;
std::uint8_t nodata = 0x7C;
std::vector<std::int16_t> pcm = amr ? dec.Decode(&nodata, 1) : zero;
applyGain(pcm);
if (!writePcm(pcm)) return;
}
expect = item.ts;
}
const std::uint32_t end = (item.ts + dur) & 0xFFFFFFFF;
const std::uint32_t ahead = (end - *expect) & 0xFFFFFFFF;
if (ahead == 0 || ahead > static_cast<std::uint32_t>(2 * rate)) continue; // covered, or not a tone's length
if (!toneTs || *toneTs != item.ts) { toneTs = item.ts; tones++; }
const std::uint32_t from = (*expect - item.ts) & 0xFFFFFFFF;
std::vector<std::int16_t> pcm(ahead);
for (std::uint32_t i = 0; i < ahead; i++)
pcm[i] = DtmfSample(*freqs, from + i, rate, amp);
applyGain(pcm);
if (!writePcm(pcm)) return;
expect = end;
continue;
}
std::vector<std::vector<std::uint8_t>> frames; std::vector<std::vector<std::uint8_t>> frames;
if (amr) { if (amr) {
for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) { for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) {
@ -867,9 +1113,65 @@ int main(int argc, char** argv) {
}); });
} }
// ---- the control pipe that queues the tones: the daemon's
// `dtmf <tones>` lines on stdin. No pipe (a hand run, /dev/null) ends
// the reader at once; the leg runs on.
std::jthread ctlThread([&] {
// a hand run in the background of a job-control shell: a read of
// its terminal would stop the whole process (SIGTTIN); blocked for
// this thread, the read fails with EIO and the reader ends
sigset_t ttin;
sigemptyset(&ttin);
sigaddset(&ttin, SIGTTIN);
pthread_sigmask(SIG_BLOCK, &ttin, nullptr);
std::string pending;
bool discarding = false; // an overlong line is dropped through its end
char buf[256];
while (!stop.load()) {
pollfd pfd{STDIN_FILENO, POLLIN, 0};
if (poll(&pfd, 1, 100) <= 0) continue;
const ssize_t n = read(STDIN_FILENO, buf, sizeof buf);
if (n <= 0) return;
std::string_view in(buf, static_cast<std::size_t>(n));
if (discarding) {
const std::size_t end = in.find('\n');
if (end == std::string_view::npos) continue;
in.remove_prefix(end + 1);
discarding = false;
}
pending.append(in);
// a line of more than 4096 bytes with no end in sight: dropped
// through its end; the next line goes
if (pending.size() > 4096 && pending.find('\n') == std::string::npos) {
std::println(std::cerr, "imsd-media: control line of {} bytes without an end dropped", pending.size());
pending.clear();
discarding = true;
continue;
}
std::size_t nl;
while ((nl = pending.find('\n')) != std::string::npos) {
const std::string line = pending.substr(0, nl);
pending.erase(0, nl + 1);
// the same bound on a line whose end came in the read that
// took it past 4096 bytes
if (nl > 4096) {
std::println(std::cerr, "imsd-media: control line of {} bytes dropped", nl);
continue;
}
if (!line.starts_with("dtmf ")) {
std::println(std::cerr, "imsd-media: control line ignored: {}", line);
} else if (dtmfPt > 0) {
dtmf.Queue(std::string_view(line).substr(5));
} else {
std::println(std::cerr, "imsd-media: {} dropped: no telephone-event payload type (DTMF_PT)", line);
}
}
}
});
// ---- main recv loop // ---- main recv loop
std::vector<std::uint8_t> silence = SilenceFrame(codec, 0, octetAlign); std::vector<std::uint8_t> silence = SilenceFrame(codec, 0, octetAlign);
for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst for (int i = 0; i < 5; i++) dtmf.SendSlot(sock, st, dtmfPt, silence); // latch burst, the one path
std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr; std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr;
double t0 = Now(); double t0 = Now();
@ -888,7 +1190,8 @@ int main(int argc, char** argv) {
break; break;
} }
if (now - lastTx >= 0.02 && !st.micOn.load()) { if (now - lastTx >= 0.02 && !st.micOn.load()) {
RtpSend(sock, st, silence); // no mic: the slot's packet is the event's, or a silence frame
dtmf.SendSlot(sock, st, dtmfPt, silence);
lastTx = now; lastTx = now;
} }
std::uint8_t buf[65535]; std::uint8_t buf[65535];
@ -926,7 +1229,14 @@ int main(int argc, char** argv) {
std::fwrite(lb, 1, 4, dump); std::fwrite(lb, 1, 4, dump);
std::fwrite(buf, 1, static_cast<std::size_t>(n), dump); std::fwrite(buf, 1, static_cast<std::size_t>(n), dump);
} }
if (playThread.joinable() && n >= 12 && (buf[1] & 0x7F) == pt) { // the far end's events arrive at the payload type our own SDP gave
// telephone-event (DTMF_RX_PT), or at the one we send with when the
// answerer numbers for itself — never at the audio's: a frame is
// audio first
const int rxPt = n >= 12 ? buf[1] & 0x7F : -1;
const bool isEvent = n >= 12 && rxPt != pt && ((dtmfRxPt > 0 && rxPt == dtmfRxPt) || (dtmfPt > 0 && rxPt == dtmfPt));
if (isEvent) rxDtmf++;
if (playThread.joinable() && n >= 12 && (rxPt == pt || isEvent)) {
std::size_t off = RtpPayloadOffset(std::span(buf, static_cast<std::size_t>(n))); std::size_t off = RtpPayloadOffset(std::span(buf, static_cast<std::size_t>(n)));
std::uint32_t pktTs = (static_cast<std::uint32_t>(buf[4]) << 24) | std::uint32_t pktTs = (static_cast<std::uint32_t>(buf[4]) << 24) |
(buf[5] << 16) | (buf[6] << 8) | buf[7]; (buf[5] << 16) | (buf[6] << 8) | buf[7];
@ -935,13 +1245,14 @@ int main(int argc, char** argv) {
playq.pop_front(); playq.pop_front();
qdrop++; qdrop++;
} }
playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n)}); playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n), isEvent});
qcv.notify_one(); qcv.notify_one();
} }
} }
stop.store(true); stop.store(true);
qcv.notify_all(); qcv.notify_all();
if (ctlThread.joinable()) ctlThread.join();
if (micThread.joinable()) micThread.join(); if (micThread.joinable()) micThread.join();
if (playThread.joinable()) playThread.join(); if (playThread.joinable()) playThread.join();
if (dump) std::fclose(dump); if (dump) std::fclose(dump);
@ -961,14 +1272,14 @@ int main(int argc, char** argv) {
// .stats sidecar (tiny; always written) // .stats sidecar (tiny; always written)
if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) { if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) {
std::print(sf, std::print(sf,
"{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, " "{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"tx_dtmf\": {}, \"rx\": {}, \"rx_bytes\": {}, "
"\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, " "\"rx_played\": {}, \"rx_dtmf\": {}, \"tones\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, " "\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"dtmf_rx_pt\": {}, \"mic\": {}, "
"\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}", "\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}",
CodecName(codec), st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc, CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, rxDtmf, tones, cng, late, qdrop, firstSrc,
rIp, rPort, pt, mic, play, amrMode, mediaEnded); rIp, rPort, pt, dtmfPt, dtmfRxPt, mic, play, amrMode, mediaEnded);
std::fclose(sf); std::fclose(sf);
} }
std::println("imsd-media: codec={} tx={} tx_mic={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded); std::println("imsd-media: codec={} tx={} tx_mic={} tx_dtmf={} rx={} rx_played={} rx_dtmf={} tones={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxPlayed, rxDtmf, tones, cng, late, qdrop, rxBytes, firstSrc, mediaEnded);
return mediaEnded ? ExitMediaTimeout : 0; return mediaEnded ? ExitMediaTimeout : 0;
} }

239
implementations/pcscf.cpp Normal file
View file

@ -0,0 +1,239 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types no-char-pointer
/*
imsd-pcscf — prints the P-CSCF list the network delivered in the ims PDN's
Protocol Configuration Options, read from the modem over QMI.
ModemManager owns the data call and never asks the modem for these fields;
a second WDS client gets them once it is bound to the same mux data port
(the binding ModemManager gives its own clients), so this is a reader on
the side: Bind Mux Data Port, Set IP Family 6, Get Current Settings, over
one AF_QIPCRTR socket, which is its own QMI client as far as the modem is
concerned — no proxy, no interference with ModemManager's. It never sends
Start Network or Stop Network: it changes nothing about the call. The
bytes are Imsd:Qmi's; this file is the socket, the service lookup and the
sequence.
Usage: imsd-pcscf --mux <id> [--ep-type 4] [--ep-iface 1] [--node 0]
[--profile <idx>] [--timeout <s>] [-v]
--mux the rmnet mux id of the ims netdev (`ip -d link show <dev>`)
--ep-type, --ep-iface
the modem's data endpoint: embedded (4), interface 1 for an
IPA-attached modem (what ModemManager binds for the `ipa`
driver)
--node the QRTR node of the modem's WDS service (0)
--profile the ims profile's 3GPP index: adds the profile's "PCSCF Address
Using PCO" flag to the output (did the modem ask the network at
all?)
--timeout the whole run's budget in seconds (10)
-v every request and answer in hex, on stderr
Output, one item per line:
<ipv6 address> the P-CSCF list, PCO order
v4 <address> an IPv4 P-CSCF (unusable on an IPv6 PDN)
fqdn <name> a P-CSCF name
flag pco-requested <0|1> the call's TLV 0x22
flag profile-pcscf-via-pco <0|1> the profile's flag (with --profile)
Exit: 0 at least one IPv6 address; 2 none; 1 an error (text on stderr —
`OutOfCall` means the bound client does not see a call on that mux).
*/
#include <errno.h>
#include <linux/qrtr.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
import std;
import Imsd;
namespace {
bool Verbose = false;
void Err(std::string_view s) { std::println(std::cerr, "imsd-pcscf: {}", s); }
std::string Hex(std::span<const std::uint8_t> b) {
std::string s;
for (std::uint8_t x : b) s += std::format("{}{:02x}", s.empty() ? "" : " ", x);
return s;
}
long NowMs() {
return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
}
// Ask the control port for the WDS servers; the first one on `node`.
// (The lookup is sent to node 1's control port — the local node on this
// platform — and to our own node's when that differs, as the modem's
// control port answers for the whole bus.)
std::optional<sockaddr_qrtr> FindWds(std::uint32_t node, long deadlineMs) {
int s = socket(AF_QIPCRTR, SOCK_DGRAM, 0);
if (s < 0) { Err(std::format("socket(AF_QIPCRTR): {}", std::strerror(errno))); return std::nullopt; }
qrtr_ctrl_pkt pkt{};
pkt.cmd = QRTR_TYPE_NEW_LOOKUP;
pkt.server.service = imsd::qmi::WdsService;
sockaddr_qrtr ctrl{};
ctrl.sq_family = AF_QIPCRTR;
ctrl.sq_node = 1;
ctrl.sq_port = QRTR_PORT_CTRL;
(void)!sendto(s, &pkt, sizeof pkt, 0, reinterpret_cast<sockaddr*>(&ctrl), sizeof ctrl);
sockaddr_qrtr me{};
socklen_t ml = sizeof me;
if (getsockname(s, reinterpret_cast<sockaddr*>(&me), &ml) == 0 && me.sq_node != 1) {
ctrl.sq_node = me.sq_node;
(void)!sendto(s, &pkt, sizeof pkt, 0, reinterpret_cast<sockaddr*>(&ctrl), sizeof ctrl);
}
std::optional<sockaddr_qrtr> found;
while (true) {
long left = deadlineMs - NowMs();
if (left <= 0) break;
pollfd p{s, POLLIN, 0};
if (poll(&p, 1, static_cast<int>(std::min(left, 2000L))) <= 0) break;
qrtr_ctrl_pkt in{};
sockaddr_qrtr from{};
socklen_t fl = sizeof from;
ssize_t n = recvfrom(s, &in, sizeof in, 0, reinterpret_cast<sockaddr*>(&from), &fl);
if (n < static_cast<ssize_t>(sizeof in)) continue;
if (in.cmd != QRTR_TYPE_NEW_SERVER) continue;
if (in.server.service == 0) break; // end of the dump
if (Verbose) Err(std::format("WDS server: instance {} at {}:{}", static_cast<unsigned>(in.server.instance), static_cast<unsigned>(in.server.node), static_cast<unsigned>(in.server.port)));
if (in.server.service == imsd::qmi::WdsService && in.server.node == node && !found) {
sockaddr_qrtr a{};
a.sq_family = AF_QIPCRTR;
a.sq_node = in.server.node;
a.sq_port = in.server.port;
found = a;
}
}
close(s);
return found;
}
// One QMI client: one socket, one transaction at a time.
class Wds {
public:
Wds(sockaddr_qrtr peer, long deadlineMs) : peer_(peer), deadline_(deadlineMs) { fd_ = socket(AF_QIPCRTR, SOCK_DGRAM, 0); }
~Wds() { if (fd_ >= 0) close(fd_); }
bool Open() const { return fd_ >= 0; }
std::uint16_t NextTxn() { return ++txn_; }
// Send a request and wait for its response (same transaction and
// message id); indications and strays are skipped.
std::optional<imsd::qmi::Response> Call(std::span<const std::uint8_t> req, std::uint16_t msg, std::string_view name) {
if (Verbose) Err(std::format("--> {}: {}", name, Hex(req)));
if (sendto(fd_, req.data(), req.size(), 0, reinterpret_cast<sockaddr*>(&peer_), sizeof peer_) < 0) {
Err(std::format("{}: sendto: {}", name, std::strerror(errno)));
return std::nullopt;
}
std::vector<std::uint8_t> buf(65536);
while (true) {
long left = deadline_ - NowMs();
if (left <= 0) { Err(std::format("{}: no answer in time", name)); return std::nullopt; }
pollfd p{fd_, POLLIN, 0};
int pr = poll(&p, 1, static_cast<int>(left));
if (pr <= 0) { Err(std::format("{}: no answer in time", name)); return std::nullopt; }
ssize_t n = recv(fd_, buf.data(), buf.size(), 0);
if (n <= 0) continue;
auto r = imsd::qmi::ParseMessage(std::span<const std::uint8_t>(buf.data(), static_cast<std::size_t>(n)));
if (!r) continue;
if (r->type != imsd::qmi::TypeResponse || r->txn != txn_ || r->msg != msg) {
if (Verbose) Err(std::format(" (skipped type {} txn {} msg 0x{:04x})", r->type, r->txn, r->msg));
continue;
}
if (Verbose) Err(std::format("<-- {}: {}", name, Hex(std::span<const std::uint8_t>(buf.data(), static_cast<std::size_t>(n)))));
return r;
}
}
private:
int fd_ = -1;
sockaddr_qrtr peer_;
long deadline_;
std::uint16_t txn_ = 0;
};
void Usage() {
std::println(std::cerr, "usage: imsd-pcscf --mux <id> [--ep-type 4] [--ep-iface 1] [--node 0] [--profile <idx>] [--timeout <s>] [-v]");
}
}
int main(int argc, char** argv) {
std::optional<int> mux;
std::optional<int> profile;
std::uint32_t epType = imsd::qmi::EndpointEmbedded;
std::uint32_t epIface = imsd::qmi::EndpointInterfaceIpa;
std::uint32_t node = 0;
double timeout = 10.0;
auto num = [&](int& i, const char* what) -> std::optional<long> {
if (i + 1 >= argc) { Err(std::format("{} needs a value", what)); return std::nullopt; }
std::string_view v(argv[++i]);
long out = 0;
auto [p, ec] = std::from_chars(v.data(), v.data() + v.size(), out);
if (ec != std::errc{} || p != v.data() + v.size()) { Err(std::format("{}: not a number: {}", what, v)); return std::nullopt; }
return out;
};
for (int i = 1; i < argc; i++) {
std::string_view a(argv[i]);
std::optional<long> v;
if (a == "--mux") { if (!(v = num(i, "--mux"))) return 1; mux = static_cast<int>(*v); }
else if (a == "--profile") { if (!(v = num(i, "--profile"))) return 1; profile = static_cast<int>(*v); }
else if (a == "--ep-type") { if (!(v = num(i, "--ep-type"))) return 1; epType = static_cast<std::uint32_t>(*v); }
else if (a == "--ep-iface") { if (!(v = num(i, "--ep-iface"))) return 1; epIface = static_cast<std::uint32_t>(*v); }
else if (a == "--node") { if (!(v = num(i, "--node"))) return 1; node = static_cast<std::uint32_t>(*v); }
else if (a == "--timeout") {
if (i + 1 >= argc) { Err("--timeout needs a value"); return 1; }
timeout = std::atof(argv[++i]);
if (timeout <= 0) { Err("--timeout: not a positive number"); return 1; }
}
else if (a == "-v") Verbose = true;
else { Usage(); return 1; }
}
if (!mux || *mux < 0 || *mux > 255) { Usage(); return 1; }
if (profile && (*profile < 0 || *profile > 255)) { Err("--profile: 0..255"); return 1; }
long deadline = NowMs() + static_cast<long>(timeout * 1000);
auto peer = FindWds(node, deadline);
if (!peer) { Err(std::format("no WDS service on qrtr node {}", node)); return 1; }
Wds c(*peer, deadline);
if (!c.Open()) { Err(std::format("socket(AF_QIPCRTR): {}", std::strerror(errno))); return 1; }
auto step = [&](std::vector<std::uint8_t> req, std::uint16_t msg, std::string_view name) -> std::optional<imsd::qmi::Response> {
auto r = c.Call(req, msg, name);
if (!r) return std::nullopt;
if (!r->Ok()) {
Err(std::format("{}: error {} ({})", name, r->error, imsd::qmi::ErrorName(r->error)));
return std::nullopt;
}
return r;
};
using namespace imsd::qmi;
if (!step(BindMuxDataPort(c.NextTxn(), epType, epIface, static_cast<std::uint8_t>(*mux)), MsgBindMuxDataPort, "Bind Mux Data Port")) return 1;
if (!step(SetIpFamily(c.NextTxn(), IpFamilyV6), MsgSetIpFamily, "Set IP Family")) return 1;
if (Verbose) {
if (auto r = step(GetPacketServiceStatus(c.NextTxn()), MsgGetPacketServiceStatus, "Get Packet Service Status"))
Err(std::format("packet service status: {}", PacketServiceStatus(*r) ? std::to_string(*PacketServiceStatus(*r)) : "?"));
}
auto settings = c.Call(GetCurrentSettings(c.NextTxn(), RequestedSettingsAll), MsgGetCurrentSettings, "Get Current Settings");
if (!settings) return 1;
if (!settings->Ok()) {
if (settings->error == ErrOutOfCall) Err(std::format("Get Current Settings: OutOfCall — the client bound to mux {} sees no call there (is that the ims netdev's mux id?)", *mux));
else Err(std::format("Get Current Settings: error {} ({})", settings->error, ErrorName(settings->error)));
return 1;
}
PcscfLists lists = ParsePcscfLists(*settings);
for (const std::string& a : lists.ipv6) std::println("{}", a);
for (const std::string& a : lists.ipv4) std::println("v4 {}", a);
for (const std::string& n : lists.fqdn) std::println("fqdn {}", n);
std::println("flag pco-requested {}", lists.viaPco ? 1 : 0);
if (profile) {
auto r = c.Call(GetProfileSettings(c.NextTxn(), static_cast<std::uint8_t>(*profile)), MsgGetProfileSettings, "Get Profile Settings");
if (r && r->Ok()) {
if (auto f = ProfilePcscfViaPco(*r)) std::println("flag profile-pcscf-via-pco {}", *f ? 1 : 0);
else Err(std::format("Get Profile Settings: profile {} carries no PCSCF-via-PCO flag", *profile));
} else if (r) {
Err(std::format("Get Profile Settings: error {} ({})", r->error, ErrorName(r->error)));
}
}
return lists.ipv6.empty() ? 2 : 0;
}

View file

@ -18,8 +18,10 @@ That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx
finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the
emergency attempt chain (urn:service:sos first for a classified number, one emergency attempt chain (urn:service:sos first for a classified number, one
fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade
the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE, the other way), the terminating (UAS) side: 100/180 on an inbound INVITE,
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — 200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair — and
DTMF, which reaches the media leg only on an active call whose SDP
negotiated telephone-event, as the tones a keypad has —
unit-testable against recorded network dialogs with no I/O (tests/Engine). unit-testable against recorded network dialogs with no I/O (tests/Engine).
State/reason strings are the frozen D-Bus vocabulary the dialer maps on State/reason strings are the frozen D-Bus vocabulary the dialer maps on
@ -60,7 +62,9 @@ export namespace imsd::engine {
// bring-up, as a pure reducer: the shell supplies the clock and the // bring-up, as a pure reducer: the shell supplies the clock and the
// network, this decides the next step and how long to wait for it. Two // network, this decides the next step and how long to wait for it. Two
// kinds of failure reach it. A REFUSAL: the network answered a refresh // kinds of failure reach it. A REFUSAL: the network answered a refresh
// (or a fresh REGISTER) with a final other than 200/401. SILENCE: the // with a final other than 200 — a 401, re-authentication, included — or
// a fresh registration's protected REGISTER with a final other than 200
// (the unprotected one's 401 is its challenge). SILENCE: the
// flow died and reconnecting through the SA, or the refresh over it, // flow died and reconnecting through the SA, or the refresh over it,
// got no answer. Neither is retried for long on the same registration. // got no answer. Neither is retried for long on the same registration.
// TS 24.229 5.1.1.4.1 has the UE perform an initial registration after // TS 24.229 5.1.1.4.1 has the UE perform an initial registration after
@ -74,13 +78,20 @@ export namespace imsd::engine {
// fresh registration the network accepted within two seconds (ims // fresh registration the network accepted within two seconds (ims
// ledger F16). // ledger F16).
// //
// Pacing. Reconnect cycles back off 5 s → 5 min. A refusal escalates // Pacing. Reconnect cycles back off 5 s → 5 min. A refusal without a
// to a fresh registration after two (one 503 may be a momentary // Retry-After of at least a second (none, 0, unparsable) escalates to
// hiccup a retried refresh clears), silence after two reconnect cycles // a fresh registration after two (one 503 may be a momentary hiccup a
// retried refresh clears); one with such a Retry-After escalates at
// once — it is the network's considered answer (O2 2026-10-08: the
// refresh after the first wait was refused again, 414 s then 772 s) —
// and so does a 401, the network asking to re-authenticate, which imsd
// answers with a fresh registration (TS 24.229 5.1.1.5.1's temporary
// SAs on the proposed client port are not implemented). Silence
// escalates after two reconnect cycles
// that end without an answer — a TCP connect series whose last attempt // that end without an answer — a TCP connect series whose last attempt
// was not a local refusal, a bind() the kernel refused, or a refresh // was not a local refusal, a bind() the kernel refused, or a refresh
// that fails short of a refusal: unanswered, an unusable challenge, a // that fails short of a refusal: unanswered, a failed send (the resume
// failed send (the resume at start gives up after two unanswered // at start gives up after two unanswered
// connects). // connects).
// A fresh registration is the expensive step: an AKA run, a // A fresh registration is the expensive step: an AKA run, a
// new SA pair, and on KPN a refusal (spi-s=0) while the registration on // new SA pair, and on KPN a refusal (spi-s=0) while the registration on
@ -94,8 +105,10 @@ export namespace imsd::engine {
// due it also says the old registration is gone (KPN drops it the // due it also says the old registration is gone (KPN drops it the
// moment an initial REGISTER arrives, throttled or not), as does a // moment an initial REGISTER arrives, throttled or not), as does a
// fresh attempt that got past the SA setup, a binding the network // fresh attempt that got past the SA setup, a binding the network
// terminated, and a resume that failed at bring-up (OldGone) — no // terminated, and a bring-up resume that failed while a fresh
// reconnect over the old SA from then on. Every wait // registration follows (OldGone; with no fresh target a resumable pair
// is kept for the restart, §7.2 step 1) — no reconnect over the old SA
// from then on. Every wait
// is either the self-imposed backoff, which a user's Dial cuts short, // is either the self-imposed backoff, which a user's Dial cuts short,
// or the network's own word (a Retry-After, the schedule after a // or the network's own word (a Retry-After, the schedule after a
// refused or throttled fresh attempt, a terminated binding's probation // refused or throttled fresh attempt, a terminated binding's probation
@ -155,16 +168,18 @@ export namespace imsd::engine {
double freshHold = 0; double freshHold = 0;
int throttleMinutes = ThrottleMinutesMin; int throttleMinutes = ThrottleMinutesMin;
bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over
// a schedule that has advanced is kept too, until a success resets it, and so is the gone verdict int refusals = 0; // refused reconnects counted so far (RefusalsBeforeFresh): a restart's refusal is the next one, not the first
bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; } // a schedule that has advanced is kept too, until a success resets it, and so are the gone verdict and the count
bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone || refusals > 0; }
}; };
Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; } Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone, refusals}; }
// A restarted process takes them over; nothing here shortens a // A restarted process takes them over; nothing here shortens a
// hold it already has, or lengthens one past RetryAfterMax. // hold it already has, or lengthens one past RetryAfterMax.
void Restore(double now, const Held& h) { void Restore(double now, const Held& h) {
if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast<double>(RetryAfterMax))); if (h.hold > 0) holdUntil = std::max(holdUntil, now + std::min(h.hold, static_cast<double>(RetryAfterMax)));
if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast<double>(RetryAfterMax))); if (h.freshHold > 0) freshHoldUntil = std::max(freshHoldUntil, now + std::min(h.freshHold, static_cast<double>(RetryAfterMax)));
throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax); throttleMinutes = std::clamp(std::max(throttleMinutes, h.throttleMinutes), ThrottleMinutesMin, ThrottleMinutesMax);
refusals = std::max(refusals, h.refusals);
if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off
} }
@ -185,8 +200,9 @@ export namespace imsd::engine {
// A refused keepalive refresh, or a failed reconnect + refresh // A refused keepalive refresh, or a failed reconnect + refresh
// cycle; `status` is the refusal's. The backoff paces the next step; // cycle; `status` is the refusal's. The backoff paces the next step;
// a Retry-After holds every attempt for its own length (up to // a Retry-After of at least a second holds every attempt for its own
// RetryAfterMax), whichever is longer. A 403 once a fresh // length (up to RetryAfterMax), whichever is longer, and makes the
// next step the fresh registration, as does a 401. A 403 once a fresh
// registration is due says the old registration is gone (KPN drops // registration is due says the old registration is gone (KPN drops
// it the moment an initial REGISTER arrives, throttled or not). // it the moment an initial REGISTER arrives, throttled or not).
void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) { void OnReconnectFailure(double now, Failure f, long retryAfter = 0, int status = 0) {
@ -199,6 +215,7 @@ export namespace imsd::engine {
if (f == Failure::Refused) refusals++; if (f == Failure::Refused) refusals++;
else silentCycles++; else silentCycles++;
if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true; if (refusals >= RefusalsBeforeFresh || silentCycles >= SilentCyclesBeforeFresh) fresh = true;
if (f == Failure::Refused && (retryAfter > 0 || status == 401)) fresh = true;
if (status == 403 && fresh) oldGone = true; if (status == 403 && fresh) oldGone = true;
backoffUntil = now + reconnectDelay; backoffUntil = now + reconnectDelay;
reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax); reconnectDelay = std::min(reconnectDelay * 2, ReconnectDelayMax);
@ -265,12 +282,24 @@ export namespace imsd::engine {
// Parsed SDP answer needed to bring up the media leg. `codec` is one of // Parsed SDP answer needed to bring up the media leg. `codec` is one of
// imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play). // imsd-media's: "AMR-WB", "AMR", "PCMA", "PCMU" (empty = none we play).
// `dtmfPt` is the telephone-event payload type at the codec's clock
// (RFC 4733) the leg sends with — the peer's SDP's; nullopt = none
// negotiated, no DTMF on this call. `dtmfRxPt` is a second number the
// leg takes events at: our own SDP's at that clock — our offer's
// number on a call we placed (an answerer sends with the offer's
// numbers, RFC 3264 §6.1, and may renumber ours in the answer), the
// offer's we echoed on a call we answered; the answer's telephone-event
// number instead when the answer gave the audio our offer's number,
// which is then the leg's audio number. The leg takes events at
// either number, never at the audio's.
struct MediaLeg { struct MediaLeg {
std::string remoteIp; std::string remoteIp;
int remotePort = 0; int remotePort = 0;
int payloadType = -1; int payloadType = -1;
bool octetAlign = false; bool octetAlign = false;
std::string codec = "AMR-WB"; std::string codec = "AMR-WB";
std::optional<int> dtmfPt;
std::optional<int> dtmfRxPt;
bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; } bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; }
}; };
@ -280,6 +309,7 @@ export namespace imsd::engine {
SendResponse, // answer the just-handled inbound request on its origin channel SendResponse, // answer the just-handled inbound request on its origin channel
StartMedia, // bring up the media leg described by `media` StartMedia, // bring up the media leg described by `media`
StopMedia, // tear the media leg down StopMedia, // tear the media leg down
SendDtmf, // hand `text` (the tones) to the running media leg
State, // emit CallStateChanged(uni, `state`, `reason`) State, // emit CallStateChanged(uni, `state`, `reason`)
Deleted, // emit CallDeleted(uni); drop this machine Deleted, // emit CallDeleted(uni); drop this machine
SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline() SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline()
@ -293,6 +323,11 @@ export namespace imsd::engine {
double seconds = 0; double seconds = 0;
}; };
// The most tones one SendDtmf may carry (a keypad sends one; a dialer's
// string is a short sequence): the leg's `dtmf <tones>\n` line stays
// within one atomic pipe write, POSIX's 512-byte PIPE_BUF floor.
inline constexpr std::size_t DtmfMaxTones = 128;
// ring/answer budget, and how long to wait for a 487 after CANCEL — the // ring/answer budget, and how long to wait for a 487 after CANCEL — the
// imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants. // imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants.
inline constexpr double InviteTimeout = 90.0; inline constexpr double InviteTimeout = 90.0;
@ -420,7 +455,7 @@ export namespace imsd::engine {
spec.payloadType = leg.payloadType; spec.payloadType = leg.payloadType;
spec.octetAlign = leg.octetAlign; spec.octetAlign = leg.octetAlign;
spec.codec = leg.codec; spec.codec = leg.codec;
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec)); spec.dtmfPt = leg.dtmfPt;
localSdp_ = imsd::sdp::BuildAnswer(spec); localSdp_ = imsd::sdp::BuildAnswer(spec);
// session timers: echo the caller's Session-Expires; with no // session timers: echo the caller's Session-Expires; with no
// refresher stated, make the caller (uac) the refresher — its // refresher stated, make the caller (uac) the refresher — its
@ -593,6 +628,45 @@ export namespace imsd::engine {
return a; return a;
} }
// The user pressed keys: the tones go to the media leg as RFC 4733
// events on an active call whose SDP negotiated telephone-event at
// the codec's clock; otherwise they are dropped with a line that
// says why — there is no second method (no SIP INFO).
// Upper and lower case letters are one event; anything but 0-9, *,
// # and A-D drops the whole string rather than sending part of
// what was pressed, and so does a string past DtmfMaxTones — a
// keypad sends one key, a dialer's string a short sequence; the
// bound keeps the leg's control line within one atomic pipe write
// (PIPE_BUF), so a line is sent whole or not at all.
std::vector<Action> OnDtmf(std::string_view tones) {
std::vector<Action> a;
if (tones.size() > DtmfMaxTones) {
a.push_back(LogAct(std::format("call {}: DTMF of {} tones dropped: more than {}", uni_, tones.size(), DtmfMaxTones)));
return a;
}
std::string sent;
for (char c : tones) {
const char u = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
if (!((u >= '0' && u <= '9') || u == '*' || u == '#' || (u >= 'A' && u <= 'D'))) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: '{}' is not a tone", uni_, tones, c)));
return a;
}
sent += u;
}
if (sent.empty()) return a;
if (state_ != CallState::Active) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: the call is {}", uni_, sent, engine::StateStr(state_))));
return a;
}
if (!dtmfPt_) {
a.push_back(LogAct(std::format("call {}: DTMF '{}' dropped: no telephone-event negotiated for this call", uni_, sent)));
return a;
}
a.push_back(LogAct(std::format("call {}: DTMF '{}' -> media leg (telephone-event pt {})", uni_, sent, *dtmfPt_)));
a.push_back(Action{Action::Type::SendDtmf, sent});
return a;
}
// The shell is abandoning this machine (an emergency dial preempts // The shell is abandoning this machine (an emergency dial preempts
// it while it waits on a CANCEL's 487): terminate bookkeeping only — // it while it waits on a CANCEL's 487): terminate bookkeeping only —
// the CANCEL already sent stands, and the late final for this // the CANCEL already sent stands, and the late final for this
@ -672,6 +746,7 @@ export namespace imsd::engine {
bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline
std::set<std::string> pracked_; std::set<std::string> pracked_;
std::string answerSdp_; std::string answerSdp_;
std::optional<int> dtmfPt_; // the running leg's telephone-event pt, set at media start
// Fresh dialog identity for the current attempt. A fallback INVITE // Fresh dialog identity for the current attempt. A fallback INVITE
// after a final response is a new transaction AND a clean new dialog // after a final response is a new transaction AND a clean new dialog
@ -764,6 +839,13 @@ export namespace imsd::engine {
leg.payloadType = ans.payloadType; leg.payloadType = ans.payloadType;
leg.octetAlign = ans.octetAlign; leg.octetAlign = ans.octetAlign;
leg.codec = ans.codec; leg.codec = ans.codec;
leg.dtmfPt = ans.dtmfPt;
// the second number the leg takes events at: our offer's on a call
// we placed — the answer's instead when the answer gave the audio
// that very number, which is then the leg's audio number
const int offered = imsd::sdp::OfferedTelephoneEventPt(imsd::sdp::CodecRate(leg.codec));
leg.dtmfRxPt = !leg.dtmfPt ? std::nullopt
: std::optional<int>(direction_ == Direction::Outgoing && offered != leg.payloadType ? offered : *leg.dtmfPt);
return leg; return leg;
} }
@ -805,6 +887,7 @@ export namespace imsd::engine {
return a; return a;
} }
Action StartMediaAct(const MediaLeg& leg) { Action StartMediaAct(const MediaLeg& leg) {
dtmfPt_ = leg.dtmfPt;
Action a{Action::Type::StartMedia}; Action a{Action::Type::StartMedia};
a.media = leg; a.media = leg;
return a; return a;

View file

@ -13,9 +13,20 @@ alg (HMAC-SHA-1-96 or HMAC-MD5-96) keyed with IK; confidentiality is the
negotiated ealg keyed with CK. Both come from the Security-Server mechanism negotiated ealg keyed with CK. Both come from the Security-Server mechanism
the P-CSCF selected in its 401 (parsed here), and this module turns the the P-CSCF selected in its 401 (parsed here), and this module turns the
negotiated SPIs/ports/keys into the exact argv lists the daemon shell hands to negotiated SPIs/ports/keys into the exact argv lists the daemon shell hands to
`ip`; for a warm resume it reconstructs the SPIs, ports, and Security-Server `ip`; for a warm resume it reconstructs the SPIs and ports back out of `ip
value back out of `ip xfrm state`/`policy` text so a refresh re-REGISTER can xfrm state`/`policy` text — the P-CSCF ports for the resume's flow, and the
advertise the SPIs actually in use. SPIs in use, which a reregistration's new SPIs must differ from (and the
state file falls back to) — and a one-mechanism Security-Server value that
stands in for the Security-Verify when the state file holds no list for the
SA pair in the kernel (`ChooseResumeSecurityVerify` decides; since 0.3.12
the 401's own list is stored there). Since 1.0.0 also `SaPeer`: the P-CSCF
the kernel's pair is to, read from the `dir out` policies that carry imsd's
fingerprint; `OwnXfrmDeleteCommands`, the deletes by exact key of what two
rules select — the policies carrying imsd's fingerprint and the reqid-1/2
transport-mode ESP states in their direction or on the UE's ims-PDN address,
whoever installed them (§12); and `FileNamesAnotherPair`, whether a state
file's spi_uc/spi_us name the pair the kernel holds (unknown on either side
decides nothing).
Pure std C++ (the shell runs the commands and captures the text). Command Pure std C++ (the shell runs the commands and captures the text). Command
shapes and the parser are pinned by tests/Ipsec. shapes and the parser are pinned by tests/Ipsec.
@ -110,9 +121,11 @@ export namespace imsd::ipsec {
} }
} }
// The full `ip xfrm` argv sequence to install a fresh SA pair: flush // The full `ip xfrm` argv sequence to install a fresh SA pair: the four
// state+policy, add the four ESP states, add the four transport policies. // ESP states, then the four transport policies — no flush: the caller
// Order matches imscall.setup_sa exactly. Callers validate p.alg with // takes imsd's previous entries out first (OwnXfrmDeleteCommands), so
// another IPsec user's stay. Order otherwise matches imscall.setup_sa.
// Callers validate p.alg with
// KernelAuth() first; an unknown alg goes to the kernel by its own name // KernelAuth() first; an unknown alg goes to the kernel by its own name
// and is rejected there, never mapped to a default. // and is rejected there, never mapped to a default.
std::vector<std::vector<std::string>> BuildSetupCommands(const SaParams& p) { std::vector<std::vector<std::string>> BuildSetupCommands(const SaParams& p) {
@ -123,8 +136,6 @@ export namespace imsd::ipsec {
std::vector<std::string> enc = detail::EncArgs(p); std::vector<std::string> enc = detail::EncArgs(p);
int plen = imsd::util::Is6(local) ? 128 : 32; int plen = imsd::util::Is6(local) ? 128 : 32;
std::vector<std::vector<std::string>> cmds; std::vector<std::vector<std::string>> cmds;
cmds.push_back({"ip", "xfrm", "state", "flush"});
cmds.push_back({"ip", "xfrm", "policy", "flush"});
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, auth, ikx, enc)); cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, auth, ikx, enc));
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, auth, ikx, enc)); cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, auth, ikx, enc));
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, auth, ikx, enc)); cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, auth, ikx, enc));
@ -136,10 +147,6 @@ export namespace imsd::ipsec {
return cmds; return cmds;
} }
std::vector<std::vector<std::string>> BuildTeardownCommands() {
return {{"ip", "xfrm", "state", "flush"}, {"ip", "xfrm", "policy", "flush"}};
}
// ---- Security-Server (RFC 3329 §2.2) ----------------------------------- // ---- Security-Server (RFC 3329 §2.2) -----------------------------------
// //
// The P-CSCF answers the challenge with the mechanisms it supports, each // The P-CSCF answers the challenge with the mechanisms it supports, each
@ -306,10 +313,10 @@ export namespace imsd::ipsec {
// What a warm SA in the kernel tells us — enough for a refresh re-REGISTER // What a warm SA in the kernel tells us — enough for a refresh re-REGISTER
// to re-bind without re-authenticating. // to re-bind without re-authenticating.
struct ExistingSa { struct ExistingSa {
std::string securityServer; // reconstructed Security-Server header value std::string securityServer; // one-mechanism Security-Server value: the resume's Security-Verify when the state file holds no list for this pair
int portPs = 0; int portPs = 0;
int portPc = 0; int portPc = 0;
std::uint32_t spiUc = 0; // our inbound client SPI (advertise on refresh) std::uint32_t spiUc = 0; // our inbound client SPI (a reregistration offers a different one)
std::uint32_t spiUs = 0; // our inbound server SPI std::uint32_t spiUs = 0; // our inbound server SPI
std::uint32_t spiPs = 0; std::uint32_t spiPs = 0;
std::uint32_t spiPc = 0; std::uint32_t spiPc = 0;
@ -363,6 +370,21 @@ export namespace imsd::ipsec {
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt; if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
return v; return v;
} }
// A policy block of imsd's own: transport mode, reqid 1 or 2, and the
// fixed protected port on the UE side — sport PortUc/PortUs on `dir
// out`, dport on `dir in` (BuildSetupCommands' PolicyAdd shape). Reqid
// and mode alone are anyone's (L2TP/IPsec, a host-to-host SA); the
// ports are the fingerprint. The one predicate every reader of the
// kernel's policy text uses.
inline bool OwnPolicy(std::string_view b) {
auto reqid = ParseDec(TokenAfter(b, "reqid "));
if (!reqid || (*reqid != 1 && *reqid != 2) || b.find("mode transport") == std::string_view::npos) return false;
const int port = *reqid == 1 ? imsd::util::PortUc : imsd::util::PortUs;
if (b.find("dir out") != std::string_view::npos) return ParseDec(TokenAfter(b, "sport ")) == port;
if (b.find("dir in") != std::string_view::npos) return ParseDec(TokenAfter(b, "dport ")) == port;
return false;
}
} }
// Reconstruct SA facts from `ip xfrm state` + `ip xfrm policy` text. // Reconstruct SA facts from `ip xfrm state` + `ip xfrm policy` text.
@ -370,26 +392,38 @@ export namespace imsd::ipsec {
// state: (src==LOCAL,dst==P): reqid1->spiPs reqid2->spiPc // state: (src==LOCAL,dst==P): reqid1->spiPs reqid2->spiPc
// (src==P,dst==LOCAL): reqid1->spiUc reqid2->spiUs // (src==P,dst==LOCAL): reqid1->spiUc reqid2->spiUs
// policy: dir out, reqid1->portPs=dport, reqid2->portPc=dport // policy: dir out, reqid1->portPs=dport, reqid2->portPc=dport
// Addresses are compared in RFC 5952 form on both sides (the kernel prints
// that form; PCSCF=/LOCAL= arrive in any spelling), so the spelling in
// /etc/imsd.env can no longer hide a registration's SAs.
// Returns nullopt unless spiPs, spiPc, portPs, portPc are all found. // Returns nullopt unless spiPs, spiPc, portPs, portPc are all found.
std::optional<ExistingSa> ParseExistingSa(std::string_view stateText, std::string_view policyText, std::string_view pcscf, std::string_view local) { std::optional<ExistingSa> ParseExistingSa(std::string_view stateText, std::string_view policyText, std::string_view pcscf, std::string_view local) {
const std::string p = imsd::util::CanonicalAddr(pcscf);
const std::string l = imsd::util::CanonicalAddr(local);
ExistingSa sa; ExistingSa sa;
bool havePs = false; bool havePs = false;
bool havePc = false; bool havePc = false;
bool havePortPs = false; bool havePortPs = false;
bool havePortPc = false; bool havePortPc = false;
for (std::string_view b : detail::SrcBlocks(stateText)) { for (std::string_view b : detail::SrcBlocks(stateText)) {
std::string_view src = detail::TokenAfter(b, "src "); const std::string src = imsd::util::CanonicalAddr(detail::TokenAfter(b, "src "));
std::string_view dst = detail::TokenAfter(b, "dst "); const std::string dst = imsd::util::CanonicalAddr(detail::TokenAfter(b, "dst "));
auto spi = detail::ParseHex32(detail::TokenAfter(b, "spi ")); auto spi = detail::ParseHex32(detail::TokenAfter(b, "spi "));
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid ")); auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
if (!spi || !reqid) continue; if (!spi || !reqid) continue;
if (src == local && dst == pcscf && *reqid == 1) { sa.spiPs = *spi; havePs = true; } if (src == l && dst == p && *reqid == 1) { sa.spiPs = *spi; havePs = true; }
if (src == local && dst == pcscf && *reqid == 2) { sa.spiPc = *spi; havePc = true; } if (src == l && dst == p && *reqid == 2) { sa.spiPc = *spi; havePc = true; }
if (src == pcscf && dst == local && *reqid == 1) sa.spiUc = *spi; if (src == p && dst == l && *reqid == 1) sa.spiUc = *spi;
if (src == pcscf && dst == local && *reqid == 2) sa.spiUs = *spi; if (src == p && dst == l && *reqid == 2) sa.spiUs = *spi;
} }
for (std::string_view b : detail::SrcBlocks(policyText)) { for (std::string_view b : detail::SrcBlocks(policyText)) {
if (b.find("dir out") == std::string_view::npos) continue; if (b.find("dir out") == std::string_view::npos || !detail::OwnPolicy(b)) continue;
// the ports come from our own pair — a policy of imsd's own
// (detail::OwnPolicy) between our two addresses — never from a
// foreign policy that happens to carry ports
std::string_view srcTok = detail::TokenAfter(b, "src ");
std::string_view dstTok = detail::TokenAfter(b, "dst ");
if (imsd::util::CanonicalAddr(srcTok.substr(0, srcTok.find('/'))) != l) continue;
if (imsd::util::CanonicalAddr(dstTok.substr(0, dstTok.find('/'))) != p) continue;
auto dport = detail::ParseDec(detail::TokenAfter(b, "dport ")); auto dport = detail::ParseDec(detail::TokenAfter(b, "dport "));
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid ")); auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
if (!dport || !reqid) continue; if (!dport || !reqid) continue;
@ -401,16 +435,144 @@ export namespace imsd::ipsec {
return sa; return sa;
} }
// The Security-Verify a resume sends, and where it came from: the state
// file's list when the entry carrying its SPIs names the SA pair the
// kernel holds (a file an attempt the process did not live to record
// left behind names another pair), else the one-mechanism entry rebuilt
// from the SA — for an empty or unparseable value too.
struct ResumeSecurityVerify {
enum class Source { Stored, NoneStored, OtherSa };
Source source = Source::NoneStored;
std::string value;
std::size_t mechanisms = 0; // of the stored list, when it is used
std::optional<std::uint32_t> storedSpiPs; // the stored list's spi-s (OtherSa); nullopt = none parsed
};
ResumeSecurityVerify ChooseResumeSecurityVerify(std::string_view stored, const ExistingSa& sa, std::string_view offeredEalg) {
ResumeSecurityVerify r;
r.value = sa.securityServer;
if (stored.empty()) return r;
r.source = ResumeSecurityVerify::Source::OtherSa;
if (auto parsed = ParseSecurityServer(stored, "hmac-sha-1-96", offeredEalg)) {
const SecurityMechanism& c = parsed->Carrier();
r.storedSpiPs = c.spiPs;
if (c.spiPc == sa.spiPc && c.spiPs == sa.spiPs && c.portPc == sa.portPc && c.portPs == sa.portPs) {
r.source = ResumeSecurityVerify::Source::Stored;
r.value = std::string(stored);
r.mechanisms = parsed->mechanisms.size();
}
}
return r;
}
// Whether a state file was written for the SA pair the kernel holds: the
// file's spi_uc/spi_us (the UE's inbound SPIs — drawn per fresh attempt,
// kept by a re-REGISTER, written back by a resume) against the pair's. A
// file another attempt left behind — one that got through the SA setup
// and did not live to record, to this or another P-CSCF — names another
// pair, and its dialog is not this pair's. Unknown on either side (a
// file from before the keys, a 0 from a missing inbound state) decides
// nothing.
bool FileNamesAnotherPair(std::optional<long> fileUc, std::optional<long> fileUs, std::uint32_t saUc, std::uint32_t saUs) {
if (!fileUc || !fileUs || *fileUc <= 0 || *fileUs <= 0 || saUc == 0 || saUs == 0) return false;
return static_cast<std::uint32_t>(*fileUc) != saUc || static_cast<std::uint32_t>(*fileUs) != saUs;
}
// Every local address holding an SA to the P-CSCF, once each, in kernel // Every local address holding an SA to the P-CSCF, once each, in kernel
// order: where a registration made on another address of the link (the // order: where a registration made on another address of the link (the
// kernel's temporary one, before 0.3.10 — ims ledger F18) still lives. // kernel's temporary one, before 0.3.10 — ims ledger F18) still lives.
std::vector<std::string> SaLocals(std::string_view stateText, std::string_view pcscf) { std::vector<std::string> SaLocals(std::string_view stateText, std::string_view pcscf) {
const std::string p = imsd::util::CanonicalAddr(pcscf);
std::vector<std::string> locals; std::vector<std::string> locals;
for (std::string_view b : detail::SrcBlocks(stateText)) { for (std::string_view b : detail::SrcBlocks(stateText)) {
std::string_view src = detail::TokenAfter(b, "src "); const std::string src = imsd::util::CanonicalAddr(detail::TokenAfter(b, "src "));
if (src.empty() || detail::TokenAfter(b, "dst ") != pcscf) continue; if (src.empty() || imsd::util::CanonicalAddr(detail::TokenAfter(b, "dst ")) != p) continue;
if (std::ranges::find(locals, src) == locals.end()) locals.emplace_back(src); if (std::ranges::find(locals, src) == locals.end()) locals.push_back(src);
} }
return locals; return locals;
} }
// The P-CSCF the kernel's SAs are to: the one address that a `dir out`
// policy of imsd's own with reqid 1 and one with reqid 2 both name
// (detail::OwnPolicy: transport mode, the fixed protected port as the
// selector's sport — reqid and mode alone are anyone's) — exactly the pair
// BuildSetupCommands installs. The state text cannot
// tell the two ends apart (the UE is a `dst` there too). A foreign policy
// beside the pair (a VPN's: tunnel mode, another destination, whatever
// its reqid) changes nothing; a lone policy is not a registration, and
// two addresses each carrying both reqids are not one pair to tell
// apart: nullopt then, and when there are none.
std::optional<std::string> SaPeer(std::string_view policyText) {
std::vector<std::string> r1;
std::vector<std::string> r2;
for (std::string_view b : detail::SrcBlocks(policyText)) {
if (b.find("dir out") == std::string_view::npos || !detail::OwnPolicy(b)) continue;
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
std::string_view dstTok = detail::TokenAfter(b, "dst ");
const std::string dst = imsd::util::CanonicalAddr(dstTok.substr(0, dstTok.find('/')));
if (dst.empty()) continue;
std::vector<std::string>& set = *reqid == 1 ? r1 : r2;
if (std::ranges::find(set, dst) == set.end()) set.push_back(dst);
}
std::optional<std::string> peer;
for (const std::string& d : r1) {
if (std::ranges::find(r2, d) == r2.end()) continue;
if (peer) return std::nullopt;
peer = d;
}
return peer;
}
// The `ip xfrm … delete` argv lists that take the entries two rules select
// out of the kernel as the two texts show it: every policy
// of imsd's own (detail::OwnPolicy — transport mode, reqid 1 or 2, the
// fixed protected port on the UE side: the ports BuildSetupCommands
// installs), by the selector `ip xfrm policy` prints (src, dst, proto,
// sport, dport, dev) and its direction; and every transport-mode ESP state
// with reqid 1 or 2 running from such a policy's source to its destination,
// or anchored on `local`, the UE's ims-PDN address, by src, dst and SPI —
// policy or not: a setup whose policy add failed leaves such states, and
// an ESP one another transport-mode user put on that address with reqid 1
// or 2 goes with them (§12; an AH or IPComp state is not selected).
// Everything else — a policy-based
// VPN's tunnel entries, another transport-mode user's on other ports and
// addresses — stays: the fresh registration's SA setup runs these before
// BuildSetupCommands' adds, the dead pair's teardown (RemoveDeadPair,
// main.cpp) runs them alone, and neither flushes (§12). Residual: a
// transport-mode policy with reqid 1 and the UE-side port 45061, or reqid
// 2 and 45062, alone or paired, carries the fingerprint and goes with the
// reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2
// transport-mode ESP state on `local` goes whoever made it (tests/Ipsec).
std::vector<std::vector<std::string>> OwnXfrmDeleteCommands(std::string_view stateText, std::string_view policyText, std::string_view local) {
auto addr = [](std::string_view tok) { return imsd::util::CanonicalAddr(tok.substr(0, tok.find('/'))); };
const std::string l = imsd::util::CanonicalAddr(local);
std::vector<std::pair<std::string, std::string>> pairs; // (src, dst) of imsd's policies
std::vector<std::vector<std::string>> policyDels;
for (std::string_view b : detail::SrcBlocks(policyText)) {
if (!detail::OwnPolicy(b)) continue;
const std::string_view src = detail::TokenAfter(b, "src ");
const std::string_view dst = detail::TokenAfter(b, "dst ");
pairs.emplace_back(addr(src), addr(dst));
// the selector line as `ip xfrm policy` prints it: src dst [proto] [sport] [dport] [dev] — every token
// present goes into the delete (the tmpl line's `proto esp` is not the selector's), none is emitted empty
const std::string_view sel = b.substr(0, b.find('\n'));
std::vector<std::string> del{"ip", "xfrm", "policy", "delete", "src", std::string(src), "dst", std::string(dst)};
for (const char* key : {"proto ", "sport ", "dport ", "dev "})
if (std::string_view v = detail::TokenAfter(sel, key); !v.empty()) { del.emplace_back(key, std::strlen(key) - 1); del.emplace_back(v); }
del.emplace_back("dir"); del.emplace_back(detail::TokenAfter(b, "dir "));
policyDels.push_back(std::move(del));
}
std::vector<std::vector<std::string>> cmds;
for (std::string_view b : detail::SrcBlocks(stateText)) {
if (b.find("proto esp ") == std::string_view::npos) continue; // the delete names proto esp: an AH or IPComp state is not this rule's
if (b.find("reqid 1 mode transport") == std::string_view::npos && b.find("reqid 2 mode transport") == std::string_view::npos) continue;
const std::string_view src = detail::TokenAfter(b, "src ");
const std::string_view dst = detail::TokenAfter(b, "dst ");
bool mine = !l.empty() && (addr(src) == l || addr(dst) == l);
for (const auto& p : pairs) if (p.first == addr(src) && p.second == addr(dst)) { mine = true; break; }
if (!mine) continue;
cmds.push_back({"ip", "xfrm", "state", "delete", "src", std::string(src), "dst", std::string(dst), "proto", "esp", "spi", std::string(detail::TokenAfter(b, "spi "))});
}
cmds.insert(cmds.end(), policyDels.begin(), policyDels.end());
return cmds;
}
} }

View file

@ -29,8 +29,9 @@ import :Sip;
export namespace imsd::msg { export namespace imsd::msg {
// Everything the builders need that is stable for the life of a // Everything the builders need that is stable for the life of a
// registration. Ports default to the fixed protected values; route/ppi/ // registration. Ports default to the fixed protected values; route and
// securityServer are filled in from the REGISTER 200 OK. // ppi are filled in from the REGISTER 200 OK, securityServer from the
// 401 (at a resume, from the state file).
struct Context { struct Context {
imsd::aka::Identity id; imsd::aka::Identity id;
std::string local; std::string local;
@ -63,7 +64,7 @@ export namespace imsd::msg {
// self-targeted requests like reg-event // self-targeted requests like reg-event
// SUBSCRIBE; the temporary (IMSI-derived) // SUBSCRIBE; the temporary (IMSI-derived)
// IMPU is barred for anything but REGISTER // IMPU is barred for anything but REGISTER
std::string securityServer; // Security-Verify value (the network's ss) std::string securityServer; // Security-Verify value: the 401's Security-Server list (from the state file on a resume)
// P-Access-Network-Info value (imsd::aka::ParsePani from live mmcli // P-Access-Network-Info value (imsd::aka::ParsePani from live mmcli
// location: real MCC/MNC/TAC/ECI, FDD). Empty omits the header — // location: real MCC/MNC/TAC/ECI, FDD). Empty omits the header —
// never fabricate one: the network reads it to learn which access // never fabricate one: the network reads it to learn which access
@ -88,9 +89,15 @@ export namespace imsd::msg {
return std::format("Via: SIP/2.0/{} {};branch=z9hG4bK{};rport", transport, imsd::util::HostPort(c.local, port), branch); return std::format("Via: SIP/2.0/{} {};branch=z9hG4bK{};rport", transport, imsd::util::HostPort(c.local, port), branch);
} }
// Security-Client value carrying our in-use client SPIs + protected ports. // Security-Client value: the SPIs and protected ports offered for the SA
// pairs this REGISTER may set up — the in-use ones on an initial
// registration, new SPIs and a new client port on a reregistration
// (TS 24.229 5.1.1.4.2 d; TS 33.203 7.4.0: the server port stays).
inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs, int portUc, int portUs) {
return std::format("Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg={}; " "spi-c={}; spi-s={}; port-c={}; port-s={}", c.ealgOffer, spiUc, spiUs, portUc, portUs);
}
inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs) { inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs) {
return std::format("Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg={}; " "spi-c={}; spi-s={}; port-c={}; port-s={}", c.ealgOffer, spiUc, spiUs, c.portUc, c.portUs); return SecurityClient(c, spiUc, spiUs, c.portUc, c.portUs);
} }
// Authorization line with an empty (unchallenged) digest — the first // Authorization line with an empty (unchallenged) digest — the first
@ -217,9 +224,17 @@ export namespace imsd::msg {
// The stock oracle sends it in BOTH REGISTERs (2026-07-21 capture, // The stock oracle sends it in BOTH REGISTERs (2026-07-21 capture,
// reg2 build at 19:30:44); an earlier reading that reg2 omits it was // reg2 build at 19:30:44); an earlier reading that reg2 omits it was
// wrong and cost us all terminating delivery. `authLine` is a full // wrong and cost us all terminating delivery. `authLine` is a full
// Authorization line (AuthEmpty for the refresh's first try, AuthAka // Authorization line: AuthAka with the challenge's answer for the
// once challenged). // initial registration's second REGISTER and, verbatim, for every
std::string BuildRegisterProtected(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, int cseq, std::uint32_t spiUc, std::uint32_t spiUs, std::string_view authLine) { // reregistration after it (TS 24.229 5.1.1.4.2 a: the last received
// nonce, the last calculated response); AuthEmpty for a registration
// resumed from a pre-0.3.11 state file, on every reregistration until
// its next fresh registration. A
// reregistration also passes NEW SPIs and the client port it proposes
// for a re-authentication's SA pairs (`portUc`, TS 33.203 7.4.0: the
// server port stays); 0 offers the in-use client port, the initial
// registration's case.
std::string BuildRegisterProtected(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, int cseq, std::uint32_t spiUc, std::uint32_t spiUs, std::string_view authLine, int portUc = 0) {
std::vector<std::string> l = { std::vector<std::string> l = {
std::format("REGISTER {} SIP/2.0", c.id.regUri), std::format("REGISTER {} SIP/2.0", c.id.regUri),
ViaLine(c, c.transport, c.portUs, branch), ViaLine(c, c.transport, c.portUs, branch),
@ -233,7 +248,7 @@ export namespace imsd::msg {
std::string(authLine), std::string(authLine),
"Require: sec-agree", "Require: sec-agree",
"Proxy-Require: sec-agree", "Proxy-Require: sec-agree",
SecurityClient(c, spiUc, spiUs), SecurityClient(c, spiUc, spiUs, portUc ? portUc : c.portUc, c.portUs),
std::format("Security-Verify: {}", c.securityServer), std::format("Security-Verify: {}", c.securityServer),
}; };
l.push_back("Supported: sec-agree, path"); l.push_back("Supported: sec-agree, path");

260
interfaces/Imsd-Qmi.cppm Normal file
View file

@ -0,0 +1,260 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Qmi — the bytes of the QMI WDS exchanges imsd-pcscf has with the modem
to learn the ims PDN's P-CSCF list, and the readers of the answers.
Over QRTR a QMI message is {type u8, transaction u16, message u16, length
u16} followed by TLVs {type u8, length u16, value}, integers little endian.
The modem hands the P-CSCF addresses the network put in the PDN's Protocol
Configuration Options to a WDS client bound to the data port that carries the
call (Bind Mux Data Port, Set IP Family, Get Current Settings) — in the TLVs
libqmi models (0x22 the flag, 0x23 the IPv4 list, 0x24 the FQDN list) and in
one it does not: 0x2E, the IPv6 list, Qualcomm's layout as read off the
Fairphone 6 (a count byte, then 16 bytes per address, PCO order). The socket,
the service lookup and the sequence are the helper's
(implementations/pcscf.cpp); this partition only shapes bytes and reads
answers, pinned by tests/Qmi on a response in the exact shape the modem
returned, addresses re-encoded to the documentation prefix.
*/
export module Imsd:Qmi;
import std;
import :Util;
export namespace imsd::qmi {
constexpr std::uint32_t WdsService = 1;
constexpr std::uint8_t TypeRequest = 0x00;
constexpr std::uint8_t TypeResponse = 0x02;
constexpr std::uint8_t TypeIndication = 0x04;
constexpr std::uint16_t MsgGetPacketServiceStatus = 0x0022;
constexpr std::uint16_t MsgGetProfileSettings = 0x002B;
constexpr std::uint16_t MsgGetCurrentSettings = 0x002D;
constexpr std::uint16_t MsgSetIpFamily = 0x004D;
constexpr std::uint16_t MsgBindMuxDataPort = 0x00A2;
// Get Current Settings "Requested Settings": every bit libqmi defines
// (0..18). The P-CSCF ones are 10 (address), 11 (server address list)
// and 12 (domain name list); the whole mask is what the probe used.
constexpr std::uint32_t RequestedSettingsAll = 0x7FFFF;
// The data endpoint of an IPA-attached modem as ModemManager binds it:
// type embedded (4), interface number 1.
constexpr std::uint32_t EndpointEmbedded = 4;
constexpr std::uint32_t EndpointInterfaceIpa = 1;
constexpr std::uint8_t IpFamilyV6 = 6;
constexpr std::uint8_t ProfileType3gpp = 0;
constexpr std::uint8_t PacketServiceConnected = 2;
constexpr std::uint16_t ErrOutOfCall = 15;
constexpr std::uint16_t ErrNoEffect = 26;
struct Tlv {
std::uint8_t type = 0;
std::vector<std::uint8_t> value;
};
namespace detail {
inline void Put16(std::vector<std::uint8_t>& b, std::uint16_t v) {
b.push_back(static_cast<std::uint8_t>(v & 0xFF));
b.push_back(static_cast<std::uint8_t>(v >> 8));
}
inline void Put32(std::vector<std::uint8_t>& b, std::uint32_t v) {
Put16(b, static_cast<std::uint16_t>(v & 0xFFFF));
Put16(b, static_cast<std::uint16_t>(v >> 16));
}
inline std::uint16_t Get16(std::span<const std::uint8_t> b, std::size_t at) {
return static_cast<std::uint16_t>(b[at] | (b[at + 1] << 8));
}
inline std::uint32_t Get32(std::span<const std::uint8_t> b, std::size_t at) {
return static_cast<std::uint32_t>(Get16(b, at)) | (static_cast<std::uint32_t>(Get16(b, at + 2)) << 16);
}
// RFC 5952 text of 16 address bytes (via the canonicaliser: the
// partition has no inet_ntop)
inline std::string Ip6Text(std::span<const std::uint8_t> a) {
std::string s;
for (std::size_t i = 0; i < 16; i += 2) s += std::format("{}{:x}", i ? ":" : "", (a[i] << 8) | a[i + 1]);
return imsd::util::CanonicalIp6(s).value_or(s);
}
}
inline std::vector<std::uint8_t> TlvBytes(std::uint8_t type, std::span<const std::uint8_t> value) {
std::vector<std::uint8_t> b;
b.push_back(type);
detail::Put16(b, static_cast<std::uint16_t>(value.size()));
b.insert(b.end(), value.begin(), value.end());
return b;
}
inline std::vector<std::uint8_t> Tlv8(std::uint8_t type, std::uint8_t v) {
std::array<std::uint8_t, 1> a{v};
return TlvBytes(type, a);
}
inline std::vector<std::uint8_t> Tlv32(std::uint8_t type, std::uint32_t v) {
std::vector<std::uint8_t> a;
detail::Put32(a, v);
return TlvBytes(type, a);
}
// A request: the header, then the TLVs as given.
inline std::vector<std::uint8_t> Request(std::uint16_t txn, std::uint16_t msg, std::span<const std::uint8_t> tlvs) {
std::vector<std::uint8_t> b;
b.push_back(TypeRequest);
detail::Put16(b, txn);
detail::Put16(b, msg);
detail::Put16(b, static_cast<std::uint16_t>(tlvs.size()));
b.insert(b.end(), tlvs.begin(), tlvs.end());
return b;
}
// Bind Mux Data Port: TLV 0x10 {endpoint type u32, interface number
// u32}, TLV 0x11 mux id u8 — the binding ModemManager gives its own
// bearer clients, which is what lets this client read their call.
inline std::vector<std::uint8_t> BindMuxDataPort(std::uint16_t txn, std::uint32_t epType, std::uint32_t epIface, std::uint8_t mux) {
std::vector<std::uint8_t> ep;
detail::Put32(ep, epType);
detail::Put32(ep, epIface);
std::vector<std::uint8_t> t = TlvBytes(0x10, ep);
std::vector<std::uint8_t> m = Tlv8(0x11, mux);
t.insert(t.end(), m.begin(), m.end());
return Request(txn, MsgBindMuxDataPort, t);
}
// Set IP Family: TLV 0x01 u8 (4 or 6) — which family's settings the
// client asks about.
inline std::vector<std::uint8_t> SetIpFamily(std::uint16_t txn, std::uint8_t family) {
return Request(txn, MsgSetIpFamily, Tlv8(0x01, family));
}
inline std::vector<std::uint8_t> GetPacketServiceStatus(std::uint16_t txn) {
return Request(txn, MsgGetPacketServiceStatus, {});
}
// Get Current Settings: TLV 0x10 the requested-settings mask u32.
inline std::vector<std::uint8_t> GetCurrentSettings(std::uint16_t txn, std::uint32_t mask) {
return Request(txn, MsgGetCurrentSettings, Tlv32(0x10, mask));
}
// Get Profile Settings: TLV 0x01 {profile type u8 (0 = 3GPP), index u8}.
inline std::vector<std::uint8_t> GetProfileSettings(std::uint16_t txn, std::uint8_t index) {
std::array<std::uint8_t, 2> id{ProfileType3gpp, index};
return Request(txn, MsgGetProfileSettings, TlvBytes(0x01, id));
}
struct Response {
std::uint8_t type = 0;
std::uint16_t txn = 0;
std::uint16_t msg = 0;
std::vector<Tlv> tlvs;
bool hasResult = false; // TLV 0x02 {result u16, error u16} present
std::uint16_t result = 0;
std::uint16_t error = 0;
bool Ok() const { return hasResult && result == 0; }
const Tlv* Find(std::uint8_t t) const {
for (const Tlv& x : tlvs) if (x.type == t) return &x;
return nullptr;
}
};
// A received message: the header and every TLV; nullopt when the buffer
// is shorter than its header says or a TLV overruns it.
inline std::optional<Response> ParseMessage(std::span<const std::uint8_t> b) {
if (b.size() < 7) return std::nullopt;
Response r;
r.type = b[0];
r.txn = detail::Get16(b, 1);
r.msg = detail::Get16(b, 3);
std::size_t len = detail::Get16(b, 5);
if (7 + len > b.size()) return std::nullopt;
std::size_t off = 7;
std::size_t end = 7 + len;
while (off + 3 <= end) {
Tlv t;
t.type = b[off];
std::size_t n = detail::Get16(b, off + 1);
if (off + 3 + n > end) return std::nullopt;
t.value.assign(b.begin() + static_cast<std::ptrdiff_t>(off + 3), b.begin() + static_cast<std::ptrdiff_t>(off + 3 + n));
if (t.type == 0x02 && n == 4) {
r.hasResult = true;
r.result = detail::Get16(t.value, 0);
r.error = detail::Get16(t.value, 2);
}
r.tlvs.push_back(std::move(t));
off += 3 + n;
}
if (off != end) return std::nullopt;
return r;
}
// The QMI protocol error names imsd-pcscf can meet (libqmi's nicks).
inline std::string_view ErrorName(std::uint16_t e) {
switch (e) {
case 0: return "None";
case 1: return "MalformedMessage";
case 3: return "Internal";
case 9: return "InvalidHandle";
case 10: return "InvalidProfile";
case 14: return "CallFailed";
case 15: return "OutOfCall";
case 17: return "MissingArgument";
case 23: return "DeviceInUse";
case 26: return "NoEffect";
case 46: return "GeneralError";
case 48: return "InvalidArgument";
case 71: return "InvalidQmiCommand";
case 82: return "AccessDenied";
default: return "?";
}
}
// What a Get Current Settings answer says about the P-CSCF.
struct PcscfLists {
bool viaPco = false; // TLV 0x22: the modem asked for P-CSCF addresses in the PCO
std::vector<std::string> ipv6; // TLV 0x2E, PCO order, canonical
std::vector<std::string> ipv4; // TLV 0x23, dotted quads
std::vector<std::string> fqdn; // TLV 0x24
};
// Tolerant: a list whose count does not match its length is read as
// empty, never as garbage.
inline PcscfLists ParsePcscfLists(const Response& r) {
PcscfLists out;
if (const Tlv* f = r.Find(0x22); f && f->value.size() == 1) out.viaPco = f->value[0] != 0;
if (const Tlv* t = r.Find(0x2E); t && !t->value.empty() && t->value.size() == 1 + 16 * static_cast<std::size_t>(t->value[0]))
for (std::size_t i = 0; i < t->value[0]; i++)
out.ipv6.push_back(detail::Ip6Text(std::span<const std::uint8_t>(t->value).subspan(1 + 16 * i, 16)));
if (const Tlv* t = r.Find(0x23); t && !t->value.empty() && t->value.size() == 1 + 4 * static_cast<std::size_t>(t->value[0]))
for (std::size_t i = 0; i < t->value[0]; i++) {
std::uint32_t v = detail::Get32(t->value, 1 + 4 * i); // little-endian u32, a.b.c.d = the high byte first
out.ipv4.push_back(std::format("{}.{}.{}.{}", v >> 24, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF));
}
if (const Tlv* t = r.Find(0x24); t && !t->value.empty()) {
std::vector<std::string> names;
std::size_t off = 1;
bool good = true;
for (std::size_t i = 0; i < t->value[0] && good; i++) {
if (off + 2 > t->value.size()) { good = false; break; }
std::size_t n = detail::Get16(t->value, off);
if (off + 2 + n > t->value.size()) { good = false; break; }
names.emplace_back(reinterpret_cast<const char*>(t->value.data() + off + 2), n);
off += 2 + n;
}
if (good && off == t->value.size()) out.fqdn = std::move(names);
}
return out;
}
// Get Packet Service Status: TLV 0x01 u8 (2 = connected) — whether the
// bound client sees the port's call.
inline std::optional<std::uint8_t> PacketServiceStatus(const Response& r) {
const Tlv* t = r.Find(0x01);
if (!t || t->value.size() != 1) return std::nullopt;
return t->value[0];
}
// Get Profile Settings: TLV 0x1F "PCSCF Address Using PCO" u8 — whether
// the profile has the modem ask the network at all.
inline std::optional<bool> ProfilePcscfViaPco(const Response& r) {
const Tlv* t = r.Find(0x1F);
if (!t || t->value.size() != 1) return std::nullopt;
return t->value[0] != 0;
}
}

View file

@ -9,7 +9,9 @@ The offer is by default an AMR-WB (octet-aligned) + AMR + telephone-event
shape with an optional GSMA IR.92 QoS-precondition block; it is what shape with an optional GSMA IR.92 QoS-precondition block; it is what
commercial IMS cores expect from a VoLTE UE, and its exact bytes are pinned by commercial IMS cores expect from a VoLTE UE, and its exact bytes are pinned by
tests/Sdp. The parser extracts only what the media plane needs: remote tests/Sdp. The parser extracts only what the media plane needs: remote
address/port, the negotiated payload type, the codec and octet-align mode — it address/port, the negotiated payload type, the codec and octet-align mode, and
the telephone-event payload type among the m-line's formats at that codec's
clock (DTMF, RFC 4733) — it
reads answers to our offers and inbound offers alike (same fields either way). reads answers to our offers and inbound offers alike (same fields either way).
The codecs it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband) The codecs it knows are the ones imsd-media can play: AMR-WB, AMR (narrowband)
and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a
@ -82,6 +84,11 @@ export namespace imsd::sdp {
std::vector<std::string> codecs; // offered codecs in order; empty = AMR-WB, AMR std::vector<std::string> codecs; // offered codecs in order; empty = AMR-WB, AMR
}; };
// The payload type our offer gives telephone-event at a clock rate: the
// number an answerer sends with (RFC 3264 §6.1), whatever the answer
// renumbered ours to.
int OfferedTelephoneEventPt(int clockRate) { return clockRate == 16000 ? 98 : 100; }
// Our offer: AMR-WB (octet-aligned) first, AMR narrowband second — the // Our offer: AMR-WB (octet-aligned) first, AMR narrowband second — the
// 3GPP-mandatory codec every IMS core and PSTN gateway can answer, so a // 3GPP-mandatory codec every IMS core and PSTN gateway can answer, so a
// call we place TO a landline no longer depends on the far side // call we place TO a landline no longer depends on the far side
@ -107,8 +114,8 @@ export namespace imsd::sdp {
s += "t=0 0\r\n"; s += "t=0 0\r\n";
std::string pts; std::string pts;
for (const auto& c : codecs) pts += std::format(" {}", pt(c)); for (const auto& c : codecs) pts += std::format(" {}", pt(c));
if (wb) pts += " 98"; if (wb) pts += std::format(" {}", OfferedTelephoneEventPt(16000));
if (nb) pts += " 100"; if (nb) pts += std::format(" {}", OfferedTelephoneEventPt(8000));
s += std::format("m=audio {} RTP/AVP{}\r\n", o.rtpPort, pts); s += std::format("m=audio {} RTP/AVP{}\r\n", o.rtpPort, pts);
s += std::format("b=AS:{}\r\n", wb ? 41 : amr ? 30 : 80); s += std::format("b=AS:{}\r\n", wb ? 41 : amr ? 30 : 80);
s += "b=RS:512\r\n"; s += "b=RS:512\r\n";
@ -125,12 +132,12 @@ export namespace imsd::sdp {
} }
} }
if (wb) { if (wb) {
s += "a=rtpmap:98 telephone-event/16000\r\n"; s += std::format("a=rtpmap:{} telephone-event/16000\r\n", OfferedTelephoneEventPt(16000));
s += "a=fmtp:98 0-15\r\n"; s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(16000));
} }
if (nb) { if (nb) {
s += "a=rtpmap:100 telephone-event/8000\r\n"; s += std::format("a=rtpmap:{} telephone-event/8000\r\n", OfferedTelephoneEventPt(8000));
s += "a=fmtp:100 0-15\r\n"; s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(8000));
} }
s += "a=ptime:20\r\n"; s += "a=ptime:20\r\n";
s += "a=maxptime:240\r\n"; s += "a=maxptime:240\r\n";
@ -150,22 +157,68 @@ export namespace imsd::sdp {
int payloadType = -1; int payloadType = -1;
bool octetAlign = false; bool octetAlign = false;
std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play) std::string codec = "AMR-WB"; // "AMR-WB" | "AMR" | "PCMA" | "PCMU" | "" (none we play)
std::optional<int> dtmfPt; // telephone-event at the codec's clock rate; nullopt = no DTMF
}; };
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
constexpr std::string_view Avp = " RTP/AVP "; // after the m=audio port: the one profile we speak
// The m=audio line's payload types in its order; empty without the line,
// its port or the " RTP/AVP " literal.
std::vector<int> AudioFormats(std::string_view body) {
std::vector<int> pts;
auto m = Find(body, "m=audio ");
if (!m) return pts;
std::string_view portTok = TokenAt(body, *m + 8);
std::size_t afterPort = *m + 8 + portTok.size();
if (!ParseInt(portTok) || !body.substr(afterPort).starts_with(Avp)) return pts;
// digits + spaces up to CR/LF
std::size_t ptsBegin = afterPort + Avp.size();
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
for (auto part : std::views::split(ptsRun, ' '))
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
return pts;
}
// A payload type's rtpmap encoding, `NAME/CLOCK[/CHANNELS]` upper-cased
// (RFC 4566 §6: encoding names are case-insensitive); empty without one.
std::string Rtpmap(std::string_view body, int pt) {
const std::string key = std::format("a=rtpmap:{} ", pt);
auto at = Find(body, key);
if (!at) return "";
std::size_t begin = *at + key.size();
std::size_t end = body.find_first_of("\r\n", begin);
std::string enc(body.substr(begin, end == std::string_view::npos ? std::string_view::npos : end - begin));
for (char& c : enc)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return enc;
}
// Payload type of `telephone-event/<clockRate>` among the m=audio line's
// formats (DTMF, RFC 4733); nullopt when the line lists none. An rtpmap
// the line does not list is not negotiated: nothing is sent with a format
// the answer does not carry (RFC 3264 §8.3.2). The clock is matched
// whole: `/8000` is not `/80000`.
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
const std::string needle = std::format("TELEPHONE-EVENT/{}", clockRate);
for (int pt : AudioFormats(body)) {
const std::string enc = Rtpmap(body, pt);
const bool whole = enc.size() == needle.size() || (enc.size() > needle.size() && (enc[needle.size()] == '/' || enc[needle.size()] == ' '));
if (enc.starts_with(needle) && whole) return pt;
}
return std::nullopt;
}
// The encoding name an offer gives a payload type: its a=rtpmap line, // The encoding name an offer gives a payload type: its a=rtpmap line,
// upper-cased (RFC 4566 §6: encoding names are case-insensitive), else // upper-cased (RFC 4566 §6: encoding names are case-insensitive), else
// the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry // the static assignment for 0/8 (RFC 3551 §6 — a G.711 offer may carry
// no rtpmap at all). Empty when neither applies. // no rtpmap at all). Empty when neither applies.
std::string CodecName(std::string_view body, int pt) { std::string CodecName(std::string_view body, int pt) {
std::string key = std::format("a=rtpmap:{} ", pt); const std::string enc = Rtpmap(body, pt);
if (auto at = Find(body, key)) { if (!enc.empty()) return enc.substr(0, enc.find('/'));
std::size_t nameBegin = *at + key.size();
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
std::string name(body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin));
for (char& c : name)
c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
return name;
}
if (pt == 0) return "PCMU"; if (pt == 0) return "PCMU";
if (pt == 8) return "PCMA"; if (pt == 8) return "PCMA";
return ""; return "";
@ -202,7 +255,10 @@ export namespace imsd::sdp {
// non-empty `prefs` replaces the codec order (and restricts acceptance // non-empty `prefs` replaces the codec order (and restricts acceptance
// to the codecs named) — the CODECS override. With no acceptable codec // to the codecs named) — the CODECS override. With no acceptable codec
// at all, the first pt is used and codec comes back EMPTY — pretending // at all, the first pt is used and codec comes back EMPTY — pretending
// otherwise would defeat codec checks upstream. // otherwise would defeat codec checks upstream. The telephone-event pt
// is the one at the chosen codec's clock (an event's rate is its rtpmap
// clock, RFC 4733 §2.4.1; an offer with two codec clocks lists
// telephone-event at each), so a tone goes out on the stream's own clock.
Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) { Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) {
Answer a; Answer a;
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9); if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
@ -214,17 +270,10 @@ export namespace imsd::sdp {
auto port = ParseInt(portTok); auto port = ParseInt(portTok);
// both the port and the " RTP/AVP " literal are required // both the port and the " RTP/AVP " literal are required
std::size_t afterPort = *m + 8 + portTok.size(); std::size_t afterPort = *m + 8 + portTok.size();
constexpr std::string_view Avp = " RTP/AVP ";
if (!port || !body.substr(afterPort).starts_with(Avp)) return a; if (!port || !body.substr(afterPort).starts_with(Avp)) return a;
a.port = *port; a.port = *port;
// payload-type list: digits + spaces up to CR/LF std::vector<int> pts = AudioFormats(body);
std::size_t ptsBegin = afterPort + Avp.size();
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
std::vector<int> pts;
for (auto part : std::views::split(ptsRun, ' '))
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
if (pts.empty()) return a; if (pts.empty()) return a;
auto hasOctetAlign = [&](int p) { auto hasOctetAlign = [&](int p) {
@ -261,28 +310,10 @@ export namespace imsd::sdp {
a.codec.clear(); a.codec.clear();
} }
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType); a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType);
if (!a.codec.empty()) a.dtmfPt = TelephoneEventPt(body, CodecRate(a.codec));
return a; return a;
} }
// Payload type of `telephone-event/<clockRate>` in a body's rtpmap lines
// (DTMF, RFC 4733); nullopt when the offer doesn't carry one.
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
std::string needle = std::format("telephone-event/{}", clockRate);
std::size_t pos = 0;
while ((pos = body.find("a=rtpmap:", pos)) != std::string_view::npos) {
std::size_t ptBegin = pos + 9;
std::size_t lineEnd = body.find_first_of("\r\n", ptBegin);
std::size_t sp = body.find(' ', ptBegin);
if (sp != std::string_view::npos && (lineEnd == std::string_view::npos || sp < lineEnd)) {
auto pt = ParseInt(body.substr(ptBegin, sp - ptBegin));
std::string_view name = body.substr(sp + 1, (lineEnd == std::string_view::npos ? body.size() : lineEnd) - sp - 1);
if (pt && name.starts_with(needle)) return pt;
}
pos = ptBegin;
}
return std::nullopt;
}
struct AnswerSpec { struct AnswerSpec {
std::string local; // connection address (v4 or v6) std::string local; // connection address (v4 or v6)
int rtpPort = 0; int rtpPort = 0;
@ -293,14 +324,11 @@ export namespace imsd::sdp {
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
}; };
// Sample rate of a codec we play: AMR-WB is 16 kHz, everything else
// (AMR, G.711) is narrowband 8 kHz. Also the RTP clock rate.
int CodecRate(std::string_view codec) { return codec == "AMR-WB" ? 16000 : 8000; }
// SDP answer to an inbound audio offer: single codec at the offer's own // SDP answer to an inbound audio offer: single codec at the offer's own
// payload type, octet-align echoed only when the offer used it (absence // payload type, octet-align echoed only when the offer used it (absence
// = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all), // = bandwidth-efficient, RFC 4867 §8.1; G.711 has no fmtp at all),
// telephone-event kept when offered. b=AS is the codec's peak payload // telephone-event kept when the offer's m-line lists one at the codec's
// clock (ParseAnswer's rule). b=AS is the codec's peak payload
// rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp. // rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp.
std::string BuildAnswer(const AnswerSpec& s) { std::string BuildAnswer(const AnswerSpec& s) {
std::string ipver = Is6(s.local) ? "IP6" : "IP4"; std::string ipver = Is6(s.local) ? "IP6" : "IP4";

View file

@ -4,8 +4,9 @@
// lint-disable-file fixed-width-types // lint-disable-file fixed-width-types
/* /*
Imsd:Util — small self-contained primitives the rest of the engine builds on: Imsd:Util — small self-contained primitives the rest of the engine builds on:
random SIP tokens, IPv6 detection + host/port formatting, hex/base64 decoding, random SIP tokens, IPv6 detection + host/port formatting, the RFC 5952
and MD5 (RFC 1321). None of the standard library ships these last three, and canonical IPv6 text and the address-list parser, hex/base64 decoding, and MD5
(RFC 1321). None of the standard library ships these last three, and
the IMS AKAv1-MD5 digest (Imsd:Aka) needs MD5 over raw bytes plus base64 to the IMS AKAv1-MD5 digest (Imsd:Aka) needs MD5 over raw bytes plus base64 to
unwrap the RAND||AUTN nonce, so they live here rather than pulling in a crypto unwrap the RAND||AUTN nonce, so they live here rather than pulling in a crypto
dependency. Pure std C++ (no GLib, no I/O); behavior is pinned by tests/Util. dependency. Pure std C++ (no GLib, no I/O); behavior is pinned by tests/Util.
@ -24,6 +25,12 @@ export namespace imsd::util {
// an installed package's nftables accept rules. // an installed package's nftables accept rules.
constexpr int PortUc = 45061; constexpr int PortUc = 45061;
constexpr int PortUs = 45062; constexpr int PortUs = 45062;
// The client port a reregistration proposes for the SA pairs a
// re-authentication would set up: TS 33.203 7.4.0 keeps the server
// ports (port_us, port_ps) and changes the client ports (port_uc,
// port_pc). imsd answers such a challenge with a fresh registration, so
// nothing is bound here and the firewall rules need not open it.
constexpr int PortUcNext = 45063;
// An address is IPv6 iff it contains a colon (matches the Python is6()). // An address is IPv6 iff it contains a colon (matches the Python is6()).
inline bool Is6(std::string_view addr) { return addr.contains(':'); } inline bool Is6(std::string_view addr) { return addr.contains(':'); }
@ -34,6 +41,114 @@ export namespace imsd::util {
: std::format("{}:{}", addr, port); : std::format("{}:{}", addr, port);
} }
// The RFC 5952 §4 text of an IPv6 address: lowercase hex, no leading
// zeros, the longest run of two or more zero fields as "::" (the first
// on a tie), never a single zero field. This is the form iproute2 and
// inet_ntop print, so addresses canonicalised here compare as text
// against `ip xfrm` output whatever spelling they arrived in (an
// upper-case or uncompressed PCSCF= in /etc/imsd.env never matched the
// kernel's text before). Surrounding brackets are accepted. nullopt for
// anything that is not a plain IPv6 literal: a zone ("%dev"), a "]:port",
// a dotted-quad tail ("::ffff:1.2.3.4" — RFC 5952 §5 keeps that dotted and
// an IPv6-only PDN never produces it; the same address spelled in hex,
// "::ffff:a00:1", is accepted and comes back in hex — §4 form, where
// inet_ntop prints it dotted), two "::", more than eight fields,
// a field over four digits, an IPv4 address, a name.
inline std::optional<std::string> CanonicalIp6(std::string_view s) {
if (s.size() >= 2 && s.front() == '[' && s.back() == ']') s = s.substr(1, s.size() - 2);
if (s.empty()) return std::nullopt;
auto nibble = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
for (char c : s) if (c != ':' && nibble(c) < 0) return std::nullopt;
// "a:b" fields of one part (no "::" inside); false on an empty or
// over-long field
auto fields = [&](std::string_view part, std::vector<std::uint16_t>& out) -> bool {
if (part.empty()) return true;
std::size_t start = 0;
while (true) {
std::size_t colon = part.find(':', start);
std::string_view f = part.substr(start, colon == std::string_view::npos ? std::string_view::npos : colon - start);
if (f.empty() || f.size() > 4) return false;
unsigned v = 0;
for (char c : f) v = v * 16 + static_cast<unsigned>(nibble(c));
out.push_back(static_cast<std::uint16_t>(v));
if (colon == std::string_view::npos) return true;
start = colon + 1;
}
};
std::vector<std::uint16_t> head;
std::vector<std::uint16_t> tail;
std::size_t dc = s.find("::");
if (dc == std::string_view::npos) {
if (!fields(s, head) || head.size() != 8) return std::nullopt;
} else {
if (s.find("::", dc + 1) != std::string_view::npos) return std::nullopt;
if (!fields(s.substr(0, dc), head) || !fields(s.substr(dc + 2), tail)) return std::nullopt;
if (head.size() + tail.size() > 7) return std::nullopt;
}
std::array<std::uint16_t, 8> g{};
std::copy(head.begin(), head.end(), g.begin());
std::copy(tail.begin(), tail.end(), g.end() - static_cast<std::ptrdiff_t>(tail.size()));
int bestStart = -1;
int bestLen = 1; // a run must beat one field to be compressed
for (int i = 0; i < 8;) {
if (g[static_cast<std::size_t>(i)] != 0) { i++; continue; }
int j = i;
while (j < 8 && g[static_cast<std::size_t>(j)] == 0) j++;
if (j - i > bestLen) { bestStart = i; bestLen = j - i; }
i = j;
}
std::string out;
for (int i = 0; i < 8;) {
if (i == bestStart) {
out += "::";
i += bestLen;
continue;
}
if (!out.empty() && out.back() != ':') out += ':';
out += std::format("{:x}", g[static_cast<std::size_t>(i)]);
i++;
}
return out;
}
// The canonical text of any address imsd compares or logs: an IPv6
// literal in RFC 5952 form, anything else (an IPv4 address, a malformed
// IPv6 that the caller's own checks will refuse) unchanged.
inline std::string CanonicalAddr(std::string_view s) {
if (!Is6(s)) return std::string(s);
return CanonicalIp6(s).value_or(std::string(s));
}
// A comma-separated address list (PCSCF_DISCOVERED=) → the canonical IPv6
// literals in it, in order, duplicates dropped. Entries that are not an
// IPv6 literal (an IPv4 address, a name, garbage) go to `rejected` when
// the caller asks for them; blanks are skipped.
inline std::vector<std::string> ParseAddressList(std::string_view list, std::vector<std::string>* rejected = nullptr) {
std::vector<std::string> out;
std::size_t start = 0;
while (start <= list.size()) {
std::size_t comma = list.find(',', start);
std::string_view item = list.substr(start, comma == std::string_view::npos ? std::string_view::npos : comma - start);
while (!item.empty() && (item.front() == ' ' || item.front() == '\t')) item.remove_prefix(1);
while (!item.empty() && (item.back() == ' ' || item.back() == '\t')) item.remove_suffix(1);
if (!item.empty()) {
if (auto c = CanonicalIp6(item)) {
if (std::find(out.begin(), out.end(), *c) == out.end()) out.push_back(*c);
} else if (rejected) {
rejected->emplace_back(item);
}
}
if (comma == std::string_view::npos) break;
start = comma + 1;
}
return out;
}
// A random token of `n` chars over [a-z0-9] — SIP branch/tag/Call-ID // A random token of `n` chars over [a-z0-9] — SIP branch/tag/Call-ID
// material (the Python rand()). The generator is a parameter so tests can // material (the Python rand()). The generator is a parameter so tests can
// pin output; the default constructor seeds from random_device. // pin output; the default constructor seeds from random_device.

View file

@ -9,6 +9,7 @@ export module Imsd;
export import :Util; export import :Util;
export import :Aka; export import :Aka;
export import :Ipsec; export import :Ipsec;
export import :Qmi;
export import :Sip; export import :Sip;
export import :Sdp; export import :Sdp;
export import :Messages; export import :Messages;

View file

@ -45,6 +45,7 @@ package() {
cd "$srcdir/imsd-$pkgver" cd "$srcdir/imsd-$pkgver"
install -Dm755 imsd "$pkgdir"/usr/bin/imsd install -Dm755 imsd "$pkgdir"/usr/bin/imsd
install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media
install -Dm755 imsd-pcscf "$pkgdir"/usr/libexec/imsd-pcscf
install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd
install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh
install -Dm755 imsd-handover.sh "$pkgdir"/usr/libexec/imsd-handover.sh install -Dm755 imsd-handover.sh "$pkgdir"/usr/libexec/imsd-handover.sh
@ -59,18 +60,17 @@ package() {
# account created at install — imsd-dialerd owns those session bus names # account created at install — imsd-dialerd owns those session bus names
install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \ install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \
"$pkgdir"/etc/skel/.config/autostart/org.kde.modem.daemon.desktop "$pkgdir"/etc/skel/.config/autostart/org.kde.modem.daemon.desktop
# enabled by preset: the unit is a no-op until /etc/imsd.env exists, and # enabled by preset: VoLTE surviving reboots must not depend on a manual
# VoLTE surviving reboots must not depend on a manual systemctl enable # systemctl enable. Since 1.0.0 the unit needs no configuration file (the
# P-CSCF comes from the PDN's PCO) and starts on every phone with the
# package; without a SIM it exits cleanly and stays inactive (IMSD_IDLE),
# and `systemctl mask --now imsd` opts out (a disable is undone by
# imsd-ofonod's Wants=imsd.service)
install -Dm644 "$srcdir"/80-imsd.preset \ install -Dm644 "$srcdir"/80-imsd.preset \
"$pkgdir"/usr/lib/systemd/system-preset/80-imsd.preset "$pkgdir"/usr/lib/systemd/system-preset/80-imsd.preset
mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants
ln -s /usr/lib/systemd/system/imsd.service \ ln -s /usr/lib/systemd/system/imsd.service \
"$pkgdir"/etc/systemd/system/multi-user.target.wants/imsd.service "$pkgdir"/etc/systemd/system/multi-user.target.wants/imsd.service
# ...but only actually start once the carrier config exists, so
# unconfigured systems don't boot into a failing unit
mkdir -p "$pkgdir"/usr/lib/systemd/system/imsd.service.d
printf '[Unit]\nConditionPathExists=/etc/imsd.env\n' \
> "$pkgdir"/usr/lib/systemd/system/imsd.service.d/10-require-config.conf
install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod install -Dm755 imsd-ofonod "$pkgdir"/usr/bin/imsd-ofonod
install -Dm755 imsd-ofono-restart-calls.sh \ install -Dm755 imsd-ofono-restart-calls.sh \
"$pkgdir"/usr/libexec/imsd-ofono-restart-calls.sh "$pkgdir"/usr/libexec/imsd-ofono-restart-calls.sh

View file

@ -91,6 +91,7 @@ crafter-build -- $XTARGET
crafter-build -- --product=media $XTARGET crafter-build -- --product=media $XTARGET
crafter-build -- --product=dialerd $XTARGET crafter-build -- --product=dialerd $XTARGET
crafter-build -- --product=ofonod $XTARGET crafter-build -- --product=ofonod $XTARGET
crafter-build -- --product=pcscf $XTARGET
crafter-build test crafter-build test
# --- bundle + package # --- bundle + package

View file

@ -5,10 +5,15 @@
# #
# Finds-or-creates the ims bearer via ModemManager, connects it, and # Finds-or-creates the ims bearer via ModemManager, connects it, and
# configures the muxed netdev with the MM-assigned address (the s34 recipe, # configures the muxed netdev with the MM-assigned address (the s34 recipe,
# automated) and a host route to the P-CSCF — never a default route (F15 # automated), reads the P-CSCF list the network put in the PDN's PCO from the
# below). Idempotent — safe to run when the PDN is already up. Runs as # modem (imsd-pcscf) unless PCSCF= names one, and gives the configured or
# ExecStartPre of imsd.service, so imsd only starts once the PDN exists; a # the first discovered address (the daemon's fresh target) a host route —
# nonzero exit fails the unit and systemd retries per Restart/RestartSec. # never a default route (F15 below). Idempotent — safe to run when the PDN
# is already up. Runs as
# ExecStartPre of imsd.service, so imsd starts once the PDN exists — or once
# this script has written IMSD_IDLE= and exited 0 with nothing to do, when
# imsd logs the idle and exits cleanly (idle_exit below); a nonzero exit
# fails the unit and systemd retries per Restart/RestartSec.
# #
# Configuration, via the unit's EnvironmentFile /etc/imsd.env (also read # Configuration, via the unit's EnvironmentFile /etc/imsd.env (also read
# directly so manual runs behave the same): # directly so manual runs behave the same):
@ -22,8 +27,16 @@
# (journal/ims 2026-09-16/17 bench: Odido, O2 UK, Swisscom). # (journal/ims 2026-09-16/17 bench: Odido, O2 UK, Swisscom).
# IMS_REG_TIMEOUT max seconds to wait for network registration before # IMS_REG_TIMEOUT max seconds to wait for network registration before
# connect attempts start counting anyway (default 300) # connect attempts start counting anyway (default 300)
# PCSCF the P-CSCF address imsd registers with (its own # PCSCF one P-CSCF address, by hand: it wins over discovery
# required setting); gets the host route over the ims PDN # (which is then skipped) and gets the host route; unset,
# imsd-pcscf reads the PDN's PCO list from the modem and
# this script hands it to imsd as PCSCF_DISCOVERED= in
# /run/imsd.env, the first of them routed
# IMSD_PCSCF the reader's path (default /usr/libexec/imsd-pcscf) — a
# bench seam, from the environment only (not from the file)
# IMSD_ENV the configuration file's path (default /etc/imsd.env) —
# a bench seam. The file is optional (overrides only); its
# existence changes nothing
log() { echo "ims-pdn-up: $*"; } log() { echo "ims-pdn-up: $*"; }
@ -44,9 +57,15 @@ bearer_paths() {
# `enabled` with the modem still in low power; switching the radio back on # `enabled` with the modem still in low power; switching the radio back on
# then no longer brings the modem up (dev phone 2026-10-06). # then no longer brings the modem up (dev phone 2026-10-06).
radio_off() { [ "$(modem_state)" = disabled ]; } radio_off() { [ "$(modem_state)" = disabled ]; }
# A locked SIM (a PIN not entered yet) is the same kind of state the user
# controls: the modem registers once the PIN is in, so like a disabled radio
# it exits non-zero and the unit's restart tries again.
sim_locked() { [ "$(modem_state)" = locked ]; }
unlock_required() { kv -m "$MODEM" | sed -n 's/^modem\.generic\.unlock-required *: *//p'; }
IMSD_ENV=${IMSD_ENV:-/etc/imsd.env}
envval() { # $1 = key — for manual runs; under systemd the vars are inherited envval() { # $1 = key — for manual runs; under systemd the vars are inherited
sed -n "s/^$1=//p" /etc/imsd.env 2>/dev/null | tail -n1 | tr -d '"' sed -n "s/^$1=//p" "$IMSD_ENV" 2>/dev/null | tail -n1 | tr -d '"'
} }
IP_TYPE=${IMS_IP_TYPE:-$(envval IMS_IP_TYPE)} IP_TYPE=${IMS_IP_TYPE:-$(envval IMS_IP_TYPE)}
IP_TYPE=${IP_TYPE:-ipv6} IP_TYPE=${IP_TYPE:-ipv6}
@ -67,6 +86,44 @@ while :; do
done done
log "modem $MODEM" log "modem $MODEM"
# ---- nothing to do: a clean exit, not a retry loop. imsd reads IMSD_IDLE=
# from /run/imsd.env, logs it and exits 0, which Restart=on-failure leaves
# alone — the unit stays inactive until the next boot, a `systemctl start
# imsd` or a start of imsd-ofonod (its Wants=imsd.service, the Phosh image).
# Installing the package and inserting a SIM is the opt-in; no configuration
# file changes what follows. The dead ends are facts: no SIM (the modem's
# `failed` state with a SIM reason; final until ModemManager re-probes;
# checked here and while waiting for registration below), a profile-indexed
# connect series refused ten times in a row — each a persistent 3GPP cause
# in ModemManager's typed error (not subscribed, unknown APN, barred, not
# authorised in this PLMN, authentication failed, the service option not
# supported, the wrong PDN type), with packet service; counted only while the
# modem reads registered, connecting or connected with packet service not
# detached — any other read, a timeout or a transient cause starts the count
# again — and — the daemon's own, with the same clean exit — a
# bring-up that ends with no P-CSCF (none configured, the PCO names none)
# and no registration of imsd's own to resume. One attempt per boot (or per
# such imsd-ofonod start). What is not a fact exits 1 like a disabled radio
# and the restart retries: ten failures without coverage, a SIM still waiting
# for its PIN, a modem without an ims profile (the APN-string connect is
# tried), an APN-string series (the modem's own wall under an IPv4 attach,
# F6), a P-CSCF reader that did not answer (PCSCF_DISCOVERY=failed, below).
idle_exit() { # $1 = key, $2 = log text
log "$2"
printf 'IMSD_IDLE=%s\n' "$1" > /run/imsd.env
log "wrote /run/imsd.env: IMSD_IDLE=$1"
exit 0
}
sim_check() {
[ "$(modem_state)" = failed ] || return 0
REASON=$(kv -m "$MODEM" | sed -n 's/^modem\.generic\.state-failed-reason *: *//p')
case "$REASON" in
sim-missing|esim-without-profiles|sim-error)
idle_exit no-sim "no SIM ($REASON): not bringing the ims PDN up — systemctl start imsd once one is in" ;;
esac
}
sim_check
# ---- the ims profile index (WDS profile list), unless configured. The # ---- the ims profile index (WDS profile list), unless configured. The
# lookup itself can fail while ModemManager is still enabling the modem; a # lookup itself can fail while ModemManager is still enabling the modem; a
# failed lookup is not "no profile" (the APN-string request it would fall # failed lookup is not "no profile" (the APN-string request it would fall
@ -167,7 +224,7 @@ if [ -z "$BEARER" ]; then
waited=0 last= waited=0 last=
while :; do while :; do
STATE=$(modem_state) STATE=$(modem_state)
case "$STATE" in registered|connecting|connected) break ;; esac case "$STATE" in registered|connecting|connected) break ;; failed) sim_check ;; esac
[ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})" [ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})"
last=$STATE last=$STATE
if [ "$waited" -ge "$REG_TIMEOUT" ]; then if [ "$waited" -ge "$REG_TIMEOUT" ]; then
@ -175,6 +232,10 @@ if [ -z "$BEARER" ]; then
log "modem still disabled after $REG_TIMEOUT s (radio off): not connecting" log "modem still disabled after $REG_TIMEOUT s (radio off): not connecting"
exit 1 exit 1
fi fi
if [ "$STATE" = locked ]; then
log "SIM locked ($(unlock_required)) after $REG_TIMEOUT s: not connecting until the PIN is entered"
exit 1
fi
log "not registered after $REG_TIMEOUT s — attempting anyway" log "not registered after $REG_TIMEOUT s — attempting anyway"
break break
fi fi
@ -200,10 +261,31 @@ if [ -z "$BEARER" ]; then
fi fi
n=0 n=0
REFUSED=0 # connects refused in a row, each a persistent 3GPP cause with the modem registered/connecting/connected and packet service not detached (stricter than the daemon's PacketServiceUp)
while [ -z "$BEARER" ]; do while [ -z "$BEARER" ]; do
n=$((n + 1)) n=$((n + 1))
[ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 1; } if [ "$n" -gt 10 ]; then
# only a profile-indexed series the network refused says anything
# about it: an APN-string series (a lookup that kept failing, a late
# registration) meets the modem's own wall under an IPv4 attach (F6),
# which the restart's fresh lookup gets past, and a failure without
# packet service is a coverage gap, not a refusal — counted only while
# the modem reads registered, connecting or connected with packet
# service not `detached` (a CS-registered modem still attaching) and
# only for a persistent 3GPP cause in ModemManager's typed error; any
# other read, `disconnecting`, `unknown` or an unanswered mmcli among
# them, and a timeout or a transient cause, reset it — stricter than
# the daemon's PacketServiceUp, which counts an unknown state as
# service — so the
# count is of refusals in a row, each with packet service at the time,
# never one state read after the series: a series that failed without
# coverage and found the network at its end exits 1 like any other,
# and the restart retries on the attached modem
[ -z "$PROFILE_ID" ] || [ "$REFUSED" -lt 10 ] || idle_exit no-ims-pdn "bearer connect refused 10 times in a row by profile index with packet service and a persistent cause (last: $(squash "$OUT")): no ims PDN for this SIM here — systemctl start imsd to try again"
log "bearer connect failed after 10 attempts (modem state: $(modem_state), last: $(squash "$OUT"))"; exit 1
fi
radio_off && { log "modem disabled (radio off): not connecting"; exit 1; } radio_off && { log "modem disabled (radio off): not connecting"; exit 1; }
sim_locked && { log "SIM locked ($(unlock_required)): not connecting until the PIN is entered"; exit 1; }
B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer of this run's shape B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer of this run's shape
if [ -z "$B" ]; then if [ -z "$B" ]; then
if [ -n "$PROFILE_ID" ]; then if [ -n "$PROFILE_ID" ]; then
@ -217,6 +299,7 @@ while [ -z "$BEARER" ]; do
log "created bearer $B ($SPEC)" log "created bearer $B ($SPEC)"
else else
log "create-bearer attempt $n failed: $(squash "$OUT"); retrying in 10 s" log "create-bearer attempt $n failed: $(squash "$OUT"); retrying in 10 s"
REFUSED=0 # not the network's answer to a connect
sleep 10 sleep 10
continue continue
fi fi
@ -224,7 +307,24 @@ while [ -z "$BEARER" ]; do
if OUT=$(mmcli -b "$B" --connect 2>&1); then if OUT=$(mmcli -b "$B" --connect 2>&1); then
BEARER=$B BEARER=$B
else else
log "connect attempt $n failed (state: $(modem_state)): $(squash "$OUT"); retrying in 10 s" ST=$(modem_state); PS=$(packet_state)
log "connect attempt $n failed (state: ${ST:-unknown}, packet service: ${PS:-unknown}): $(squash "$OUT"); retrying in 10 s"
# a refusal is the network's answer, as ModemManager types it: a
# persistent 3GPP cause — not subscribed, unknown APN, barred, not
# authorised in this PLMN, authentication failed, the service option
# not supported, the wrong PDN type for this attach — with the modem
# attached. A timeout (D-Bus, esm-proc-timeout), a transient cause
# (insufficient resources, out of order, a network failure) or any
# other error starts the count again, as a read without packet service
# does: an outage must not idle VoLTE until the next boot
PERSISTENT=
case "$OUT" in
*MobileEquipment.ServiceOptionNotSubscribed:*|*MobileEquipment.MissingOrUnknownApn:*|*MobileEquipment.OperatorDeterminedBarring:*|*MobileEquipment.ServiceOptionNotAuthorizedInPlmn:*|*MobileEquipment.UserAuthenticationFailed:*|*MobileEquipment.ServiceOptionNotSupported:*|*MobileEquipment.Ipv4OnlyAllowed:*|*MobileEquipment.Ipv6OnlyAllowed:*) PERSISTENT=1 ;;
esac
case "$ST" in
registered|connecting|connected) if [ "$PS" = detached ] || [ -z "$PERSISTENT" ]; then REFUSED=0; else REFUSED=$((REFUSED + 1)); fi ;;
*) REFUSED=0 ;;
esac
case "$OUT" in *call-already-present*) case "$OUT" in *call-already-present*)
# the PDN is up on a bearer this run did not recognise (ip-type or # the PDN is up on a bearer this run did not recognise (ip-type or
# APN spelling); use it rather than ask the modem for a second one # APN spelling); use it rather than ask the modem for a second one
@ -291,30 +391,108 @@ log "$IFACE up, $ADDR/${PREFIX:-64}, ipv6 mtu $(cat "/proc/sys/net/ipv6/conf/$IF
if [ -n "$(ip -6 route show default dev "$IFACE" 2>/dev/null)" ]; then if [ -n "$(ip -6 route show default dev "$IFACE" 2>/dev/null)" ]; then
ip -6 route flush default dev "$IFACE" && log "dropped the RA default route on $IFACE" ip -6 route flush default dev "$IFACE" && log "dropped the RA default route on $IFACE"
fi fi
# the P-CSCF is reachable through the ims PDN only: a host route stands in # ---- the P-CSCF. PCSCF= when the admin set one (one IPv6 address; it wins
# for the default. `dev` without a gateway — a raw-IP netdev without # and discovery is skipped), else the list the network put in the PDN's PCO,
# header_ops, so the kernel resolves no neighbours — the shape imsd uses # read from the modem by imsd-pcscf: a QMI WDS client bound to this netdev's
# for media peers. The PDN is brought up IPv6-only (the address above), so # mux data port — the binding ModemManager gives its own clients, and only
# the P-CSCF must be an IPv6 address; anything else on the IMS network # such a client is shown the call's settings. The list goes to imsd as
# (its DNS servers, a traceroute target) needs its own host route. Without # PCSCF_DISCOVERED= (its own name: this script inherits the previous run's
# this route imsd cannot reach the P-CSCF, so a failure is the unit's # /run/imsd.env through the unit, so a stale discovered value must never
# failure. # pass for the admin's PCSCF=, and it is never read from the environment
# here). The PDN is brought up IPv6-only, so only IPv6 addresses are usable;
# IPv4 ones and names are logged and dropped. Nothing usable is not this
# script's failure, and PCSCF_DISCOVERY= in the hand-off tells the daemon
# which kind it was: `none` — the PCO carried no IPv6 P-CSCF (the reader's
# exit 2), a fact: a registration of imsd's own that can be resumed is
# resumed and stays up, else the daemon idles (its `idle:` exit; after a
# resumable pair's failed attempt the fatal and the restart) — or `failed` —
# the reader did not answer (an error, a timeout, nothing printed), no fact:
# a daemon with nothing to resume exits 1 and the unit's restart runs
# discovery again. `found` and `skipped` (PCSCF= set, an IPv4-only PDN, no
# rmnet mux id) are the other two values.
# The P-CSCF is reachable through the ims PDN only: a host route stands in
# for the default — `dev` without a gateway (a raw-IP netdev without
# header_ops, so the kernel resolves no neighbours), the shape imsd uses for
# media peers; of a discovered list the first address — the daemon's fresh
# target — gets one here; imsd routes the P-CSCF it uses itself as well — the
# fresh target before a fresh registration, the resumed SA peer once its SAs
# are confirmed — with the same idempotent replace as a call's media peer, so
# a target this script did not route (a list set in /etc/imsd.env, a route
# lost with the PDN) is reachable.
# Anything else on the IMS network (its DNS servers, a traceroute
# target) needs its own host route. Without the route imsd cannot reach the
# P-CSCF, so a route failure is the unit's failure.
route_pcscf() { # $1 = address
OUT=$(ip -6 route replace "$1/128" dev "$IFACE" 2>&1) ||
{ log "host route to P-CSCF $1 failed: $(squash "$OUT")"; exit 1; }
log "host route to P-CSCF $1 dev $IFACE"
}
PCSCF=${PCSCF:-$(envval PCSCF)} PCSCF=${PCSCF:-$(envval PCSCF)}
DISCOVERED=
DISCOVERY=skipped
case "$PCSCF" in case "$PCSCF" in
'') log "PCSCF not configured: no P-CSCF host route (imsd needs PCSCF= in /etc/imsd.env)" ;; '')
*:*) OUT=$(ip -6 route replace "$PCSCF/128" dev "$IFACE" 2>&1) || if [ "$IP_TYPE" = ipv4 ]; then
{ log "host route to P-CSCF $PCSCF failed: $(squash "$OUT")"; exit 1; } log "P-CSCF discovery: not for IMS_IP_TYPE=$IP_TYPE — the bring-up configures only the PDN's IPv6 address"
log "host route to P-CSCF $PCSCF dev $IFACE" ;; else
MUX=$(ip -d link show "$IFACE" 2>/dev/null | sed -n 's/.*rmnet mux_id \([0-9]*\).*/\1/p' | head -n1)
if [ -z "$MUX" ]; then
log "P-CSCF discovery: no rmnet mux id on $IFACE (ip -d link); set PCSCF="
else
DISCOVERY=failed
OUT=$("${IMSD_PCSCF:-/usr/libexec/imsd-pcscf}" --mux "$MUX" ${PROFILE_ID:+--profile "$PROFILE_ID"} --timeout 10 2>&1); rc=$?
V6=; SHOWN=; OTHER=; FLAGS=; ERR=; n=0
while IFS= read -r line; do
case "$line" in
'') ;;
v4\ *|fqdn\ *) OTHER="${OTHER:+$OTHER }$line" ;;
flag\ *) FLAGS="${FLAGS:+$FLAGS, }${line#flag }" ;;
*[!0-9a-f:]*) ERR="${ERR:+$ERR }$line" ;;
*:*) [ "$n" -ge 8 ] && continue; n=$((n + 1))
V6="${V6:+$V6 }$line"; DISCOVERED="${DISCOVERED:+$DISCOVERED,}$line"; SHOWN="${SHOWN:+$SHOWN, }$line" ;;
*) ERR="${ERR:+$ERR }$line" ;;
esac
done <<EOF
$OUT
EOF
if [ -n "$V6" ]; then
DISCOVERY=found
log "P-CSCF discovered: $SHOWN${FLAGS:+ ($FLAGS)}"
[ -n "$OTHER" ] && log "P-CSCF discovery also gave $OTHER (not usable on the IPv6-only ims PDN)"
route_pcscf "${V6%% *}" # the first: the daemon's fresh target; imsd routes the P-CSCF it uses itself as well (the fresh target, the resumed peer)
else
case $rc in
0) log "P-CSCF discovery: nothing usable (imsd-pcscf printed no address${ERR:+: $ERR})" ;;
2) DISCOVERY=none; log "P-CSCF discovery: nothing usable (the PCO carried no IPv6 P-CSCF${OTHER:+; it gave $OTHER}${FLAGS:+; $FLAGS})" ;;
*) log "P-CSCF discovery: nothing usable (imsd-pcscf rc $rc${ERR:+: $ERR})" ;;
esac
fi
fi
fi ;;
*,*) log "PCSCF '$PCSCF' is a list; one address here (a list is PCSCF_DISCOVERED, the bring-up's)"; exit 1 ;;
*\[*) log "PCSCF '$PCSCF': write the bare address, as ip prints it (no brackets)"; exit 1 ;;
*:*) log "P-CSCF $PCSCF configured: discovery skipped"; route_pcscf "$PCSCF" ;;
*) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;; *) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;;
esac esac
# ---- hand the connected ims netdev and the PDN's own address to imsd # ---- hand the connected ims netdev, the PDN's own address and the
# (imsd.service reads /run/imsd.env): the daemon carries no baked-in # discovered P-CSCF list to imsd (imsd.service reads /run/imsd.env): the
# interface name and registers on the address the network assigned — not # daemon carries no baked-in interface name and registers on the address
# on the kernel's temporary SLAAC address from the RA, which the netdev # the network assigned — not on the kernel's temporary SLAAC address from
# lists first and which is deprecated after a day and removed after two # the RA, which the netdev lists first and which is deprecated after a day
# (ims ledger F18; the SAs and the Contact would go with it) # and removed after two (ims ledger F18; the SAs and the Contact would go
printf 'DEV=%s\nLOCAL=%s\n' "$IFACE" "$ADDR" > /run/imsd.env # with it). PCSCF_DISCOVERED= is written on every run, empty when nothing
# was discovered or PCSCF= is set; PCSCF_DISCOVERY= says how it came to be
# (found, none, failed, skipped — above).
# The dead end after the PDN is up — nothing to register to (no PCSCF=,
# nothing discovered, no registration of imsd's own in the kernel to resume)
# — is the daemon's to decide (its `idle:` exit before it takes the bus name,
# §7.3): it knows the fresh target, the kernel's SA pair and the holds, and
# PCSCF_DISCOVERY= tells it whether an empty list is the PCO's answer (none:
# the idle) or the reader's failure (failed: exit 1, the restart runs
# discovery again). The hand-off is written on every run.
printf 'DEV=%s\nLOCAL=%s\nPCSCF_DISCOVERED=%s\nPCSCF_DISCOVERY=%s\n' "$IFACE" "$ADDR" "$DISCOVERED" "$DISCOVERY" > /run/imsd.env
log "wrote /run/imsd.env: DEV=$IFACE LOCAL=$ADDR PCSCF_DISCOVERED=$DISCOVERED PCSCF_DISCOVERY=$DISCOVERY"
# ---- open the IMS protected ports in the firewall (rung 5b root cause, # ---- open the IMS protected ports in the firewall (rung 5b root cause,
# journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and # journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and

View file

@ -11,7 +11,9 @@ Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd
# — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different # — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different
# buses, different consumers), but a real ofonod must not. # buses, different consumers), but a real ofonod must not.
Conflicts=ofono.service Conflicts=ofono.service
After=imsd.service # Wants, not After: imsd starts on every phone since 1.0.0 and its start runs
# the ims PDN bring-up (the whole ExecStartPre, up to TimeoutStartSec=900);
# this backend starts at once and republishes when imsd's name appears (OnImsdAppeared)
Wants=imsd.service Wants=imsd.service
[Service] [Service]

View file

@ -6,8 +6,10 @@
# trigger restarts every unit marked needs-restart (enqueue-marked). A # trigger restarts every unit marked needs-restart (enqueue-marked). A
# changed bus policy is live the moment apk writes it (dbus-broker-launch # changed bus policy is live the moment apk writes it (dbus-broker-launch
# reloads on its own), so it must not be left facing the old daemon. The # reloads on its own), so it must not be left facing the old daemon. The
# restart resumes the registration over its existing SAs, or goes on # restart resumes the registration over its existing SAs, goes on waiting
# waiting when the network had imsd wait; a call in progress ends. # when the network had imsd wait, or — an upper-case, zero-padded or uncompressed PCSCF=,
# accepted until 0.3.12 — stops at bring-up with the spelling to use; a
# call in progress ends.
# #
# Also installed as imsd.post-install: `apk del imsd` removes the files # Also installed as imsd.post-install: `apk del imsd` removes the files
# and leaves the daemon running, so a later `apk add` of the package is # and leaves the daemon running, so a later `apk add` of the package is

View file

@ -20,10 +20,13 @@ ExecStartPre=/usr/libexec/ims-pdn-up.sh
ExecStartPre=-/usr/libexec/imsd-handover.sh ExecStartPre=-/usr/libexec/imsd-handover.sh
TimeoutStartSec=900 TimeoutStartSec=900
# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev, # /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev,
# LOCAL= the PDN address ModemManager assigned to it). # LOCAL= the PDN address ModemManager assigned to it, PCSCF_DISCOVERED= the
# /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's # P-CSCF list the network put in the PDN's PCO, read from the modem,
# P-CSCF address; see AGENTS.md §10, or the README) and wins over the # PCSCF_DISCOVERY= how that list came to be: found, none, failed, skipped;
# runtime-derived values. # or IMSD_IDLE=<reason> alone when the bring-up found nothing to do).
# /etc/imsd.env: admin configuration, none required where discovery finds a
# P-CSCF (AGENTS.md §10, the README); it wins over the runtime-derived values
# — PCSCF= overrides discovery.
EnvironmentFile=-/run/imsd.env EnvironmentFile=-/run/imsd.env
EnvironmentFile=-/etc/imsd.env EnvironmentFile=-/etc/imsd.env
# registration state (imsreg.state) + media stats live here # registration state (imsreg.state) + media stats live here

View file

@ -12,10 +12,12 @@ BIN=$(ls -t bin/imsd-aarch64-*/imsd 2>/dev/null | head -n1)
MEDIA=$(ls -t bin/imsd-media-aarch64-*/imsd-media 2>/dev/null | head -n1) MEDIA=$(ls -t bin/imsd-media-aarch64-*/imsd-media 2>/dev/null | head -n1)
DIALERD=$(ls -t bin/imsd-dialerd-aarch64-*/imsd-dialerd 2>/dev/null | head -n1) DIALERD=$(ls -t bin/imsd-dialerd-aarch64-*/imsd-dialerd 2>/dev/null | head -n1)
OFONOD=$(ls -t bin/imsd-ofonod-aarch64-*/imsd-ofonod 2>/dev/null | head -n1) OFONOD=$(ls -t bin/imsd-ofonod-aarch64-*/imsd-ofonod 2>/dev/null | head -n1)
PCSCF=$(ls -t bin/imsd-pcscf-aarch64-*/imsd-pcscf 2>/dev/null | head -n1)
[ -n "$BIN" ] || { echo "no aarch64 imsd build found — cross-compile first" >&2; exit 1; } [ -n "$BIN" ] || { echo "no aarch64 imsd build found — cross-compile first" >&2; exit 1; }
[ -n "$MEDIA" ] || { echo "no aarch64 imsd-media build — cross-compile with --product=media" >&2; exit 1; } [ -n "$MEDIA" ] || { echo "no aarch64 imsd-media build — cross-compile with --product=media" >&2; exit 1; }
[ -n "$DIALERD" ] || { echo "no aarch64 imsd-dialerd build — cross-compile with --product=dialerd" >&2; exit 1; } [ -n "$DIALERD" ] || { echo "no aarch64 imsd-dialerd build — cross-compile with --product=dialerd" >&2; exit 1; }
[ -n "$OFONOD" ] || { echo "no aarch64 imsd-ofonod build — cross-compile with --product=ofonod" >&2; exit 1; } [ -n "$OFONOD" ] || { echo "no aarch64 imsd-ofonod build — cross-compile with --product=ofonod" >&2; exit 1; }
[ -n "$PCSCF" ] || { echo "no aarch64 imsd-pcscf build — cross-compile with --product=pcscf" >&2; exit 1; }
stage=$(mktemp -d) stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT trap 'rm -rf "$stage"' EXIT
@ -24,6 +26,7 @@ cp "$BIN" "$stage/imsd-$VER/imsd"
cp "$MEDIA" "$stage/imsd-$VER/imsd-media" cp "$MEDIA" "$stage/imsd-$VER/imsd-media"
cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd" cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd"
cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod" cp "$OFONOD" "$stage/imsd-$VER/imsd-ofonod"
cp "$PCSCF" "$stage/imsd-$VER/imsd-pcscf"
cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \ cp packaging/ims-pdn-up.sh packaging/imsd-handover.sh packaging/imsd.service \
packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \ packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \
packaging/imsd-ofono-restart-calls.sh \ packaging/imsd-ofono-restart-calls.sh \

View file

@ -4,7 +4,8 @@
# make-sysroot.sh — build an Alpine aarch64 sysroot for cross-compiling imsd. # make-sysroot.sh — build an Alpine aarch64 sysroot for cross-compiling imsd.
# #
# Downloads apk-tools-static + the Alpine signing keys from the CDN and # Downloads apk-tools-static + the Alpine signing keys from the CDN and
# populates a minimal rootfs (musl, libc++, glib and their headers) that # populates a minimal rootfs (musl, libc++, glib and their headers, the Linux
# UAPI headers) that
# clang can use via --sysroot. No root, no qemu, no device needed: # clang can use via --sysroot. No root, no qemu, no device needed:
# #
# packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine # packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine
@ -22,8 +23,10 @@ ARCH=aarch64
# none): crafter-build compiles the sysroot's own copy first. --upgrade below # none): crafter-build compiles the sysroot's own copy first. --upgrade below
# is for a run over an existing sysroot: added alone beside libc++ 22, # is for a run over an existing sysroot: added alone beside libc++ 22,
# llvm-runtimes 23's source did not compile against its headers; upgraded # llvm-runtimes 23's source did not compile against its headers; upgraded
# with it, it did. # with it, it did. linux-headers is in it for <linux/qrtr.h>, which
PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc" # imsd-pcscf's QRTR socket needs (the sysroot had it only as another
# package's dependency before).
PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc linux-headers"
work=$(mktemp -d) work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT trap 'rm -rf "$work"' EXIT

View file

@ -77,7 +77,7 @@ static std::unique_ptr<Configuration> BusLibrary(std::span<const std::string_vie
} }
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) { extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
// Four executables come out of this repo; which one a build produces is // Five executables come out of this repo; which one a build produces is
// chosen by `--product=`: // chosen by `--product=`:
// daemon (default) — imsd, the control-plane daemon (GDBus + engine), // daemon (default) — imsd, the control-plane daemon (GDBus + engine),
// and the unit tests, which link imsd-core. // and the unit tests, which link imsd-core.
@ -91,13 +91,19 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// (org.ofono.*) bridging to imsd, for Phosh and // (org.ofono.*) bridging to imsd, for Phosh and
// GNOME Mobile (GDBus translation plus the imsd-bus // GNOME Mobile (GDBus translation plus the imsd-bus
// polkit gate on its callers, no core). // polkit gate on its callers, no core).
// pcscf — imsd-pcscf, the P-CSCF reader ims-pdn-up.sh runs
// once the ims PDN is up: one QMI WDS client over a
// raw QRTR socket (pure std + POSIX, links the core
// for Imsd:Qmi, no GLib).
bool wantMedia = false; bool wantMedia = false;
bool wantDialerd = false; bool wantDialerd = false;
bool wantOfonod = false; bool wantOfonod = false;
bool wantPcscf = false;
for (std::string_view a : args) { for (std::string_view a : args) {
if (a == "--product=media") wantMedia = true; if (a == "--product=media") wantMedia = true;
if (a == "--product=dialerd") wantDialerd = true; if (a == "--product=dialerd") wantDialerd = true;
if (a == "--product=ofonod") wantOfonod = true; if (a == "--product=ofonod") wantOfonod = true;
if (a == "--product=pcscf") wantPcscf = true;
} }
if (wantOfonod) { if (wantOfonod) {
Configuration ofonod; Configuration ofonod;
@ -161,11 +167,12 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
ApplyStandardArgs(*Core, args); ApplyStandardArgs(*Core, args);
Core->type = ConfigurationType::LibraryStatic; Core->type = ConfigurationType::LibraryStatic;
{ {
std::array<fs::path, 8> ifaces = { std::array<fs::path, 9> ifaces = {
"interfaces/Imsd", "interfaces/Imsd",
"interfaces/Imsd-Util", "interfaces/Imsd-Util",
"interfaces/Imsd-Aka", "interfaces/Imsd-Aka",
"interfaces/Imsd-Ipsec", "interfaces/Imsd-Ipsec",
"interfaces/Imsd-Qmi",
"interfaces/Imsd-Sip", "interfaces/Imsd-Sip",
"interfaces/Imsd-Sdp", "interfaces/Imsd-Sdp",
"interfaces/Imsd-Messages", "interfaces/Imsd-Messages",
@ -175,6 +182,23 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
Core->GetInterfacesAndImplementations(ifaces, impls); Core->GetInterfacesAndImplementations(ifaces, impls);
} }
if (wantPcscf) {
Configuration pcscf;
pcscf.path = "./";
pcscf.name = "imsd-pcscf";
pcscf.outputName = "imsd-pcscf";
ApplyStandardArgs(pcscf, args);
pcscf.type = ConfigurationType::Executable;
pcscf.dependencies = { Core.get() };
{
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "implementations/pcscf" };
pcscf.GetInterfacesAndImplementations(ifaces, impls);
}
LintRules::AddRules(pcscf);
return pcscf;
}
// imsd — the daemon executable: GLib main loop + GDBus service // imsd — the daemon executable: GLib main loop + GDBus service
// (net.catcrafts.IMS1, same ABI as the Python imsd.py) around the core. // (net.catcrafts.IMS1, same ABI as the Python imsd.py) around the core.
Configuration cfg; Configuration cfg;
@ -197,6 +221,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("Util").Dependencies({ Core.get() }); cfg.AddTest("Util").Dependencies({ Core.get() });
cfg.AddTest("Aka").Dependencies({ Core.get() }); cfg.AddTest("Aka").Dependencies({ Core.get() });
cfg.AddTest("Ipsec").Dependencies({ Core.get() }); cfg.AddTest("Ipsec").Dependencies({ Core.get() });
cfg.AddTest("Qmi").Dependencies({ Core.get() });
cfg.AddTest("Messages").Dependencies({ Core.get() }); cfg.AddTest("Messages").Dependencies({ Core.get() });
cfg.AddTest("Engine").Dependencies({ Core.get() }); cfg.AddTest("Engine").Dependencies({ Core.get() });
cfg.AddTest("Sip").Dependencies({ Core.get() }); cfg.AddTest("Sip").Dependencies({ Core.get() });

View file

@ -11,7 +11,10 @@
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming // reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
// what was offered), a landline gateway's G.711-only and AMR-NB offers // what was offered), a landline gateway's G.711-only and AMR-NB offers
// ring and are answered at the offer's own payload types, ring timeout -> // ring and are answered at the offer's own payload types, ring timeout ->
// 480, BYE both ways. Then RegRecovery, the registration recovery policy: // 480, BYE both ways. DTMF: the tones reach the media leg on an active
// call that negotiated telephone-event, and are dropped — with the reason
// — before the answer, after the call, without the negotiation or with a
// character that is no tone. Then RegRecovery, the registration recovery policy:
// the refusal and silence thresholds, local refusals, the reconnect and // the refusal and silence thresholds, local refusals, the reconnect and
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial // fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
// may cut short, when the old registration counts as gone, the holds a // may cut short, when the old registration counts as gone, the holds a
@ -90,6 +93,21 @@ namespace {
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n" "v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid); "t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
} }
// A 200 OK around an arbitrary SDP answer, Content-Length from it — a
// message the shell's framing would deliver whole.
std::string R200Sdp(std::string_view callid, std::string_view sdp) {
return std::format(
"SIP/2.0 200 OK\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: {}\r\n\r\n{}", callid, sdp.size(), sdp);
}
constexpr std::string_view SdpHead = "v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\nt=0 0\r\n";
std::string Final(std::string_view callid, int code, std::string_view text) { std::string Final(std::string_view callid, int code, std::string_view text) {
return std::format( return std::format(
"SIP/2.0 {} {}\r\n" "SIP/2.0 {} {}\r\n"
@ -209,6 +227,7 @@ int main() {
Check(media && media->media.remotePort == 6004, "media remote port"); Check(media && media->media.remotePort == 6004, "media remote port");
Check(media && media->media.payloadType == 97, "media pt"); Check(media && media->media.payloadType == 97, "media pt");
Check(media && media->media.codec == "AMR-WB", "media codec"); Check(media && media->media.codec == "AMR-WB", "media codec");
Check(media && !media->media.dtmfPt, "an answer without telephone-event: the leg has no DTMF pt");
const Action* active = Find(a200, Action::Type::State); const Action* active = Find(a200, Action::Type::State);
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted"); Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
Check(m.State() == CallState::Active, "state active"); Check(m.State() == CallState::Active, "state active");
@ -220,6 +239,12 @@ int main() {
Check(!Has(a200b, Action::Type::StartMedia) && !Has(a200b, Action::Type::State), "retransmitted 200: no second media start, no state action"); Check(!Has(a200b, Action::Type::StartMedia) && !Has(a200b, Action::Type::State), "retransmitted 200: no second media start, no state action");
Check(m.State() == CallState::Active, "state still active after the retransmit"); Check(m.State() == CallState::Active, "state still active after the retransmit");
// DTMF on a call whose answer negotiated no telephone-event: dropped, said why
auto adt = m.OnDtmf("1");
Check(!Has(adt, Action::Type::SendDtmf), "no telephone-event negotiated: no SendDtmf");
const Action* dtLog = Find(adt, Action::Type::Log);
Check(dtLog && dtLog->text == "call ims-call-1: DTMF '1' dropped: no telephone-event negotiated for this call", "...and the line says so");
// media leg exits code 3 (downlink dried up) -> BYE + terminated // media leg exits code 3 (downlink dried up) -> BYE + terminated
auto amx = m.OnMediaExit(3); auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE"); Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
@ -230,6 +255,73 @@ int main() {
Check(m.Terminated(), "terminated"); Check(m.Terminated(), "terminated");
} }
// ---- DTMF, outgoing: the answer echoes our telephone-event/16000 at pt
// 98; the tones go to the leg once active, in upper case, and are
// dropped before the answer, with a character that is no tone,
// and after the call
{
imsd::util::Rng rng(43);
CallMachine m(c, rng, "ims-call-1d", "1233", 50004, false);
m.Start();
auto early = m.OnDtmf("1");
Check(!Has(early, Action::Type::SendDtmf), "DTMF while dialing: not sent");
const Action* earlyLog = Find(early, Action::Type::Log);
Check(earlyLog && earlyLog->text == "call ims-call-1d: DTMF '1' dropped: the call is dialing", "...the line names the state");
auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 98 100\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:98 telephone-event/16000\r\na=rtpmap:100 telephone-event/8000\r\n", SdpHead)));
const Action* media = Find(a200, Action::Type::StartMedia);
Check(media && media->media.dtmfPt == 98, "the leg carries the answer's telephone-event pt at the codec's clock (98, not the narrowband 100)");
Check(media && media->media.dtmfRxPt == 98, "outgoing: the far end sends at our offer's number for that clock");
Check(m.State() == CallState::Active, "active");
auto sent = m.OnDtmf("12#*a");
const Action* dt = Find(sent, Action::Type::SendDtmf);
Check(dt && dt->text == "12#*A", "active + negotiated: SendDtmf with the tones, letters upper-cased");
const Action* dtLog = Find(sent, Action::Type::Log);
Check(dtLog && dtLog->text == "call ims-call-1d: DTMF '12#*A' -> media leg (telephone-event pt 98)", "...and the line names the pt");
auto bad = m.OnDtmf("1x");
Check(!Has(bad, Action::Type::SendDtmf), "a character that is no tone: nothing sent, not even the '1'");
const Action* badLog = Find(bad, Action::Type::Log);
Check(badLog && badLog->text == "call ims-call-1d: DTMF '1x' dropped: 'x' is not a tone", "...the line names the character");
Check(m.OnDtmf("").empty(), "an empty string: nothing, silently");
const std::string atMost(DtmfMaxTones, '1');
auto most = m.OnDtmf(atMost);
const Action* maxDt = Find(most, Action::Type::SendDtmf);
Check(maxDt && maxDt->text == atMost, "a string of DtmfMaxTones tones goes");
auto tooMany = m.OnDtmf(std::format("{}1", atMost));
Check(!Has(tooMany, Action::Type::SendDtmf) && Find(tooMany, Action::Type::Log) && Find(tooMany, Action::Type::Log)->text == std::format("call ims-call-1d: DTMF of {} tones dropped: more than {}", DtmfMaxTones + 1, DtmfMaxTones), "one more: dropped, the line names the bound");
m.OnHangup();
auto late = m.OnDtmf("5");
Check(!Has(late, Action::Type::SendDtmf) && Find(late, Action::Type::Log) && Find(late, Action::Type::Log)->text.ends_with("the call is terminated"), "DTMF after the call: dropped, the line names the state");
}
// ---- DTMF, outgoing, the answer renumbered telephone-event (RFC 3264
// allows it): we send with its number, the far end with our offer's
{
imsd::util::Rng rng(44);
CallMachine m(c, rng, "ims-call-1e", "1233", 50004, false);
m.Start();
auto a200 = m.OnInviteResponse(R200Sdp(m.CallId(), std::format("{}m=audio 6004 RTP/AVP 97 101\r\na=rtpmap:97 AMR-WB/16000/1\r\na=rtpmap:101 telephone-event/16000\r\n", SdpHead)));
const Action* media = Find(a200, Action::Type::StartMedia);
Check(media && media->media.dtmfPt == 101 && media->media.dtmfRxPt == 98, "renumbered answer: send with 101, listen on our 98");
// a narrowband answer: AMR at 99 with telephone-event/8000 at 102
imsd::util::Rng rng2(45);
CallMachine n(c, rng2, "ims-call-1f", "1233", 50004, false);
n.Start();
auto b200 = n.OnInviteResponse(R200Sdp(n.CallId(), std::format("{}m=audio 6004 RTP/AVP 99 102\r\na=rtpmap:99 AMR/8000/1\r\na=rtpmap:102 telephone-event/8000\r\n", SdpHead)));
const Action* nbMedia = Find(b200, Action::Type::StartMedia);
Check(nbMedia && nbMedia->media.codec == "AMR" && nbMedia->media.dtmfPt == 102 && nbMedia->media.dtmfRxPt == 100, "narrowband answer: send with its 102, listen on our offer's 100");
// an answer numbering for itself that gives the audio our offer's
// telephone-event number: the receive number is the answer's, never
// the audio's (every downlink frame would be taken for an event)
imsd::util::Rng rng3(46);
CallMachine o(c, rng3, "ims-call-1g", "1233", 50004, false);
o.Start();
auto c200 = o.OnInviteResponse(R200Sdp(o.CallId(), std::format("{}m=audio 6004 RTP/AVP 100 101\r\na=rtpmap:100 AMR/8000/1\r\na=rtpmap:101 telephone-event/8000\r\n", SdpHead)));
const Action* colMedia = Find(c200, Action::Type::StartMedia);
Check(colMedia && colMedia->media.payloadType == 100 && colMedia->media.dtmfPt == 101 && colMedia->media.dtmfRxPt == 101, "the audio at our telephone-event number: listen on the answer's 101, not the audio's 100");
auto again = m.OnInviteResponse(R200(m.CallId()));
Check(!Find(again, Action::Type::StartMedia), "a retransmitted 200 starts no second leg");
}
// ---- remote BYE mid-call // ---- remote BYE mid-call
{ {
imsd::util::Rng rng(7); imsd::util::Rng rng(7);
@ -368,6 +460,12 @@ int main() {
Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt"); Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt");
Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align"); Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align");
Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event"); Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event");
const Action* mtMedia = Find(aa, Action::Type::StartMedia);
Check(mtMedia && mtMedia->media.dtmfPt == 105, "incoming: the leg carries the offer's telephone-event pt");
Check(mtMedia && mtMedia->media.dtmfRxPt == 105, "incoming: the far end sends at the number we echoed");
auto mtDtmf = m.OnDtmf("9");
const Action* mtDt = Find(mtDtmf, Action::Type::SendDtmf);
Check(mtDt && mtDt->text == "9", "incoming, accepted: the tones go to the leg");
Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes"); Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes");
Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact"); Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact");
// the 180 and 200 carry the SAME To tag (one dialog) // the 180 and 200 carry the SAME To tag (one dialog)
@ -882,9 +980,9 @@ int main() {
// Retry-After is the network's word: it holds every attempt for its // Retry-After is the network's word: it holds every attempt for its
// own length, next to the backoff, and a Dial cuts only the backoff // own length, next to the backoff, and a Dial cuts only the backoff
r.OnReconnectFailure(1000, F::Refused, 90); r.OnReconnectFailure(1000, F::Refused, 90);
Check(reconnectAt(r, 1000, 1090), "Retry-After 90 beats the 5-s backoff"); Check(r.Fresh() && r.Reconnectable() && freshAt(r, 1000, 1090), "a refusal with a Retry-After escalates at once: the fresh attempt behind the network's wait (O2 2026-10-08), the old SA kept");
r.OnDial(); r.OnDial();
Check(reconnectAt(r, 1000, 1090), "…and a Dial does not cut it"); Check(freshAt(r, 1000, 1090), "…and a Dial does not cut it");
r.OnReconnectFailure(1090, F::Refused, 3); r.OnReconnectFailure(1090, F::Refused, 3);
Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it"); Check(r.Fresh() && freshAt(r, 1090, 1100), "a Retry-After shorter than the backoff does not shorten it");
r.OnDial(); r.OnDial();
@ -900,6 +998,17 @@ int main() {
k.OnSuccess(); k.OnSuccess();
Check(k.Reconnectable(), "success forgets it"); Check(k.Reconnectable(), "success forgets it");
} }
// a 401 to a refresh asks for re-authentication over temporary SAs,
// which imsd does not do: the fresh registration at once, the old
// SA kept (the network still holds the registration)
{
RegRecovery j;
j.OnReconnectFailure(1000, F::Refused, 0, 401);
Check(j.Fresh() && j.Reconnectable() && j.refusals == 1 && freshAt(j, 1000, 1005), "a 401 on the first refusal: the fresh attempt after the backoff");
RegRecovery jr;
jr.OnReconnectFailure(1000, F::Refused, 0, 503);
Check(!jr.Fresh() && reconnectAt(jr, 1000, 1005), "a bare 503 (no Retry-After) is still the hiccup case: one more refresh first");
}
// O2 10:43: the flow killed 8 s after the 200, every SYN unanswered // O2 10:43: the flow killed 8 s after the 200, every SYN unanswered
r.OnReconnectFailure(1000, F::Silent); r.OnReconnectFailure(1000, F::Silent);
Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s"); Check(!r.Fresh() && reconnectAt(r, 1000, 1005), "first silent cycle: retry in 5 s");
@ -957,7 +1066,7 @@ int main() {
Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then"); Check(ra.freshHoldUntil == 5000 + 3600, "…a day's for an hour: the refusal is heard again then");
RegRecovery rb; RegRecovery rb;
rb.OnReconnectFailure(1000, F::Refused, 86400); rb.OnReconnectFailure(1000, F::Refused, 86400);
Check(reconnectAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same"); Check(freshAt(rb, 1000, 1000 + 3600), "a refresh's Retry-After is cut to the hour the same, the fresh attempt behind it");
RegRecovery h; RegRecovery h;
h.OnFreshFailure(1000, F::Refused, 60); h.OnFreshFailure(1000, F::Refused, 60);
Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule"); Check(h.freshHoldUntil == 1060, "a refused fresh attempt with Retry-After 60 waits 60 s, not the schedule");
@ -1086,6 +1195,20 @@ int main() {
n.Restore(5, m.Holds(5)); n.Restore(5, m.Holds(5));
Check(!n.Reconnectable() && freshAt(n, 5, 0), "…so the next start registers fresh at once instead of resuming"); Check(!n.Reconnectable() && freshAt(n, 5, 0), "…so the next start registers fresh at once instead of resuming");
Check(!RegRecovery::Held{0, 0, 21, false}.Any(), "nothing gone, nothing held: nothing to keep"); Check(!RegRecovery::Held{0, 0, 21, false}.Any(), "nothing gone, nothing held: nothing to keep");
// the refusal count travels: a resume refused without a Retry-After at a start with no fresh target is one
// refusal, the restart's is the second — without the travel every restart would be the first (F16's loop)
RegRecovery rq;
rq.OnReconnectFailure(10, F::Refused, 0, 503);
auto hq = rq.Holds(20);
Check(hq.Any() && hq.refusals == 1 && !hq.oldGone && hq.hold == 0, "a refusal without a Retry-After is held as a count, nothing else");
RegRecovery sq;
sq.Restore(100, hq);
Check(!sq.Fresh() && sq.Reconnectable(), "the count alone gives nothing up");
sq.OnReconnectFailure(100, F::Refused, 0, 503);
Check(sq.Fresh() && sq.Reconnectable(), "the second refusal, across the restart, gives the resume up (fresh) without writing the registration off");
RegRecovery tq;
tq.OnReconnectFailure(10, F::Refused, 0, 401);
Check(tq.Fresh() && tq.Reconnectable() && tq.Holds(20).refusals == 1, "a 401 gives the resume up at once; its count travels too");
RegRecovery x; RegRecovery x;
x.Restore(2000, RegRecovery::Held{86400, 86400, 21}); x.Restore(2000, RegRecovery::Held{86400, 86400, 21});
Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from"); Check(x.holdUntil == 2000 + 3600 && x.freshHoldUntil == 2000 + 3600, "a restored hold is cut to the hour like the Retry-After it came from");

View file

@ -78,6 +78,18 @@ int main() {
Check(sa && sa->spiPc == 0x0526f2c1u, "spi_pc (UE->P reqid2)"); Check(sa && sa->spiPc == 0x0526f2c1u, "spi_pc (UE->P reqid2)");
Check(sa && sa->securityServer == "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000", "reconstructed Security-Server value"); Check(sa && sa->securityServer == "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000", "reconstructed Security-Server value");
} }
// the environment's spelling must not matter: upper case, uncompressed,
// bracketed — the kernel prints RFC 5952 form and both sides are
// canonicalised before the compare
{
auto sa = ParseExistingSa(State, Policy, "2001:DB8::105", "2001:DB8:1111:2222:3333:4444:5555:DB43");
Check(sa && sa->spiPs == 0x0526f2c0u && sa->portPs == 6000, "ParseExistingSa: upper-case spellings find the SAs");
auto sa2 = ParseExistingSa(State, Policy, "[2001:0db8:0000:0000:0000:0000:0000:0105]", "2001:db8:1111:2222:3333:4444:5555:db43");
Check(sa2 && sa2->spiPc == 0x0526f2c1u, "ParseExistingSa: uncompressed, bracketed P-CSCF finds the SAs");
Check(!ParseExistingSa(State, Policy, "2001:db8::106", "2001:db8:1111:2222:3333:4444:5555:db43"), "ParseExistingSa: another P-CSCF still finds nothing");
auto locals = SaLocals(State, "2001:DB8:0:0:0:0:0:105");
Check(locals.size() == 1 && locals[0] == "2001:db8:1111:2222:3333:4444:5555:db43", "SaLocals: any spelling of the P-CSCF, canonical locals out");
}
// incomplete input -> nullopt // incomplete input -> nullopt
Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt"); Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt");
{ {
@ -89,6 +101,92 @@ int main() {
Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none"); Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none");
} }
// ---- SaPeer: the P-CSCF the kernel's policies point at
{
auto peer = SaPeer(Policy);
Check(peer && *peer == "2001:db8::105", "SaPeer: the dir-out policies' dst with reqid 1 and 2 agreeing");
Check(!SaPeer("").has_value(), "SaPeer: no policies -> none");
// a VPN's: tunnel mode, reqid 1, another destination — alone, and beside our pair
constexpr std::string_view Vpn = "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 1 mode tunnel\n";
constexpr std::string_view Vpn4 = "src 192.0.2.10/32 dst 0.0.0.0/0 \n\tdir out priority 383615 ptype main \n\ttmpl src 192.0.2.10 dst 192.0.2.1\n\t\tproto esp reqid 1 mode tunnel\n";
Check(!SaPeer(Vpn).has_value(), "SaPeer: a lone reqid-1 policy (a VPN's) is not ours");
auto beside = SaPeer(std::string(Policy) + std::string(Vpn));
Check(beside && *beside == "2001:db8::105", "SaPeer: our pair beside a VPN's reqid-1 tunnel policy -> ours");
auto beside4 = SaPeer(std::string(Vpn4) + std::string(Policy));
Check(beside4 && *beside4 == "2001:db8::105", "SaPeer: our pair beside an IPv4 VPN's policy to 0.0.0.0/0 -> ours");
// a transport policy to another address beside the pair: one address still carries both reqids
const std::string other2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 1 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n";
const std::string other1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 3 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
auto one = SaPeer(std::string(Policy) + other2);
Check(one && *one == "2001:db8::105", "SaPeer: a reqid-2 policy to another address beside the pair -> the pair's");
Check(SaPeer(std::string(Policy) + other2 + other1) == "2001:db8::105", "SaPeer: another transport-mode reqid 1 + 2 pair beside ours, on other ports -> ours (the protected ports are the fingerprint)");
Check(!SaPeer(other2 + other1).has_value(), "SaPeer: a transport-mode reqid 1 + 2 pair on other ports alone -> none (not imsd's)");
const std::string ours2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::106/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
Check(!SaPeer(std::string(Policy) + ours2).has_value(), "SaPeer: two addresses each carrying imsd's fingerprint -> none (not one pair to tell apart)");
// the fingerprint alone is not a pair: a reqid-1 policy without its reqid-2 partner, and two fingerprinted policies naming different addresses
const std::string lone1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::107/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::107\n\t\tproto esp reqid 1 mode transport\n";
const std::string apart2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::108/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::108\n\t\tproto esp reqid 2 mode transport\n";
Check(!SaPeer(lone1).has_value(), "SaPeer: a lone fingerprinted reqid-1 policy (no reqid-2 partner) -> none");
Check(!SaPeer(lone1 + apart2).has_value(), "SaPeer: a fingerprinted reqid-1 policy to one address and a fingerprinted reqid-2 policy to another -> none (not one pair)");
Check(SaPeer(std::string(Policy) + apart2) == "2001:db8::105", "SaPeer: a fingerprinted reqid-2 policy to another address beside our pair -> the pair's");
Check(!SaPeer(std::string(Vpn) + "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 2 mode tunnel\n").has_value(), "SaPeer: a tunnel-mode reqid 1 + 2 pair (a VPN's two child SAs) -> none");
// transport mode is part of the fingerprint: a tunnel-mode reqid 1 + 2 pair carrying the protected ports is not ours
const std::string tunnelOut1 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::109/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::109\n\t\tproto esp reqid 1 mode tunnel\n";
const std::string tunnelOut2 = "src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::109/128 sport 45062 dport 2 \n\tdir out priority 0 \n\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::109\n\t\tproto esp reqid 2 mode tunnel\n";
Check(!SaPeer(tunnelOut1 + tunnelOut2).has_value(), "SaPeer: a tunnel-mode reqid 1 + 2 pair carrying the protected ports -> none (transport mode is part of the fingerprint)");
Check(SaPeer(std::string(Policy) + tunnelOut1 + tunnelOut2) == "2001:db8::105", "SaPeer: the same beside our pair -> ours");
Check(SaPeer(State).has_value() == false, "SaPeer: state text (no dir out) -> none");
}
// ---- OwnXfrmDeleteCommands: imsd's own entries out of the kernel by exact key; everything else stays
{
const std::string ue = "2001:db8:1111:2222:3333:4444:5555:db43";
// a policy-based VPN's: a tunnel policy (v6, reqid 1), an IPv4 one to 0.0.0.0/0, a tunnel SA
const std::string vpn = "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 1 mode tunnel\n";
const std::string vpn4 = "src 192.0.2.10/32 dst 0.0.0.0/0 \n\tdir out priority 383615 ptype main \n\ttmpl src 192.0.2.10 dst 192.0.2.1\n\t\tproto esp reqid 1 mode tunnel\n";
const std::string tunState = "src 2001:db8::1 dst 2001:db8::2\n\tproto esp spi 0x00000777 reqid 7 mode tunnel\n\treplay-window 32 \n";
// another transport-mode user's reqid 1 + 2 pair on other ports, with its SA between other addresses
const std::string otherOut2 = "src " + ue + "/128 dst 2001:db8::106/128 sport 1 dport 2 \n\tdir out priority 0 \n\ttmpl src " + ue + " dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n";
const std::string otherOut1 = "src " + ue + "/128 dst 2001:db8::106/128 sport 3 dport 4 \n\tdir out priority 0 \n\ttmpl src " + ue + " dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
const std::string otherState = "src 2001:db8::7 dst 2001:db8::106\n\tproto esp spi 0x00000106 reqid 2 mode transport\n\treplay-window 0 \n";
auto dels = OwnXfrmDeleteCommands(State, Policy, ue);
Check(dels.size() == 8, "OwnXfrmDeleteCommands: our four states and four policies -> 8 deletes");
Check(dels.size() == 8 && Join(dels[0]) == "ip xfrm state delete src 2001:db8::105 dst " + ue + " proto esp spi 0x0a84ab2f", "OwnXfrmDeleteCommands: a state by src, dst and SPI, byte-exact");
Check(dels.size() == 8 && Join(dels[4]) == "ip xfrm policy delete src 2001:db8::105/128 dst " + ue + "/128 sport 33421 dport 45062 dir in", "OwnXfrmDeleteCommands: a policy by its selector and direction, byte-exact");
Check(dels.size() == 8 && Join(dels[1]) == "ip xfrm state delete src " + ue + " dst 2001:db8::105 proto esp spi 0x0526f2c1" && Join(dels[2]) == "ip xfrm state delete src 2001:db8::105 dst " + ue + " proto esp spi 0x0071acd4" && Join(dels[3]) == "ip xfrm state delete src " + ue + " dst 2001:db8::105 proto esp spi 0x0526f2c0",
"OwnXfrmDeleteCommands: the other three states byte-exact, in the kernel's order");
Check(dels.size() == 8 && Join(dels[5]) == "ip xfrm policy delete src " + ue + "/128 dst 2001:db8::105/128 sport 45062 dport 33421 dir out" && Join(dels[6]) == "ip xfrm policy delete src 2001:db8::105/128 dst " + ue + "/128 sport 6000 dport 45061 dir in" && Join(dels[7]) == "ip xfrm policy delete src " + ue + "/128 dst 2001:db8::105/128 sport 45061 dport 6000 dir out",
"OwnXfrmDeleteCommands: the other three policies byte-exact, in the kernel's order");
auto besideVpn = OwnXfrmDeleteCommands(std::string(State) + tunState, std::string(Policy) + vpn + vpn4, ue);
Check(besideVpn.size() == 8 && std::ranges::none_of(besideVpn, [](const auto& c) { auto j = Join(c); return j.contains("2001:db8::2") || j.contains("192.0.2.10") || j.contains("0x00000777"); }),
"OwnXfrmDeleteCommands: a VPN's tunnel SA and two tunnel policies beside ours -> ours only, theirs untouched");
Check(OwnXfrmDeleteCommands(otherState, otherOut2 + otherOut1, ue).empty(), "OwnXfrmDeleteCommands: another transport-mode reqid 1 + 2 pair on other ports, with its SA between other addresses -> nothing deleted");
Check(OwnXfrmDeleteCommands(std::string(State) + otherState, std::string(Policy) + otherOut2 + otherOut1, ue).size() == 8, "OwnXfrmDeleteCommands: the same beside our pair -> ours only");
Check(OwnXfrmDeleteCommands("", "", ue).empty(), "OwnXfrmDeleteCommands: nothing -> nothing");
Check(OwnXfrmDeleteCommands(State, "", ue).size() == 4, "OwnXfrmDeleteCommands: policy-less states of imsd's on the UE's address are deleted (a setup whose policy add failed leaves them)");
Check(OwnXfrmDeleteCommands(otherState, "", ue).empty(), "OwnXfrmDeleteCommands: a policy-less state between other addresses is left");
// the fingerprint selects, not a pair: a lone transport-mode reqid-1 policy carrying the protected port between other addresses goes, with the reqid-1 state between them (the §12 residual)
const std::string loneOther = "src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
const std::string loneOtherState = "src 2001:db8::7 dst 2001:db8::106\n\tproto esp spi 0x00000107 reqid 1 mode transport\n\treplay-window 0 \n";
Check(OwnXfrmDeleteCommands(loneOtherState, loneOther, ue).size() == 2, "OwnXfrmDeleteCommands: a lone policy carrying the fingerprint between other addresses, and the reqid-1 state in its direction, are deleted (the fingerprint selects, not a pair)");
// a reqid-1/2 transport-mode state on the UE's address goes whoever made it: the other user's SA between the UE address and its peer, its policies on other ports staying (the §12 residual)
const std::string otherOnUe = "src " + ue + " dst 2001:db8::106\n\tproto esp spi 0x00000108 reqid 1 mode transport\n\treplay-window 0 \n";
auto onUe = OwnXfrmDeleteCommands(otherOnUe, otherOut2 + otherOut1, ue);
Check(onUe.size() == 1 && Join(onUe[0]) == "ip xfrm state delete src " + ue + " dst 2001:db8::106 proto esp spi 0x00000108", "OwnXfrmDeleteCommands: another transport-mode user's reqid-1 state on the UE's address is deleted whoever made it; its policies on other ports stay (the §12 residual)");
Check(OwnXfrmDeleteCommands("src " + ue + " dst 2001:db8::106\n\tproto ah spi 0x00000109 reqid 1 mode transport\n\treplay-window 0 \n", otherOut2 + otherOut1, ue).empty(), "OwnXfrmDeleteCommands: an AH reqid-1 transport state on the UE's address is not selected (the delete names proto esp)");
// the selector as `ip xfrm policy` prints it: a fingerprinted policy with a protocol (and a dev) keeps them in its delete, a missing port is no empty token
const std::string protoOther = "src 2001:db8::7/128 dst 2001:db8::106/128 proto udp sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
auto protoDels = OwnXfrmDeleteCommands("", protoOther, ue);
Check(protoDels.size() == 1 && Join(protoDels[0]) == "ip xfrm policy delete src 2001:db8::7/128 dst 2001:db8::106/128 proto udp sport 45061 dport 4 dir out", "OwnXfrmDeleteCommands: a fingerprinted policy with a protocol in its selector is deleted by that selector (proto kept; the tmpl's 'proto esp' is not the selector's)");
const std::string portlessOther = "src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dev qmapmux0.0 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
auto portlessDels = OwnXfrmDeleteCommands("", portlessOther, ue);
Check(portlessDels.size() == 1 && Join(portlessDels[0]) == "ip xfrm policy delete src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dev qmapmux0.0 dir out", "OwnXfrmDeleteCommands: a selector without a dport and with a dev -> no empty token, the dev kept");
// the SA reader's ports come from our pair, never from a foreign transport policy that happens to carry ports
auto saBeside = ParseExistingSa(State, std::string(Policy) + otherOut2, "2001:db8::105", ue);
Check(saBeside && saBeside->portPc == 33421 && saBeside->portPs == 6000, "ParseExistingSa: a foreign transport policy's ports beside ours are not taken");
}
// ---- fresh setup command sequence // ---- fresh setup command sequence
{ {
SaParams p; SaParams p;
@ -99,23 +197,21 @@ int main() {
p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400; p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400;
p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421; p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421;
auto cmds = BuildSetupCommands(p); auto cmds = BuildSetupCommands(p);
Check(cmds.size() == 10, "flush x2 + 4 states + 4 policies"); Check(cmds.size() == 8, "4 states + 4 policies, no flush (OwnXfrmDeleteCommands takes imsd's previous entries out)");
Check(Join(cmds[0]) == "ip xfrm state flush", "state flush first");
Check(Join(cmds[1]) == "ip xfrm policy flush", "policy flush second");
// reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc // reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc
Check(Join(cmds[2]) == Check(Join(cmds[0]) ==
"ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 " "ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 "
"proto esp spi 400 mode transport reqid 1 " "proto esp spi 400 mode transport reqid 1 "
"auth-trunc hmac(sha1) 0xabababababababababababababababab 96 " "auth-trunc hmac(sha1) 0xabababababababababababababababab 96 "
"enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", "enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
"state add reqid1 outbound byte-exact"); "state add reqid1 outbound byte-exact");
// first out policy: sport PORT_UC dport PORT_PS reqid 1 // first out policy: sport PORT_UC dport PORT_PS reqid 1
Check(Join(cmds[6]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact"); Check(Join(cmds[4]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact");
// v4 uses /32 // v4 uses /32
SaParams p4 = p; SaParams p4 = p;
p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1"; p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1";
auto c4 = BuildSetupCommands(p4); auto c4 = BuildSetupCommands(p4);
Check(c4[6][5] == "10.0.0.2/32", "v4 prefix length /32"); Check(c4[4][5] == "10.0.0.2/32", "v4 prefix length /32");
} }
// null cipher -> cipher_null + empty key arg // null cipher -> cipher_null + empty key arg
{ {
@ -125,7 +221,7 @@ int main() {
p.ck = {}; p.ck = {};
p.ealg = "null"; p.ealg = "null";
auto cmds = BuildSetupCommands(p); auto cmds = BuildSetupCommands(p);
auto& st = cmds[2]; auto& st = cmds[0];
Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key"); Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key");
} }
@ -137,7 +233,7 @@ int main() {
p.ck = {}; p.ck = {};
p.alg = "hmac-md5-96"; p.ealg = "null"; p.alg = "hmac-md5-96"; p.ealg = "null";
auto cmds = BuildSetupCommands(p); auto cmds = BuildSetupCommands(p);
Check(cmds[2][17] == "hmac(md5)" && cmds[2][18] == "0x01010101010101010101010101010101" && cmds[2][19] == "96", "md5: auth-trunc hmac(md5) IK 96"); Check(cmds[0][17] == "hmac(md5)" && cmds[0][18] == "0x01010101010101010101010101010101" && cmds[0][19] == "96", "md5: auth-trunc hmac(md5) IK 96");
Check(KernelAuth("hmac-sha-1-96") == "hmac(sha1)" && KernelAuth("hmac-md5-96") == "hmac(md5)" && !KernelAuth("hmac-sha-256-128"), "KernelAuth names"); Check(KernelAuth("hmac-sha-1-96") == "hmac(sha1)" && KernelAuth("hmac-md5-96") == "hmac(md5)" && !KernelAuth("hmac-sha-256-128"), "KernelAuth names");
Check(KnownEalg("null") && KnownEalg("aes-cbc") && KnownEalg("des-ede3-cbc") && !KnownEalg("aes-gcm") && !KnownEalg("") && !KnownEalg("3des"), "KnownEalg set (canonical names only)"); Check(KnownEalg("null") && KnownEalg("aes-cbc") && KnownEalg("des-ede3-cbc") && !KnownEalg("aes-gcm") && !KnownEalg("") && !KnownEalg("3des"), "KnownEalg set (canonical names only)");
} }
@ -196,6 +292,47 @@ int main() {
auto bare = ParseSecurityServer("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=null;", "hmac-sha-1-96", "null"); auto bare = ParseSecurityServer("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=null;", "hmac-sha-1-96", "null");
Check(bare && bare->mechanisms.size() == 1 && bare->offerMatched && !bare->Carrier().spiPs && !bare->Carrier().portPs, "no spi-s: absent, not zero"); Check(bare && bare->mechanisms.size() == 1 && bare->offerMatched && !bare->Carrier().spiPs && !bare->Carrier().portPs, "no spi-s: absent, not zero");
} }
// ---- the resume's Security-Verify: the stored list only for the SA pair the kernel holds
{
using Source = ResumeSecurityVerify::Source;
ExistingSa sa;
sa.spiPc = 129427852u; sa.spiPs = 133937015u; sa.portPc = 6051; sa.portPs = 6060;
sa.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=129427852; spi-s=133937015; port-c=6051; port-s=6060";
auto ok = ChooseResumeSecurityVerify(O2, sa, "null");
Check(ok.source == Source::Stored && ok.value == O2 && ok.mechanisms == 6, "resume: O2's six-entry list whose carrier is the kernel's pair -> the list verbatim, six mechanisms");
auto okAes = ChooseResumeSecurityVerify(O2, sa, "aes-cbc");
Check(okAes.source == Source::Stored && okAes.value == O2, "resume: the same under the aes offer (the carrier does not depend on the offer)");
constexpr std::pair<std::string_view, std::string_view> off[] = {
{"spi-c", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427853;spi-s=133937015;port-c=6051;port-s=6060"},
{"spi-s", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937016;port-c=6051;port-s=6060"},
{"port-c", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6052;port-s=6060"},
{"port-s", "ipsec-3gpp;q=0.94;alg=hmac-sha-1-96;mod=trans;spi-c=129427852;spi-s=133937015;port-c=6051;port-s=6061"},
};
for (const auto& [field, text] : off) {
auto r = ChooseResumeSecurityVerify(text, sa, "null");
Check(r.source == Source::OtherSa && r.value == sa.securityServer, std::format("resume: {} off by one -> the rebuilt entry", field));
}
auto offSpiS = ChooseResumeSecurityVerify(off[1].second, sa, "null");
Check(offSpiS.storedSpiPs && *offSpiS.storedSpiPs == 133937016u, "resume: the stored spi-s is reported for the log line");
auto none = ChooseResumeSecurityVerify("", sa, "null");
Check(none.source == Source::NoneStored && none.value == sa.securityServer, "resume: nothing stored -> the rebuilt entry");
auto junk = ChooseResumeSecurityVerify("not a list", sa, "null");
Check(junk.source == Source::OtherSa && junk.value == sa.securityServer && !junk.storedSpiPs, "resume: an unparseable value -> the rebuilt entry, no spi-s to report");
ExistingSa kpn;
kpn.spiPc = 49889723u; kpn.spiPs = 49889722u; kpn.portPc = 33142; kpn.portPs = 6000;
kpn.securityServer = "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=49889723; spi-s=49889722; port-c=33142; port-s=6000";
auto k = ChooseResumeSecurityVerify(kpn.securityServer, kpn, "aes-cbc");
Check(k.source == Source::Stored && k.value == kpn.securityServer && k.mechanisms == 1, "resume: KPN's one-entry list for its pair -> the list, one mechanism");
}
// ---- a state file another attempt left behind: the file's UE SPIs against the kernel pair's
{
Check(!FileNamesAnotherPair(0x0526f2c0L, 0x0526f2c1L, 0x0526f2c0, 0x0526f2c1), "file pair: the pair's own SPIs -> the file is this pair's");
Check(FileNamesAnotherPair(0x11111111L, 0x22222222L, 0x0526f2c0, 0x0526f2c1), "file pair: other SPIs -> another attempt's");
Check(FileNamesAnotherPair(0x0526f2c0L, 0x22222222L, 0x0526f2c0, 0x0526f2c1), "file pair: one SPI differs -> another attempt's");
Check(!FileNamesAnotherPair(std::nullopt, std::nullopt, 0x0526f2c0, 0x0526f2c1), "file pair: a file without the keys -> no verdict");
Check(!FileNamesAnotherPair(0x11111111L, 0x22222222L, 0, 0x0526f2c1), "file pair: a missing inbound state (0) -> no verdict");
Check(!FileNamesAnotherPair(0L, 0L, 0x0526f2c0, 0x0526f2c1), "file pair: zeros in the file -> no verdict");
}
// ---- selection rules and syntax corners // ---- selection rules and syntax corners
{ {
// spec-shaped list: every entry carries the SPI set, md5 preferred by q — we offered sha1, so sha1 wins (audit A1) // spec-shaped list: every entry carries the SPI set, md5 preferred by q — we offered sha1, so sha1 wins (audit A1)

View file

@ -192,6 +192,20 @@ int main() {
Reg2, "protected REGISTER"); Reg2, "protected REGISTER");
} }
// ---- a reregistration (the keepalive refresh, a resume): the last
// challenge answer verbatim, new SPIs and a new client port in
// Security-Client, the server port unchanged (TS 24.229 5.1.1.4.2,
// TS 33.203 7.4.0)
{
std::string auth = AuthAka(c, "NONCEXYZ", "CNONCE0123456789", "RESP0000");
std::string r3 = BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 3, 55555u, 66666u, auth, 45063);
Check(r3.contains("\r\nSecurity-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=55555; spi-s=66666; port-c=45063; port-s=45062\r\n"), "refresh Security-Client: new SPIs, a new client port, the same server port");
Check(r3.contains("\r\nCSeq: 3 REGISTER\r\n") && r3.contains("\r\nVia: SIP/2.0/TCP [2001:db8::db43]:45062;") && r3.contains("@[2001:db8::db43]:45062>"), "refresh: CSeq+1, the server port in Via and Contact");
Check(r3.contains(std::format("\r\n{}\r\n", auth)), "refresh carries the last challenge answer verbatim");
std::string r3same = BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678", "REGBRANCH123456", 3, 55555u, 66666u, auth);
Check(r3same.contains("port-c=45061; port-s=45062\r\n"), "without a client port the in-use one is offered (the initial registration's second REGISTER)");
}
// ---- protected-leg transport follows Context.transport (SIP_TRANSPORT=udp, // ---- protected-leg transport follows Context.transport (SIP_TRANSPORT=udp,
// or the auto fallback): every protected request's Via says UDP, the // or the auto fallback): every protected request's Via says UDP, the
// challenge's Via stays UDP either way, and TCP is the untouched default. // challenge's Via stays UDP either way, and TCP is the untouched default.

95
tests/Qmi/main.cpp Normal file
View file

@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Qmi unit tests — the request bytes imsd-pcscf sends (pinned to the
// bytes the dev-phone probe sent, which are protocol only), a Get Current
// Settings answer in the exact TLV shape the Fairphone 6's modem returned
// for the ims PDN (163 bytes; the addresses re-encoded to 2001:db8::/32),
// the OutOfCall answer an unbound client gets, and the readers' tolerance.
import std;
import Imsd;
using namespace imsd::qmi;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::vector<std::uint8_t> Hex(std::string_view s) { return *imsd::util::FromHex(s); }
// The phone's answer to Get Current Settings (mask 0x7FFFF) on the bound
// client, 2026-10-11: profile2 / ims / IPv6 / MTU 1280, TLV 0x22 = 1,
// 0x24 empty, 0x2E = two P-CSCFs (PCO order), 0x2F empty. UE and
// gateway and the two P-CSCFs re-encoded.
const std::vector<std::uint8_t> Settings = Hex("02 04 00 2d 00 9c 00 02 04 00 00 00 00 00 10 08 00 70 72 6f 66 69 6c 65 32 11 01 00 02 14 03 00 69 6d 73 1d 01 00 00 1f 02 00 00 02 22 01 00 01 24 01 00 00 25 11 00 20 01 0d b8 11 11 22 22 33 33 44 44 55 55 db 43 40 26 11 00 20 01 0d b8 11 11 22 22 00 00 00 00 00 00 00 01 40 29 04 00 00 05 00 00 2a 01 00 00 2b 01 00 06 2c 01 00 00 2d 02 00 80 88 2e 21 00 02 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 23 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 01 05 2f 08 00 00 00 00 00 00 00 00 00");
// An unbound client's answer: result 1, error 15 (OutOfCall).
const std::vector<std::uint8_t> OutOfCall = Hex("02 04 00 2d 00 07 00 02 04 00 01 00 0f 00");
// Synthetic (no recording carries them): an IPv4 list of one and an FQDN
// list of one beside a one-entry IPv6 list.
const std::vector<std::uint8_t> Synthetic = Hex("02 07 00 2d 00 3a 00 02 04 00 00 00 00 00 22 01 00 01 23 05 00 01 05 02 00 c0 24 10 00 01 0d 00 70 63 73 63 66 2e 65 78 61 6d 70 6c 65 2e 11 00 01 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 01 05");
// Synthetic: a 0x2E whose count says two but carries one.
const std::vector<std::uint8_t> BadCount = Hex("02 08 00 2d 00 1b 00 02 04 00 00 00 00 00 2e 11 00 02 20 01 0d b8 00 00 00 00 00 00 00 00 00 00 00 23");
}
int main() {
// ---- requests, byte for byte what the probe sent
Check(BindMuxDataPort(1, EndpointEmbedded, EndpointInterfaceIpa, 1) == Hex("00 01 00 a2 00 0f 00 10 08 00 04 00 00 00 01 00 00 00 11 01 00 01"), "Bind Mux Data Port bytes");
Check(SetIpFamily(2, IpFamilyV6) == Hex("00 02 00 4d 00 04 00 01 01 00 06"), "Set IP Family bytes");
Check(GetPacketServiceStatus(3) == Hex("00 03 00 22 00 00 00"), "Get Packet Service Status bytes");
Check(GetCurrentSettings(4, RequestedSettingsAll) == Hex("00 04 00 2d 00 07 00 10 04 00 ff ff 07 00"), "Get Current Settings bytes");
Check(GetProfileSettings(5, 2) == Hex("00 05 00 2b 00 05 00 01 02 00 00 02"), "Get Profile Settings bytes");
// ---- the recorded answer
{
auto r = ParseMessage(Settings);
Check(r.has_value(), "settings parse");
Check(r && r->type == TypeResponse && r->txn == 4 && r->msg == MsgGetCurrentSettings, "settings header");
Check(r && r->Ok(), "settings result ok");
Check(r && r->tlvs.size() == 17, "settings: 17 TLVs");
Check(r && r->Find(0x2E) && r->Find(0x2E)->value.size() == 33, "settings: TLV 0x2E is 33 bytes");
PcscfLists l = ParsePcscfLists(*r);
Check(l.viaPco, "settings: P-CSCF via PCO requested");
Check(l.ipv6.size() == 2 && l.ipv6[0] == "2001:db8::23" && l.ipv6[1] == "2001:db8::105", "settings: the IPv6 list in PCO order, canonical");
Check(l.ipv4.empty() && l.fqdn.empty(), "settings: no IPv4 list, an empty FQDN list");
}
// ---- the unbound client's answer
{
auto r = ParseMessage(OutOfCall);
Check(r && !r->Ok() && r->error == ErrOutOfCall, "OutOfCall parsed");
Check(r && ErrorName(r->error) == "OutOfCall", "OutOfCall named");
Check(ErrorName(ErrNoEffect) == "NoEffect" && ErrorName(999) == "?", "error names");
Check(r && ParsePcscfLists(*r).ipv6.empty(), "no lists in an error answer");
}
// ---- the other lists, and tolerance
{
auto r = ParseMessage(Synthetic);
Check(r.has_value(), "synthetic parse");
PcscfLists l = ParsePcscfLists(*r);
Check(l.ipv4.size() == 1 && l.ipv4[0] == "192.0.2.5", "IPv4 list: little-endian word to dotted quad");
Check(l.fqdn.size() == 1 && l.fqdn[0] == "pcscf.example", "FQDN list: u16-length strings");
Check(l.ipv6.size() == 1 && l.ipv6[0] == "2001:db8::105", "IPv6 list of one");
auto b = ParseMessage(BadCount);
Check(b && ParsePcscfLists(*b).ipv6.empty(), "a 0x2E whose count disagrees with its length reads as empty");
Check(!ParseMessage(Hex("02 04 00 2d 00")).has_value(), "short buffer -> nullopt");
Check(!ParseMessage(Hex("02 04 00 2d 00 07 00 02 09 00 01 00 0f 00")).has_value(), "a TLV overrunning the message -> nullopt");
Check(!ParseMessage(Hex("02 04 00 2d 00 20 00 02 04 00 01 00 0f 00")).has_value(), "a header length past the buffer -> nullopt");
}
// ---- the two small readers
{
auto st = ParseMessage(Hex("02 03 00 22 00 0b 00 02 04 00 00 00 00 00 01 01 00 02"));
Check(st && PacketServiceStatus(*st) == PacketServiceConnected, "packet service status: connected (the probe's answer)");
auto pf = ParseMessage(Hex("02 05 00 2b 00 0b 00 02 04 00 00 00 00 00 1f 01 00 01"));
Check(pf && ProfilePcscfViaPco(*pf) == true, "profile flag: PCSCF via PCO set");
auto none = ParseMessage(Hex("02 05 00 2b 00 07 00 02 04 00 00 00 00 00"));
Check(none && !ProfilePcscfViaPco(*none).has_value() && !PacketServiceStatus(*none).has_value(), "absent TLVs -> nullopt");
}
if (Failures == 0) std::println("Qmi: all tests passed");
return Failures;
}

View file

@ -206,8 +206,23 @@ int main() {
"a=rtpmap:104 AMR-WB/16000/1\r\n" "a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=rtpmap:105 telephone-event/16000\r\n" "a=rtpmap:105 telephone-event/16000\r\n"
"a=rtpmap:106 telephone-event/8000\r\n"; "a=rtpmap:106 telephone-event/8000\r\n";
Check(OfferedTelephoneEventPt(16000) == 98 && OfferedTelephoneEventPt(8000) == 100, "our offer's telephone-event numbers per clock");
Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate"); Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate");
Check(TelephoneEventPt(offer, 8000) == 106, "narrowband telephone-event pt"); Check(!TelephoneEventPt(offer, 8000), "an rtpmap the m-line does not list (106 among 104 105) is not negotiated");
Check(ParseAnswer(offer).dtmfPt == 105, "the answer's dtmfPt is the one at the chosen codec's clock (AMR-WB: 16000)");
std::string both = std::format("{}a=rtpmap:99 AMR/8000/1\r\n", offer);
both.replace(both.find("RTP/AVP 104 105"), 15, "RTP/AVP 104 99 105 106");
std::array<std::string, 1> nb = {"AMR"};
Check(ParseAnswer(both, nb).dtmfPt == 106, "...and the narrowband one when AMR is chosen");
Check(TelephoneEventPt(both, 8000) == 106, "listed on the m-line, the narrowband telephone-event pt is found");
Check(!ParseAnswer(offer, nb).dtmfPt, "no codec we play chosen: no dtmfPt either");
Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8\r\n" "a=rtpmap:8 PCMA/8000\r\n").dtmfPt, "no telephone-event in the answer: dtmfPt empty");
Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8\r\n" "a=rtpmap:8 PCMA/8000\r\n" "a=rtpmap:101 telephone-event/8000\r\n").dtmfPt, "the answer dropped telephone-event from its m-line and kept the rtpmap: not negotiated (RFC 3264 §8.3.2: nothing is sent with a format the answer does not list)");
Check(ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 telephone-event/8000\r\n").dtmfPt == 101, "G.711 answer with telephone-event/8000: dtmfPt 101");
Check(!ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 telephone-event/16000\r\n").dtmfPt, "telephone-event at the wrong clock for the codec is not the call's");
Check(ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 101\r\n" "a=rtpmap:101 TELEPHONE-EVENT/8000\r\n").dtmfPt == 101, "the name in another case (RFC 4566: case-insensitive)");
Check(TelephoneEventPt("m=audio 1 RTP/AVP 98\r\na=rtpmap:98 Telephone-Event/16000/1\r\n", 16000) == 98, "mixed case, with a channel count");
Check(!TelephoneEventPt("m=audio 1 RTP/AVP 98\r\na=rtpmap:98 telephone-event/80000\r\n", 8000), "the clock is matched whole: /80000 is not /8000");
Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt"); Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt");
Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt"); Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt");
} }

View file

@ -3,7 +3,8 @@
// lint-disable-file fixed-width-types // lint-disable-file fixed-width-types
// Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex // Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex
// decoding, host/port formatting, and the deterministic-seed RNG. MD5 and // decoding, host/port formatting, the RFC 5952 canonical IPv6 text and the
// address-list parser, and the deterministic-seed RNG. MD5 and
// base64 are our own (the standard library ships neither) and the AKAv1-MD5 // base64 are our own (the standard library ships neither) and the AKAv1-MD5
// digest depends on them exactly, so these are pinned hard. // digest depends on them exactly, so these are pinned hard.
import std; import std;
@ -95,6 +96,47 @@ int main() {
Check(Uuid4(r2) == u, "same seed -> same uuid"); Check(Uuid4(r2) == u, "same seed -> same uuid");
} }
// ---- CanonicalIp6: RFC 5952 §4 — the text iproute2/inet_ntop print.
// Global addresses from the documentation prefix only (2001:db8::/32); the
// link-local one is synthetic.
{
auto c = [](std::string_view s) { return CanonicalIp6(s).value_or("<nullopt>"); };
Check(c("2001:DB8:E000:100::105") == "2001:db8:e000:100::105", "canon upper case");
Check(c("2001:0db8:e000:0100:0000:0000:0000:0105") == "2001:db8:e000:100::105", "canon uncompressed, leading zeros");
Check(c("2001:db8:e000:100:0:0:0:105") == "2001:db8:e000:100::105", "canon zero fields -> ::");
Check(c("2001:db8:0:0:1:0:0:1") == "2001:db8::1:0:0:1", "canon first of two equal runs (5952 4.2.3)");
Check(c("2001:0:0:1:0:0:0:1") == "2001:0:0:1::1", "canon the longer run wins (4.2.3)");
Check(c("2001:db8:0:1:1:1:1:1") == "2001:db8:0:1:1:1:1:1", "canon a single zero field stays (4.2.2)");
Check(c("2001:0db8::0001") == "2001:db8::1", "canon leading zeros dropped (RFC 5952 4.1)");
Check(c("2001:db8:0:0:0:0:2:1") == "2001:db8::2:1", "canon the zero run compressed (4.2.1)");
Check(c("2001:db8::0:1") == "2001:db8::1", "canon :: takes the whole run (4.2.1)");
Check(c("1:2:3:4:5:6:7::") == "1:2:3:4:5:6:7:0", "canon :: standing for one field is written out");
Check(c("::1") == "::1", "canon ::1");
Check(c("::") == "::", "canon ::");
Check(c("1::") == "1::", "canon 1::");
Check(c("[2001:db8::1]") == "2001:db8::1", "canon brackets stripped");
// idempotence on inet_ntop-shaped input: the holds file and the state
// file were written in that shape by 0.3.12 and must still compare
for (std::string_view s : {"2001:db8:1111:2222:3333:4444:5555:db43", "2001:db8::105", "2001:db8::1:0:0:1", "fe80::db8:ff:fe00:1"})
Check(c(s) == s, std::format("canon idempotent on {}", s));
for (std::string_view s : {"2001:db8::1::2", "2001:db8:1", "12345::1", "2001:db8::1%qmapmux0.0", "[fe80::1]:5060", "::ffff:10.0.0.1", "10.0.0.1", "pcscf.example.com", "", ":1::2", "1:::2", "::1:", "1:2:3:4:5:6:7:8:9", "2001:db8::g"})
Check(!CanonicalIp6(s), std::format("canon rejects '{}'", s));
Check(CanonicalAddr("2001:DB8::105") == "2001:db8::105", "CanonicalAddr v6");
Check(CanonicalAddr("10.0.0.1") == "10.0.0.1", "CanonicalAddr v4 untouched");
Check(CanonicalAddr("2001:db8::g") == "2001:db8::g", "CanonicalAddr passes a malformed v6 through");
}
// ---- ParseAddressList: the PCSCF_DISCOVERED= hand-off
{
std::vector<std::string> rejected;
auto l = ParseAddressList("2001:DB8::105, 2001:db8::23,,10.1.1.1,2001:db8:0::105, pcscf.example , ", &rejected);
Check(l.size() == 2 && l[0] == "2001:db8::105" && l[1] == "2001:db8::23", "list: canonical, ordered, deduped");
Check(rejected.size() == 2 && rejected[0] == "10.1.1.1" && rejected[1] == "pcscf.example", "list: rejects reported in order");
Check(ParseAddressList("").empty(), "list: empty string");
Check(ParseAddressList(" , ,").empty(), "list: blanks only");
Check(ParseAddressList("2001:db8::1").size() == 1, "list: single entry, no rejected sink");
}
if (Failures == 0) std::println("Util: all tests passed"); if (Failures == 0) std::println("Util: all tests passed");
return Failures; return Failures;
} }