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:
parent
6e77823933
commit
f2a404855f
8 changed files with 365 additions and 42 deletions
17
README.md
17
README.md
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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,7 +1111,9 @@ 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_) {
|
||||||
|
if (!emergency) {
|
||||||
Log(std::format("dial({}) refused: call in progress", c.number));
|
Log(std::format("dial({}) refused: call in progress", c.number));
|
||||||
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0};
|
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0};
|
||||||
PostAdded(bad);
|
PostAdded(bad);
|
||||||
|
|
@ -1106,12 +1121,24 @@ private:
|
||||||
PostDeleted(c.uni);
|
PostDeleted(c.uni);
|
||||||
return;
|
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());
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
// even when a deadline already CANCELled: the user's intent
|
||||||
|
// also kills the emergency fallback chain
|
||||||
|
userCancelled_ = true;
|
||||||
|
if (!cancelled_) {
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
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{}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue