Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b5de7a60b | |||
|
547361a4d4 |
|||
|
e31887bf21 |
|||
|
92fba40da0 |
|||
|
29d624fc55 |
|||
|
108afcce96 |
|||
| 78685c4b41 | |||
|
d4a0759e57 |
|||
|
7a3dcc8951 |
|||
|
1d28063700 |
|||
|
bd52afd931 |
|||
|
a89ca5a60c |
|||
|
40a960f749 |
|||
|
21cb0f870d |
|||
|
b60a3633c1 |
|||
|
146d4368d0 |
|||
|
9bdbb96b02 |
|||
|
a7caceb1b0 |
|||
|
f3cd70d05e |
|||
|
729ccaa01d |
|||
|
53b8c5c8e7 |
|||
|
799fd1a802 |
|||
|
a00b14a5a9 |
27 changed files with 3363 additions and 427 deletions
58
README.md
58
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed");
|
// rebuilt from the kernel's SA). Returns (200 msg, used cseq). A 401
|
||||||
std::string m = AwaitFinal(callid, cseq, "re-REGISTER");
|
// asks for re-authentication over temporary SAs on the proposed ports,
|
||||||
auto st = imsd::sip::Status(m);
|
// which imsd does not do: it is thrown as a refusal whose recovery is a
|
||||||
if (st == 200) return {m, cseq};
|
// fresh registration (RegRecovery's 401 rule). Every refresh of the
|
||||||
if (st == 401 && !challenged) {
|
// pre-0.3.11 shape on record from O2 UK — an initial registration's
|
||||||
challenged = true;
|
// empty digest, the in-use SPIs — was refused 503 (2026-09-24 under
|
||||||
auto nonce = QuotedAfter(m, "nonce=\"");
|
// 0.3.9, 2026-10-08 under 0.3.10) while the fresh registrations that
|
||||||
auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt;
|
// followed were accepted; KPN accepted both shapes without a challenge.
|
||||||
if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh");
|
std::pair<std::string, long> Reregister(const std::string& callid, const std::string& ftag, long cseq) {
|
||||||
std::vector<std::uint8_t> rnd(raw->begin(), raw->begin() + 16);
|
std::string auth = reg_.authNonce.empty() ? imsd::msg::AuthEmpty(ctx_)
|
||||||
std::vector<std::uint8_t> autn(raw->begin() + 16, raw->begin() + 32);
|
: imsd::msg::AuthAka(ctx_, reg_.authNonce, reg_.authCnonce, reg_.authResponse);
|
||||||
auto aka = Authenticate(rnd, autn);
|
std::uint32_t spiUc = 0, spiUs = 0;
|
||||||
if (!aka) throw std::runtime_error("AKA failed on refresh");
|
do spiUc = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUc == reg_.spiUc || spiUc == reg_.spiUs);
|
||||||
std::string cnonce = rng_.Token(16);
|
do spiUs = rng_.UInt(0x10000, 0xFFFFFFF); while (spiUs == reg_.spiUc || spiUs == reg_.spiUs || spiUs == spiUc);
|
||||||
std::string response = imsd::aka::DigestAkav1(*nonce, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain);
|
std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth, PortUcNext);
|
||||||
auth = imsd::msg::AuthAka(ctx_, *nonce, cnonce, response);
|
if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed");
|
||||||
cseq++;
|
std::string m = AwaitFinal(callid, cseq, "re-REGISTER");
|
||||||
continue;
|
auto st = imsd::sip::Status(m);
|
||||||
}
|
if (st == 200) return {m, cseq};
|
||||||
DumpRaw("imsd-register-fail.raw", m);
|
DumpRaw("imsd-register-fail.raw", m);
|
||||||
throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
239
implementations/pcscf.cpp
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
260
interfaces/Imsd-Qmi.cppm
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
29
project.cpp
29
project.cpp
|
|
@ -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() });
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
95
tests/Qmi/main.cpp
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue