Emergency calling, stage 1: urn:service:sos with a digits fallback

Classify 112/911 (plus EMERGENCY_NUMBERS) at Dial. A classified call
INVITEs urn:service:sos over the existing registration and, on any
non-2xx final not caused by the user hanging up (including the
setup-timeout CANCEL paths), retries once as a plain INVITE of the
dialled digits — the pre-0.3.0 behavior, so classification can never
place a call worse than the status quo. The reverse edge: a 380
Alternative Service whose body carries the emergency indication
upgrades an unclassified call to the sos URN; a bare 380 stays an
error, since promoting an arbitrary redirect would put a
non-emergency call through to a PSAP. An emergency dial preempts an
in-progress call, and an answer racing a deadline-initiated CANCEL is
taken instead of BYE'd (user-initiated CANCEL races still BYE).

No emergency registration, no emergency PDN, no CS fallback, no
SIM-less calling, no AML — and no carrier has confirmed the sos path
end-to-end. The README warning states exactly that.

Assisted-by: Claude:claude-fable-5
This commit is contained in:
Jorijn van der Graaf 2026-08-01 22:29:12 +02:00
commit f2a404855f
8 changed files with 365 additions and 42 deletions

View file

@ -6,7 +6,7 @@
Userspace IMS/VoLTE daemon for mainline Linux phones. Userspace IMS/VoLTE daemon for mainline Linux phones.
> [!WARNING] > [!WARNING]
> imsd currently cannot make emergency calls > Emergency calling is unverified
Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but
can't be used for actual call audio. imsd implements the whole IMS client in can't be used for actual call audio. imsd implements the whole IMS client in
@ -22,20 +22,6 @@ full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via
`Accept`, and carry media the same way — driven end-to-end from a stock `Accept`, and carry media the same way — driven end-to-end from a stock
dialer over the frozen D-Bus ABI. Current state: dialer over the frozen D-Bus ABI. Current state:
| Piece | State |
|---|---|
| SIP message layer + TCP framing (`Imsd:Sip`) | done, tested |
| SDP offer/answer for AMR-WB (`Imsd:Sdp`) | done, tested |
| USIM AKA + AKAv1-MD5 digest, SIM parsers (`Imsd:Aka`) | done, tested |
| IPsec SA commands + warm-SA reader (`Imsd:Ipsec`) | done, tested |
| SIP request/response builders (`Imsd:Messages`) | done, byte-pinned |
| Outgoing + incoming call state machine (`Imsd:Engine`) | done, tested |
| Registration (fresh + warm resume + keepalive refresh, MMTEL feature tags) | done |
| RTP + AMR-WB media plane into PipeWire (`imsd-media`) | done |
| D-Bus service `net.catcrafts.IMS1` (register, Dial, Accept, HangUp, live media) | done |
| Plasma Dialer session backend (`imsd-dialerd`, org.kde.telephony.*) | done |
| DTMF (`SendDtmf`, RFC 4733) | not yet implemented |
The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose
every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the
`ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev `ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev
@ -165,6 +151,7 @@ PCSCF=2001:db8::105
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) | | `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) |
| `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`) | | `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`) |
| `EMERGENCY_NUMBERS` | *(empty)* | comma-separated additions to the builtin 112/911 emergency numbers (e.g. a private test core's short code). SIM `EF_ECC` is not read yet |
| `RTP_PORT` | `50004` | local RTP port advertised in SDP | | `RTP_PORT` | `50004` | local RTP port advertised in SDP |
| `PRECOND` | `0` | `1` offers SDP QoS preconditions | | `PRECOND` | `0` | `1` offers SDP QoS preconditions |
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200/INVITE dumps (mode 0600) for debugging — they contain your IMSI/MSISDN and addresses | | `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200/INVITE dumps (mode 0600) for debugging — they contain your IMSI/MSISDN and addresses |

View file

@ -41,7 +41,7 @@ import Imsd;
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.2.7"; constexpr const char* Version = "0.3.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";
@ -571,6 +571,18 @@ public:
precond_ = EnvOr("PRECOND", "0") == "1"; precond_ = EnvOr("PRECOND", "0") == "1";
ealgOffer_ = EnvOr("EALG", "aes-cbc"); ealgOffer_ = EnvOr("EALG", "aes-cbc");
mediaBin_ = ResolveMediaBin(); mediaBin_ = ResolveMediaBin();
// EMERGENCY_NUMBERS: comma-separated additions to the builtin
// 112/911 (a lab core's advertised short code, carrier extras).
std::string extra = EnvOr("EMERGENCY_NUMBERS", "");
for (std::size_t pos = 0; pos <= extra.size();) {
std::size_t comma = extra.find(',', pos);
std::size_t end = comma == std::string::npos ? extra.size() : comma;
std::string n = extra.substr(pos, end - pos);
std::erase(n, ' ');
if (!n.empty()) emergencyExtra_.push_back(std::move(n));
if (comma == std::string::npos) break;
pos = comma + 1;
}
} }
void SetLocal(std::string l) { local_ = std::move(l); } void SetLocal(std::string l) { local_ = std::move(l); }
@ -672,6 +684,7 @@ private:
// config // config
std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_;
std::vector<std::string> emergencyExtra_;
int pcscfPort_ = 5060; int pcscfPort_ = 5060;
int rtpPort_ = 50004; int rtpPort_ = 50004;
bool precond_ = false; bool precond_ = false;
@ -1098,20 +1111,34 @@ 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_);
if (call_) { if (call_) {
Log(std::format("dial({}) refused: call in progress", c.number)); if (!emergency) {
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; Log(std::format("dial({}) refused: call in progress", c.number));
PostAdded(bad); CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0};
PostState(c.uni, "terminated", "error"); PostAdded(bad);
PostDeleted(c.uni); PostState(c.uni, "terminated", "error");
return; PostDeleted(c.uni);
return;
}
// an emergency dial preempts whatever exists (TS 22.101):
// release it; if it lingers awaiting a CANCEL's 487, abandon
// it — the late final is ignored by Call-ID mismatch
Log(std::format("emergency dial: preempting call {}", call_->Uni()));
Execute(call_->OnHangup());
MaybeDropCall();
if (call_) { Execute(call_->Abandon("preempted by emergency dial")); MaybeDropCall(); }
} }
// a dead client flow fails the INVITE instantly — give the // a dead client flow fails the INVITE instantly — give the
// reconnect path one immediate shot first (user action beats // reconnect path one immediate shot first (user action beats
// the backoff timer) // the backoff timer)
if (!sip_.Alive()) { nextReconnect_ = 0; MaybeReconnect(); } if (!sip_.Alive()) { nextReconnect_ = 0; MaybeReconnect(); }
RefreshPani(); RefreshPani();
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_); if (emergency)
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
"fallback plain INVITE — stage 1, no emergency registration/PDN; "
"carrier-side behavior UNVERIFIED", c.number));
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency);
CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0};
PostAdded(info); PostAdded(info);
bool ok = Execute(call_->Start()); bool ok = Execute(call_->Start());

View file

@ -13,10 +13,12 @@ state and returns a list of Actions for the daemon shell to carry out — send
this on the client flow, answer the request on the channel it came in on, this on the client flow, answer the request on the channel it came in on,
start/stop the media leg, emit this D-Bus state change, arm this deadline. start/stop the media leg, emit this D-Bus state change, arm this deadline.
That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx 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, and the finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, the
terminating (UAS) side: 100/180 on an inbound INVITE, 200-with-answer on emergency attempt chain (urn:service:sos first for a classified number, one
Accept, 486 reject, the remote-CANCEL 200+487 pair — unit-testable against fallback to a plain INVITE of the digits; a 380 Alternative Service upgrade
recorded network dialogs with no I/O (tests/Engine). the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE,
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair —
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
(dialing/ringing/incoming/active/terminated; outgoing/incoming/accepted/ (dialing/ringing/incoming/active/terminated; outgoing/incoming/accepted/
@ -94,13 +96,12 @@ export namespace imsd::engine {
class CallMachine { class CallMachine {
public: public:
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond) // `emergency`: the shell classified the dialled string as an
// emergency number — the first attempt targets urn:service:sos.
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond, bool emergency = false)
: ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)), : ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)),
rtpPort_(rtpPort), precond_(precond) { rtpPort_(rtpPort), precond_(precond), attemptSos_(emergency) {
d_.ruri = imsd::msg::RuriFor(number_, ctx_.id.domain); ResetDialog();
d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local);
d_.itag = rng_.Token(10);
d_.invBranch = rng_.Token(20);
} }
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort) CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort)
@ -135,8 +136,12 @@ export namespace imsd::engine {
const std::string& Reason() const { return reason_; } const std::string& Reason() const { return reason_; }
bool Terminated() const { return state_ == CallState::Terminated; } bool Terminated() const { return state_ == CallState::Terminated; }
// Build + queue the INVITE and arm the ring timeout. // Build + queue the INVITE for the current attempt and arm the ring
// timeout.
std::vector<Action> Start() { std::vector<Action> Start() {
if (attemptSos_) sosTried_ = true;
else
plainTried_ = true;
imsd::sdp::Offer offer; imsd::sdp::Offer offer;
offer.local = ctx_.local; offer.local = ctx_.local;
offer.rtpPort = rtpPort_; offer.rtpPort = rtpPort_;
@ -239,13 +244,17 @@ export namespace imsd::engine {
return a; // 1xx other than 100<code<200 (only 100): nothing return a; // 1xx other than 100<code<200 (only 100): nothing
if (code == 200) { if (code == 200) {
a.push_back(Send(imsd::msg::BuildAck2xx(ctx_, d_, rng_.Token(20), std::string(imsd::sip::Header(msg, "To").value_or(""))))); a.push_back(Send(imsd::msg::BuildAck2xx(ctx_, d_, rng_.Token(20), std::string(imsd::sip::Header(msg, "To").value_or("")))));
if (cancelled_) { if (cancelled_ && userCancelled_) {
// answered in the CANCEL race — we no longer want it // answered in the user's CANCEL race — we no longer want it
nextCseq_++; nextCseq_++;
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20)))); a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
Append(a, Finish("local-hangup", "answered during our CANCEL; BYE sent")); Append(a, Finish("local-hangup", "answered during our CANCEL; BYE sent"));
return a; return a;
} }
// An answer racing a DEADLINE-initiated CANCEL is still
// wanted — the user never hung up. Matters most when the sos
// attempt is answered at the wire just as the timeout fires:
// BYEing an answering PSAP is the worst possible outcome.
MediaLeg leg = ParseAnswer(); MediaLeg leg = ParseAnswer();
a.push_back(StartMediaAct(leg)); a.push_back(StartMediaAct(leg));
state_ = CallState::Active; state_ = CallState::Active;
@ -255,6 +264,10 @@ export namespace imsd::engine {
// non-2xx final // non-2xx final
a.push_back(Send(imsd::msg::BuildAckNon2xx(ctx_, d_, std::string(imsd::sip::Header(msg, "To").value_or(""))))); a.push_back(Send(imsd::msg::BuildAckNon2xx(ctx_, d_, std::string(imsd::sip::Header(msg, "To").value_or("")))));
std::string fin = WithReason(std::format("INVITE final {}", code), msg); std::string fin = WithReason(std::format("INVITE final {}", code), msg);
if (auto sos = FallbackTo(code, msg)) {
Append(a, Retry(*sos, fin));
return a;
}
if (cancelled_ || code == 487) Append(a, Finish("local-hangup", fin)); if (cancelled_ || code == 487) Append(a, Finish("local-hangup", fin));
else if (code == 486 || code == 480 || code == 603) Append(a, Finish("refused-or-busy", fin)); else if (code == 486 || code == 480 || code == 603) Append(a, Finish("refused-or-busy", fin));
else else
@ -338,14 +351,27 @@ export namespace imsd::engine {
nextCseq_++; nextCseq_++;
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20)))); a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
Append(a, Finish("local-hangup", "HangUp; BYE sent")); Append(a, Finish("local-hangup", "HangUp; BYE sent"));
} else if ((state_ == CallState::Dialing || state_ == CallState::Ringing) && !cancelled_) { } else if (state_ == CallState::Dialing || state_ == CallState::Ringing) {
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_))); // even when a deadline already CANCELled: the user's intent
cancelled_ = true; // also kills the emergency fallback chain
a.push_back(Deadline(Cancel487Timeout)); userCancelled_ = true;
if (!cancelled_) {
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
cancelled_ = true;
a.push_back(Deadline(Cancel487Timeout));
}
} }
return a; return a;
} }
// The shell is abandoning this machine (an emergency dial preempts
// it while it waits on a CANCEL's 487): terminate bookkeeping only —
// the CANCEL already sent stands, and the late final for this
// Call-ID is ignored by dispatch.
std::vector<Action> Abandon(std::string_view detail) {
return Finish("local-hangup", detail);
}
// The media subprocess exited. code 3 = the downlink RTP dried up // The media subprocess exited. code 3 = the downlink RTP dried up
// (far-end hangup); anything else = the leg died. Either way, if the // (far-end hangup); anything else = the leg died. Either way, if the
// call was up we release it network-side and tear down. // call was up we release it network-side and tear down.
@ -375,6 +401,10 @@ export namespace imsd::engine {
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_))); a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
cancelled_ = true; cancelled_ = true;
a.push_back(Deadline(Cancel487Timeout)); a.push_back(Deadline(Cancel487Timeout));
} else if (auto sos = FallbackTo(0, {})) {
// the 487 never came; an abandoned sos attempt still gets
// its digits fallback rather than dying silently
Append(a, Retry(*sos, "no final after CANCEL"));
} else { } else {
Append(a, Finish("error", "setup deadline expired")); Append(a, Finish("error", "setup deadline expired"));
} }
@ -403,9 +433,67 @@ export namespace imsd::engine {
imsd::msg::Dialog d_; imsd::msg::Dialog d_;
int nextCseq_ = 2; int nextCseq_ = 2;
bool cancelled_ = false; bool cancelled_ = false;
// The emergency attempt chain (outgoing only). attemptSos_ is what
// the CURRENT attempt targets; the tried flags bound the chain to
// one step in each direction, never a loop.
bool attemptSos_ = false;
bool sosTried_ = false;
bool plainTried_ = false;
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_;
// Fresh dialog identity for the current attempt. A fallback INVITE
// after a final response is a new transaction AND a clean new dialog
// attempt (fresh Call-ID) — the shell's dispatch then ignores late
// responses to the abandoned attempt by Call-ID mismatch.
void ResetDialog() {
d_ = {};
d_.ruri = attemptSos_ ? std::string(imsd::msg::SosUrn)
: imsd::msg::RuriFor(number_, ctx_.id.domain);
d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local);
d_.itag = rng_.Token(10);
d_.invBranch = rng_.Token(20);
nextCseq_ = 2;
pracked_.clear();
answerSdp_.clear();
cancelled_ = false;
userCancelled_ = false;
}
// The emergency fallback chain — where the next attempt (if any)
// points. `msg` may be empty (deadline paths).
// - a sos INVITE that fails for any reason other than the user
// hanging up is re-attempted as a plain INVITE of the dialled
// digits: the pre-emergency-support behavior, so classification
// can only improve on the status quo, never block a call the old
// code would have placed.
// - a plain INVITE answered 380 Alternative Service whose body
// names emergency (TS 24.229 5.1.6: the network classified a
// number we didn't) is re-attempted as urn:service:sos. A 380
// without that indication terminates as an error: promoting an
// arbitrary redirect would put a non-emergency call through to a
// PSAP.
std::optional<bool> FallbackTo(int code, std::string_view msg) const {
if (userCancelled_) return std::nullopt;
if (attemptSos_) return plainTried_ ? std::nullopt : std::optional(false);
if (code == 380 && !sosTried_ && Body(msg).contains("emergency")) return true;
return std::nullopt;
}
// Switch the attempt and send its INVITE. Same uni: the dialer keeps
// seeing one continuous call, back in "dialing".
std::vector<Action> Retry(bool sos, std::string_view why) {
attemptSos_ = sos;
ResetDialog();
state_ = CallState::Dialing;
std::vector<Action> a;
a.push_back(LogAct(std::format("call {}: {} — retrying as {}", uni_, why, sos ? "emergency (urn:service:sos)" : "plain INVITE of the digits")));
a.push_back(StateAct("outgoing"));
Append(a, Start());
return a;
}
static std::string_view Body(std::string_view msg) { static std::string_view Body(std::string_view msg) {
std::size_t at = msg.find("\r\n\r\n"); std::size_t at = msg.find("\r\n\r\n");
return at == std::string_view::npos ? std::string_view{} return at == std::string_view::npos ? std::string_view{}

View file

@ -133,6 +133,27 @@ export namespace imsd::msg {
return s; return s;
} }
// The service URN an emergency INVITE targets (RFC 5031); Request-URI and
// To both carry it (TS 24.229 5.1.6). No sos subtype: a dialled string
// does not say police/ambulance/fire.
inline constexpr std::string_view SosUrn = "urn:service:sos";
// Emergency-number classification. 112 and 911 are emergency numbers on
// every network (TS 22.101 §10.1.1), so they are compiled in; `extra`
// adds carrier- or lab-specific numbers (the EMERGENCY_NUMBERS list —
// e.g. a private test core's advertised short code). Exact match after
// stripping "1 1 2"-style separators; prefixed/suffixed strings ("0112",
// "1120") are ordinary numbers. SIM EF_ECC is not read yet.
inline bool IsEmergency(std::string_view target, std::span<const std::string> extra = {}) {
std::string t;
for (char ch : target)
if (ch != ' ' && ch != '-' && ch != '.') t.push_back(ch);
if (t == "112" || t == "911") return true;
for (const std::string& e : extra)
if (!e.empty() && t == e) return true;
return false;
}
// Map a dialled string to a Request-URI (imscall.ruri_for): E.164/national // Map a dialled string to a Request-URI (imscall.ruri_for): E.164/national
// numbers become user=phone SIP URIs at the home domain; a bare short code // numbers become user=phone SIP URIs at the home domain; a bare short code
// gets phone-context. // gets phone-context.

View file

@ -5,7 +5,7 @@
# (or replaced here by a bootstrap invocation). Until then this documents # (or replaced here by a bootstrap invocation). Until then this documents
# the packaging shape. # the packaging shape.
pkgname=imsd pkgname=imsd
pkgver=0.2.7 pkgver=0.3.0
pkgrel=0 pkgrel=0
pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd" url="https://forgejo.catcrafts.net/Catcrafts/imsd"

View file

@ -6,7 +6,7 @@
# apk-managed on the phone while crafter-build itself isn't packaged for # apk-managed on the phone while crafter-build itself isn't packaged for
# Alpine yet. The source tarball is produced by packaging/make-bin-tarball.sh. # Alpine yet. The source tarball is produced by packaging/make-bin-tarball.sh.
pkgname=imsd pkgname=imsd
pkgver=0.2.7 pkgver=0.3.0
pkgrel=0 pkgrel=0
pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones" pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd" url="https://forgejo.catcrafts.net/Catcrafts/imsd"

View file

@ -100,6 +100,24 @@ namespace {
return nullptr; return nullptr;
} }
// A 380 Alternative Service final; `body` empty = no emergency
// indication (a bare redirect).
std::string Alt380(std::string_view callid, std::string_view body) {
return std::format(
"SIP/2.0 380 Alternative Service\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\nCSeq: 1 INVITE\r\n"
"Content-Type: application/3gpp-ims+xml\r\n"
"Content-Length: {}\r\n\r\n{}",
callid, body.size(), body);
}
constexpr std::string_view EmergencyXml =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
"<ims-3gpp version=\"1\"><alternative-service><type><emergency/></type>"
"<reason>emergency call detected</reason></alternative-service></ims-3gpp>";
// An inbound (terminating) INVITE the way KPN's P-CSCF delivers one: // An inbound (terminating) INVITE the way KPN's P-CSCF delivers one:
// two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer // two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer
// at non-default payload types (104/105) with telephone-event. // at non-default payload types (104/105) with telephone-event.
@ -525,6 +543,150 @@ int main() {
Check(m.Terminated(), "terminated"); Check(m.Terminated(), "terminated");
} }
// ================= emergency (stage 1) ================================
// ---- emergency dial: the INVITE targets the sos URN over the normal
// registration; a 200 is answered like any call
{
imsd::util::Rng rng(41);
CallMachine m(c, rng, "ims-call-15", "112", 50004, false, /*emergency=*/true);
auto start = m.Start();
const Action* inv = Sent(start, "INVITE ");
Check(inv && inv->text.starts_with("INVITE urn:service:sos SIP/2.0\r\n"), "emergency R-URI is the sos URN");
Check(inv && inv->text.contains("To: <urn:service:sos>\r\n"), "emergency To carries the URN");
Check(inv && inv->text.contains("Route: <sip:[2001:db8::105]:6000;lr>\r\n"), "emergency INVITE rides the registration route");
Check(inv && inv->text.contains("m=audio"), "emergency INVITE carries an SDP offer");
Check(Has(start, Action::Type::SetDeadline), "emergency dial arms the timeout");
auto a200 = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(a200, "ACK ") != nullptr, "sos 200 is ACKed");
Check(Has(a200, Action::Type::StartMedia), "sos answer starts media");
Check(m.State() == CallState::Active, "sos call active");
}
// ---- sos rejected -> one fallback: a fresh-dialog plain INVITE of the
// digits, whose answer is then taken normally
{
imsd::util::Rng rng(43);
CallMachine m(c, rng, "ims-call-16", "112", 50004, false, true);
m.Start();
std::string firstId = m.CallId();
auto a = m.OnInviteResponse(Final(m.CallId(), 500, "Server Internal Error"));
Check(Sent(a, "ACK ") != nullptr, "sos final is ACKed");
const Action* inv2 = Sent(a, "INVITE ");
Check(inv2 && inv2->text.starts_with("INVITE sip:112;phone-context="), "fallback INVITE dials the digits");
Check(m.CallId() != firstId, "fallback is a fresh dialog");
Check(inv2 && inv2->text.contains(std::format("Call-ID: {}\r\n", m.CallId())), "fallback INVITE carries the new Call-ID");
Check(inv2 && inv2->text.contains("CSeq: 1 INVITE\r\n"), "fallback INVITE restarts CSeq");
Check(Has(a, Action::Type::SetDeadline), "fallback re-arms the timeout");
Check(!m.Terminated(), "machine survives into the fallback");
auto a2 = m.OnInviteResponse(R200(m.CallId()));
Check(Has(a2, Action::Type::StartMedia), "fallback answer starts media");
Check(m.State() == CallState::Active, "active on the fallback leg");
}
// ---- chain exhausted: the fallback leg's final terminates, no 3rd try
{
imsd::util::Rng rng(45);
CallMachine m(c, rng, "ims-call-17", "112", 50004, false, true);
m.Start();
m.OnInviteResponse(Final(m.CallId(), 500, "x")); // sos fails
auto a = m.OnInviteResponse(Final(m.CallId(), 486, "Busy Here"));
Check(Sent(a, "INVITE ") == nullptr, "no third attempt");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "refused-or-busy", "fallback final keeps its own reason");
Check(m.Terminated(), "terminated when the chain is exhausted");
}
// ---- user hangup during the sos attempt kills the chain: 487 ends it
{
imsd::util::Rng rng(47);
CallMachine m(c, rng, "ims-call-18", "112", 50004, false, true);
m.Start();
auto ah = m.OnHangup();
Check(Sent(ah, "CANCEL ") != nullptr, "user hangup CANCELs the sos attempt");
auto a = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
Check(Sent(a, "INVITE ") == nullptr, "user cancel suppresses the fallback");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "local-hangup", "user-cancelled sos -> local-hangup");
Check(m.Terminated(), "terminated");
}
// ---- setup timeout on the sos attempt: CANCEL, then the 487 falls back
{
imsd::util::Rng rng(49);
CallMachine m(c, rng, "ims-call-19", "112", 50004, false, true);
m.Start();
auto a1 = m.OnDeadline();
Check(Sent(a1, "CANCEL ") != nullptr, "sos timeout CANCELs");
auto a2 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
Check(Sent(a2, "INVITE sip:112;phone-context=") != nullptr, "timeout-cancelled sos falls back to the digits");
Check(!m.Terminated(), "chain continues after the timeout");
}
// ---- timeout CANCEL whose 487 never comes: second deadline falls back
{
imsd::util::Rng rng(51);
CallMachine m(c, rng, "ims-call-20", "112", 50004, false, true);
m.Start();
m.OnDeadline(); // CANCEL
auto a = m.OnDeadline(); // no 487 arrived
Check(Sent(a, "INVITE sip:112;phone-context=") != nullptr, "silent sos attempt still gets its fallback");
Check(!m.Terminated(), "not terminated while the chain has a step left");
}
// ---- answered during a DEADLINE-initiated CANCEL race: taken, not BYEd
{
imsd::util::Rng rng(53);
CallMachine m(c, rng, "ims-call-21", "112", 50004, false, true);
m.Start();
m.OnDeadline(); // CANCEL (not the user)
auto ar = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed");
Check(Sent(ar, "BYE ") == nullptr, "deadline race keeps the answered call");
Check(Has(ar, Action::Type::StartMedia), "deadline race starts media");
Check(m.State() == CallState::Active, "active after the deadline race");
}
// ---- network 380 with the emergency indication upgrades a plain call
// to sos; if that also fails the chain ends (plain already tried)
{
imsd::util::Rng rng(55);
CallMachine m(c, rng, "ims-call-22", "0612345678", 50004, false); // NOT classified
m.Start();
auto a = m.OnInviteResponse(Alt380(m.CallId(), EmergencyXml));
Check(Sent(a, "ACK ") != nullptr, "380 is ACKed");
const Action* inv = Sent(a, "INVITE ");
Check(inv && inv->text.starts_with("INVITE urn:service:sos SIP/2.0\r\n"), "emergency 380 upgrades to the sos URN");
Check(!m.Terminated(), "call survives into the sos attempt");
auto a2 = m.OnInviteResponse(Final(m.CallId(), 500, "x"));
Check(Sent(a2, "INVITE ") == nullptr, "no loop back to the already-tried plain leg");
Check(m.Terminated(), "terminated after the upgraded attempt fails");
}
// ---- a bare 380 (no emergency indication) is an error, never sos
{
imsd::util::Rng rng(57);
CallMachine m(c, rng, "ims-call-23", "0612345678", 50004, false);
m.Start();
auto a = m.OnInviteResponse(Alt380(m.CallId(), ""));
Check(Sent(a, "INVITE ") == nullptr, "bare 380 places no emergency call");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "error", "bare 380 -> error");
Check(m.Terminated(), "terminated on bare 380");
}
// ---- Abandon (emergency preemption): terminate bookkeeping only
{
imsd::util::Rng rng(59);
CallMachine m(c, rng, "ims-call-24", "1233", 50004, false);
m.Start();
m.OnHangup(); // CANCEL sent, awaiting 487
auto a = m.Abandon("preempted by emergency dial");
Check(Sent(a, "BYE ") == nullptr && Sent(a, "CANCEL ") == nullptr, "Abandon sends nothing further");
Check(Has(a, Action::Type::Deleted), "Abandon deletes the call");
Check(m.Terminated(), "abandoned machine is terminated");
}
if (Failures == 0) std::println("Engine: all tests passed"); if (Failures == 0) std::println("Engine: all tests passed");
return Failures; return Failures;
} }

View file

@ -222,6 +222,44 @@ int main() {
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer"); Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer");
} }
// ---- emergency INVITE: byte-identical to the plain INVITE except the
// Request-URI and To carry the service URN (RFC 5031 / TS 24.229 5.1.6)
{
Dialog d;
d.ruri = std::string(SosUrn);
d.callid = "CALLID789@2001:db8::db43";
d.itag = "ITAG123456";
d.invBranch = "INVBRANCH0000000000";
std::string want(Invite);
auto swap = [&](std::string_view from, std::string_view to) {
std::size_t at = want.find(from);
Check(at != std::string::npos, "sos pin template line present");
if (at != std::string::npos) want.replace(at, from.size(), to);
};
swap("INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n",
"INVITE urn:service:sos SIP/2.0\r\n");
swap("To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n",
"To: <urn:service:sos>\r\n");
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), want, "emergency INVITE (urn:service:sos)");
}
// ---- IsEmergency classification
Check(IsEmergency("112"), "112 is emergency");
Check(IsEmergency("911"), "911 is emergency");
Check(IsEmergency("1 1 2"), "space separators stripped");
Check(IsEmergency("1-1-2"), "dash separators stripped");
Check(!IsEmergency("0112"), "prefixed 112 is not emergency");
Check(!IsEmergency("1120"), "suffixed 112 is not emergency");
Check(!IsEmergency("+31612345678"), "E.164 number is not emergency");
Check(!IsEmergency(""), "empty string is not emergency");
Check(!IsEmergency("199"), "extra number not classified by default");
{
std::vector<std::string> extra = {"199", "088"};
Check(IsEmergency("199", extra), "EMERGENCY_NUMBERS extra matches");
Check(IsEmergency("088", extra), "second extra matches");
Check(!IsEmergency("19", extra), "extra requires exact match");
}
// ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks) // ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks)
{ {
Dialog d; Dialog d;