Compare commits
25 changed files with 357 additions and 3012 deletions
58
README.md
58
README.md
|
|
@ -6,26 +6,36 @@
|
|||
Userspace IMS/VoLTE daemon for mainline Linux phones.
|
||||
|
||||
Most Linux phones drive the modem's internal IMS engine.
|
||||
imsd implements the whole IMS client in userspace instead wired into
|
||||
imsd implements the whole IMS client in userspace instead:
|
||||
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
|
||||
can place and receive real VoLTE calls on a commercial network.
|
||||
|
||||
Calls verified by community members to be fully working:
|
||||
## Status
|
||||
|
||||
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 |
|
||||
|---|---|---|
|
||||
| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | KPN NL |
|
||||
| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telekom Deutschland GER |
|
||||
| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Phonero |
|
||||
| The Fairphone (Gen. 6) | Nura, Linux 7.1.2 | Telia Norge |
|
||||
| The Fairphone (Gen. 6) | Nura, Linux 7.2.0 | o2-uk |
|
||||
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | KPN NL |
|
||||
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telekom Deutschland GER |
|
||||
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Phonero |
|
||||
| The Fairphone (Gen. 6) | postmarketOS, Linux 7.1.2 | Telia Norge |
|
||||
|
||||
## What it needs
|
||||
|
||||
imsd talks to the modem through ModemManager plus `qmicli` over QRTR, installs
|
||||
its security associations with `ip xfrm` and plays audio through PipeWire, so
|
||||
in practice: a Qualcomm modem on a mainline kernel with working
|
||||
WWAN/ModemManager integration, kernel ESP, PipeWire, systemd and polkit.
|
||||
WWAN/ModemManager integration, kernel ESP, PipeWire, systemd, polkit and
|
||||
your carrier's P-CSCF address.
|
||||
|
||||
## Install and configure
|
||||
|
||||
|
|
@ -39,6 +49,25 @@ Both can be found in the packages tab:
|
|||
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
|
||||
|
||||
imsd itself is desktop-agnostic: everything a dialer needs is on the system
|
||||
|
|
@ -67,20 +96,16 @@ session and root may place, answer and end calls; an ssh login may not.
|
|||
|
||||
## Full config:
|
||||
|
||||
Configuration is environment variables in `/etc/imsd.env`, none are required.
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `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` | *(none — required)* | P-CSCF address (IPv6); without it registration fails with a clear error |
|
||||
| `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` |
|
||||
| `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) |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `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) 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 |
|
||||
| `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 |
|
||||
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) |
|
||||
| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
|
||||
| `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 |
|
||||
|
|
@ -91,7 +116,6 @@ Configuration is environment variables in `/etc/imsd.env`, none are required.
|
|||
| `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 |
|
||||
| `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
|
||||
(`imsd::util::PortUc`/`PortUs`) — changing them in source also requires
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ reach the bus via g_idle_add, where the calls table + status snapshot live
|
|||
#include <gio/gio.h>
|
||||
#include <glib-unix.h>
|
||||
#include <ifaddrs.h>
|
||||
#include <limits.h>
|
||||
#include <netinet/in.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
|
|
@ -49,7 +48,7 @@ import ImsdBus;
|
|||
namespace {
|
||||
|
||||
// ---- static config (env-overridable, same knobs as imsd.py) ---------------
|
||||
constexpr const char* Version = "1.1.0";
|
||||
constexpr const char* Version = "0.3.12";
|
||||
constexpr const char* BusName = "net.catcrafts.IMS1";
|
||||
constexpr const char* ObjPath = "/net/catcrafts/IMS1";
|
||||
constexpr const char* Iface = "net.catcrafts.IMS1";
|
||||
|
|
@ -115,35 +114,6 @@ std::string RedactKeys(const std::vector<std::string>& argv) {
|
|||
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:") -------
|
||||
|
||||
std::optional<std::string> QuotedAfter(std::string_view text, std::string_view key) {
|
||||
|
|
@ -297,31 +267,6 @@ void LogAudioSession() {
|
|||
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 ------------------------------------------------------
|
||||
socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) {
|
||||
std::memset(&ss, 0, sizeof ss);
|
||||
|
|
@ -726,16 +671,16 @@ std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.stat
|
|||
// 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) {
|
||||
void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport) {
|
||||
std::string ss = r.securityServer.find_first_of("\"\\\n") == std::string::npos ? r.securityServer : std::string();
|
||||
std::string j = std::format(
|
||||
"{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, "
|
||||
"\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", "
|
||||
"\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\", "
|
||||
"\"auth_nonce\": \"{}\", \"auth_cnonce\": \"{}\", \"auth_response\": \"{}\", "
|
||||
"\"security_server\": \"{}\", \"pcscf\": \"{}\"}}",
|
||||
"\"security_server\": \"{}\"}}",
|
||||
r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor,
|
||||
r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse, ss, pcscf);
|
||||
r.contactUser, transport, r.authNonce, r.authCnonce, r.authResponse, ss);
|
||||
std::string path = StatePath();
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
|
||||
|
|
@ -759,7 +704,7 @@ void DumpRaw(std::string_view name, std::string_view msg) {
|
|||
}
|
||||
|
||||
struct PersistedState {
|
||||
std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer, pcscf;
|
||||
std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport, authNonce, authCnonce, authResponse, securityServer;
|
||||
std::optional<long> cseq, spiUc, spiUs, expiry;
|
||||
};
|
||||
std::optional<PersistedState> LoadState() {
|
||||
|
|
@ -781,7 +726,6 @@ std::optional<PersistedState> LoadState() {
|
|||
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.spiUc = IntAfter(s, "\"spi_uc\": ");
|
||||
ps.spiUs = IntAfter(s, "\"spi_us\": ");
|
||||
|
|
@ -805,11 +749,11 @@ struct StatusSnapshot {
|
|||
|
||||
// engine -> main-loop event
|
||||
struct Event {
|
||||
enum class Type { Added, State, Deleted, Registration, Status, Fatal, Idle } type;
|
||||
enum class Type { Added, State, Deleted, Registration, Status, Fatal } type;
|
||||
CallInfo info; // Added
|
||||
std::string uni, state, reason; // State/Deleted
|
||||
StatusSnapshot status; // Registration/Status
|
||||
std::string text; // Fatal, Idle
|
||||
std::string text; // Fatal
|
||||
};
|
||||
|
||||
GDBusConnection* DbusConn = nullptr;
|
||||
|
|
@ -876,8 +820,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); }
|
||||
return;
|
||||
}
|
||||
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", h.refusals);
|
||||
std::string j = std::format("{{\"pdn\": \"{}\", \"boot\": \"{}\", \"written_at\": {}, \"hold\": {}, \"fresh_hold\": {}, \"throttle_minutes\": {}, \"old_gone\": {}}}\n",
|
||||
pdn, BootId(), BootSeconds(), static_cast<long>(std::ceil(h.hold)), static_cast<long>(std::ceil(h.freshHold)), h.throttleMinutes, h.oldGone ? "true" : "false");
|
||||
if (std::FILE* f = std::fopen(path.c_str(), "w")) {
|
||||
std::fwrite(j.data(), 1, j.size(), f);
|
||||
std::fclose(f);
|
||||
|
|
@ -891,7 +835,7 @@ std::optional<imsd::engine::RegRecovery::Held> LoadHolds(const std::string& pdn)
|
|||
char buf[512]; std::size_t n;
|
||||
while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n);
|
||||
std::fclose(f);
|
||||
if (imsd::util::CanonicalAddr(QuotedAfter(s, "\"pdn\": \"").value_or("")) != imsd::util::CanonicalAddr(pdn)) return std::nullopt;
|
||||
if (QuotedAfter(s, "\"pdn\": \"").value_or("") != pdn) return std::nullopt;
|
||||
if (QuotedAfter(s, "\"boot\": \"").value_or("") != BootId()) return std::nullopt;
|
||||
long elapsed = BootSeconds() - IntAfter(s, "\"written_at\": ").value_or(0);
|
||||
imsd::engine::RegRecovery::Held h;
|
||||
|
|
@ -899,7 +843,6 @@ 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.throttleMinutes = static_cast<int>(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes));
|
||||
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;
|
||||
return h;
|
||||
}
|
||||
|
|
@ -957,12 +900,6 @@ gboolean OnIdleEvent(gpointer data) {
|
|||
FatalExit = true; // non-zero exit, or Restart=on-failure never fires
|
||||
g_main_loop_quit(MainLoop);
|
||||
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;
|
||||
}
|
||||
|
|
@ -975,25 +912,10 @@ void PostEvent(std::unique_ptr<Event> ev) {
|
|||
class Engine {
|
||||
public:
|
||||
Engine() {
|
||||
// The P-CSCF a fresh registration goes to: PCSCF= when set, else the
|
||||
// first address the bring-up read from the PDN's PCO
|
||||
// (PCSCF_DISCOVERED=); no baked-in default — carrier infrastructure.
|
||||
// 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));
|
||||
}
|
||||
// No baked-in default: the P-CSCF is carrier infrastructure, learned
|
||||
// from the IMS PDN's PCO on a stock stack. Until PCO discovery is
|
||||
// implemented, PCSCF= must be configured (see README).
|
||||
pcscf_ = EnvOr("PCSCF", "");
|
||||
pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str());
|
||||
dev_ = EnvOr("DEV", "qmapmux0.0");
|
||||
outDir_ = EnvOr("OUT_DIR", StateDir());
|
||||
|
|
@ -1034,7 +956,7 @@ public:
|
|||
}
|
||||
}
|
||||
|
||||
void SetLocal(std::string l) { local_ = imsd::util::CanonicalAddr(l); pdnLocal_ = local_; }
|
||||
void SetLocal(std::string l) { local_ = l; pdnLocal_ = std::move(l); }
|
||||
void SetDevMode(bool d) { devMode_ = d; }
|
||||
const std::string& Local() const { return local_; }
|
||||
|
||||
|
|
@ -1053,10 +975,6 @@ public:
|
|||
std::scoped_lock g(cmdLock_);
|
||||
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() {
|
||||
{ std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); }
|
||||
quit_.store(true);
|
||||
|
|
@ -1145,7 +1063,7 @@ public:
|
|||
}
|
||||
|
||||
private:
|
||||
struct Cmd { enum class Kind { Dial, HangUp, Accept, Dtmf, Stop } kind; std::string uni, arg; }; // arg: the number to dial, or the tones
|
||||
struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; };
|
||||
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
|
||||
|
|
@ -1181,20 +1099,6 @@ private:
|
|||
}
|
||||
|
||||
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,
|
||||
// but there is no SIP socket — a Dial collapses to terminated/error.
|
||||
|
|
@ -1207,15 +1111,6 @@ private:
|
|||
|
||||
// config
|
||||
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
|
||||
// registration's. local_ differs from it only while a resume runs on the
|
||||
// address an older registration was made on (TryResume).
|
||||
|
|
@ -1252,7 +1147,6 @@ private:
|
|||
bool dropCall_ = false;
|
||||
bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call
|
||||
pid_t mediaPid_ = -1;
|
||||
int mediaCtl_ = -1; // our end of the leg's control pipe (its stdin)
|
||||
double deadline_ = 0;
|
||||
// reply_ answers the inbound request being dispatched right now;
|
||||
// callReply_ is the call's durable response channel — refreshed from
|
||||
|
|
@ -1304,15 +1198,7 @@ private:
|
|||
|
||||
// ---- bring-up: warm resume if an SA + state file exist, else fresh ----
|
||||
void BringUp() {
|
||||
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>")));
|
||||
}
|
||||
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)");
|
||||
auto card = RunCapture({"qmicli", "-d", "qrtr://0", "--uim-get-card-status"});
|
||||
auto sel = imsd::aka::ParseCardStatus(card.out);
|
||||
if (!sel) throw std::runtime_error("no ready USIM found");
|
||||
|
|
@ -1341,67 +1227,25 @@ private:
|
|||
std::string resume = EnvOr("RESUME", "auto");
|
||||
// A resume that the network refuses (403 after another process
|
||||
// registered fresh for the same IMPI, a stale binding, …) is not
|
||||
// fatal while there is a fresh target: the fresh registration below
|
||||
// replaces its SAs, and only a failed FRESH registration is. With no
|
||||
// fresh target the bring-up ends as §7.2 step 1 says.
|
||||
// fatal: the fresh registration below replaces its SAs. Only a
|
||||
// failed FRESH registration is.
|
||||
// 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
|
||||
// refresh's Retry-After) or the fresh registration (a fresh attempt's
|
||||
// schedule or Retry-After, a terminated binding's wait), and so is
|
||||
// one the resume itself earns — a refusal's Retry-After, a binding
|
||||
// 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 (!WaitForPolicy()) throw Stopping{};
|
||||
// a registration the network wrote off before this start (the
|
||||
// holds carry it) is not resumed: the wait above was the fresh one
|
||||
const std::string next = ResumeFailedNext();
|
||||
try { if (recovery_.Reconnectable()) { outcome = TryResume(); if (outcome == ResumeOutcome::Done) return; } }
|
||||
try { if (recovery_.Reconnectable() && TryResume()) return; }
|
||||
catch (const RefusedError& e) {
|
||||
outcome = ResumeOutcome::Failed;
|
||||
Log(std::format("resume failed: {} — the next registration is a fresh one", e.what()));
|
||||
CloseFlowAfterWait();
|
||||
// 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)));
|
||||
if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status);
|
||||
}
|
||||
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{};
|
||||
catch (const std::exception& e) { Log(std::format("resume failed: {} — the next registration is a fresh one", e.what())); CloseFlowAfterWait(); }
|
||||
}
|
||||
// a resume that failed found the stored SAs unusable (and after a
|
||||
// reboot there are none): nothing to reconnect over while a deferred
|
||||
|
|
@ -1410,39 +1254,14 @@ private:
|
|||
portPs_ = 0;
|
||||
local_ = pdnLocal_; ctx_.local = local_;
|
||||
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 (!WaitForPolicy()) throw Stopping{};
|
||||
FreshRegister();
|
||||
}
|
||||
|
||||
// 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() {
|
||||
bool TryResume() {
|
||||
auto state = RunCapture({"ip", "xfrm", "state"});
|
||||
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_);
|
||||
// The registration may sit on another address of the link: before
|
||||
// 0.3.10 imsd registered on the kernel's temporary one (ims ledger
|
||||
|
|
@ -1464,31 +1283,14 @@ private:
|
|||
break;
|
||||
}
|
||||
}
|
||||
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_});
|
||||
if (!sa) return false;
|
||||
auto ps = LoadState();
|
||||
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;
|
||||
}
|
||||
if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; }
|
||||
// The registration's transport is what the P-CSCF knows; a resume
|
||||
// must reuse it. A forced policy that disagrees means a fresh start
|
||||
// — decided before anything from the file is adopted.
|
||||
Transport t = ps->transport.value_or("TCP") == "UDP" ? Transport::Udp : Transport::Tcp;
|
||||
if (sipPolicy_ != "auto" && t != transport_) { Log(std::format("state file transport differs from SIP_TRANSPORT; {}", ResumeFailedNext())); return ResumeOutcome::Failed; }
|
||||
if (sipPolicy_ != "auto" && t != transport_) { Log("state file transport differs from SIP_TRANSPORT; fresh"); return false; }
|
||||
|
||||
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
|
||||
|
|
@ -1501,7 +1303,7 @@ private:
|
|||
// (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).
|
||||
// registers fresh.
|
||||
auto rsv = imsd::ipsec::ChooseResumeSecurityVerify(ps->securityServer.value_or(""), *sa, ealgOffer_);
|
||||
ss_ = rsv.value;
|
||||
using Source = imsd::ipsec::ResumeSecurityVerify::Source;
|
||||
|
|
@ -1530,8 +1332,8 @@ private:
|
|||
// Local refusals (TIME_WAIT after a restart) keep retrying; two
|
||||
// silences mean the flow is not coming back — fresh instead.
|
||||
if (!sip_.Connect(local_, PortUc, pcscf_, sa->portPs, transport_, 10, 2)) {
|
||||
Log(std::format("resume connect failed; {}", ResumeFailedNext()));
|
||||
return ResumeOutcome::Failed;
|
||||
Log("resume connect failed; falling back to fresh");
|
||||
return false;
|
||||
}
|
||||
reg_.callid = *ps->callid;
|
||||
reg_.ftag = ps->ftag.value_or("");
|
||||
|
|
@ -1552,9 +1354,9 @@ private:
|
|||
if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e;
|
||||
UpdateRouteAndPpi(okMsg);
|
||||
LogBindings(okMsg);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
||||
Log("REGISTERED (resumed + true-refreshed)");
|
||||
return ResumeOutcome::Done;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The registrar's 200 OK echoes EVERY current binding for the implicit
|
||||
|
|
@ -1660,13 +1462,6 @@ private:
|
|||
sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc;
|
||||
sp.alg = alg;
|
||||
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
|
||||
for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) {
|
||||
int rc = RunCmd(cmd);
|
||||
|
|
@ -1697,7 +1492,7 @@ private:
|
|||
if (autoTcp) {
|
||||
// Two unanswered TCP connects: treat the P-CSCF as UDP-only
|
||||
// and register again over UDP — a new challenge and a new SA
|
||||
// pair (the SA setup takes this one out), same ports.
|
||||
// pair (the SA setup flushes this one), same ports.
|
||||
Log("protected TCP connect unanswered; registering fresh over UDP");
|
||||
SetTransport(Transport::Udp);
|
||||
FreshRegisterAttempt();
|
||||
|
|
@ -1724,15 +1519,11 @@ private:
|
|||
reg_.securityServer = ss_;
|
||||
reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0);
|
||||
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);
|
||||
LogBindings(ok);
|
||||
if (route_.empty()) route_ = std::format("<sip:{};lr>", imsd::util::HostPort(pcscf_, portPs));
|
||||
ctx_.route = route_; ctx_.ppi = ppi_;
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
||||
Log("REGISTERED (fresh)");
|
||||
}
|
||||
|
||||
|
|
@ -1848,11 +1639,7 @@ private:
|
|||
}
|
||||
|
||||
void UpdateRouteAndPpi(const std::string& okMsg) {
|
||||
// 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);
|
||||
auto svc = imsd::sip::Headers(okMsg, "Service-Route");
|
||||
if (svc.empty()) Log("200: no Service-Route (route stays P-CSCF)");
|
||||
if (!svc.empty()) {
|
||||
std::string r;
|
||||
|
|
@ -1934,7 +1721,7 @@ private:
|
|||
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1);
|
||||
reg_.cseq = used;
|
||||
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
||||
Log(std::format("keepalive re-REGISTER ok (cseq {})", used));
|
||||
OnRegistered();
|
||||
} catch (const RefusedError& e) {
|
||||
|
|
@ -2045,7 +1832,7 @@ private:
|
|||
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
|
||||
UpdateRouteAndPpi(msg);
|
||||
LogBindings(msg);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport, pcscf_);
|
||||
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
||||
Log("reconnected + re-registered");
|
||||
OnRegistered();
|
||||
} catch (const RefusedError& e) {
|
||||
|
|
@ -2077,7 +1864,7 @@ private:
|
|||
}
|
||||
|
||||
// The rest of what a restart does, in place: a new Call-ID, a new
|
||||
// challenge, a new SA pair (FreshRegister takes the old ones out), then
|
||||
// challenge, a new SA pair (FreshRegister flushes the old ones), then
|
||||
// the post-registration steps. A refused or throttled attempt waits on
|
||||
// the throttle schedule (a wait no Dial cuts short — on KPN a refused
|
||||
// attempt costs the old registration) and a silent one on the
|
||||
|
|
@ -2098,21 +1885,12 @@ private:
|
|||
// 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;
|
||||
// an attempt that fails before the SA setup leaves that registration
|
||||
// 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_;
|
||||
}
|
||||
// standing, so it moves back to it
|
||||
std::string from = local_;
|
||||
if (local_ != pdnLocal_) {
|
||||
Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, 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;
|
||||
try {
|
||||
RefreshPani();
|
||||
|
|
@ -2138,12 +1916,6 @@ private:
|
|||
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,
|
||||
|
|
@ -2198,16 +1970,6 @@ private:
|
|||
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
|
||||
// reconnect + refresh, or a fresh recovery: the status (a signal only
|
||||
// when it changes), the timers, the recovery policy, the reg-event
|
||||
|
|
@ -2374,8 +2136,8 @@ private:
|
|||
bool ClearForDial(const Cmd& c, bool emergency) {
|
||||
if (!call_) return true;
|
||||
if (!emergency) {
|
||||
Log(std::format("dial({}) refused: call in progress", c.arg));
|
||||
CallInfo bad{c.uni, c.arg, "terminated", "error", NowEpoch(), 0};
|
||||
Log(std::format("dial({}) refused: call in progress", c.number));
|
||||
CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0};
|
||||
PostAdded(bad);
|
||||
PostState(c.uni, "terminated", "error");
|
||||
PostDeleted(c.uni);
|
||||
|
|
@ -2391,7 +2153,7 @@ private:
|
|||
void HandleCmd(const Cmd& c) {
|
||||
if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; }
|
||||
if (c.kind == Cmd::Kind::Dial) {
|
||||
bool emergency = imsd::msg::IsEmergency(c.arg, emergencyExtra_);
|
||||
bool emergency = imsd::msg::IsEmergency(c.number, emergencyExtra_);
|
||||
if (!ClearForDial(c, emergency)) return;
|
||||
// a dead client flow fails the INVITE instantly — give the
|
||||
// recovery path one immediate shot first: user action cuts the
|
||||
|
|
@ -2410,9 +2172,9 @@ private:
|
|||
if (emergency)
|
||||
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
|
||||
"fallback plain INVITE — stage 1, no emergency registration/PDN; "
|
||||
"carrier-side behavior UNVERIFIED", c.arg));
|
||||
call_.emplace(ctx_, rng_, c.uni, c.arg, rtpPort_, precond_, emergency, codecs_);
|
||||
CallInfo info{c.uni, c.arg, "dialing", "outgoing", NowEpoch(), 0};
|
||||
"carrier-side behavior UNVERIFIED", c.number));
|
||||
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, codecs_);
|
||||
CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0};
|
||||
PostAdded(info);
|
||||
bool ok = Execute(call_->Start());
|
||||
if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); }
|
||||
|
|
@ -2422,9 +2184,6 @@ private:
|
|||
} else if (c.kind == Cmd::Kind::Accept) {
|
||||
if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); }
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2524,7 +2283,6 @@ private:
|
|||
break;
|
||||
case T::StartMedia: StartMedia(a.media); break;
|
||||
case T::StopMedia: StopMedia(); break;
|
||||
case T::SendDtmf: SendDtmfToMedia(a.text); break;
|
||||
case T::State:
|
||||
if (announce) PostState(call_->Uni(), a.state, a.reason);
|
||||
break;
|
||||
|
|
@ -2551,9 +2309,7 @@ private:
|
|||
|
||||
void StartMedia(const imsd::engine::MediaLeg& leg) {
|
||||
if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; }
|
||||
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()));
|
||||
Log(std::format("media {} pt={} octet={} -> [{}]:{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort));
|
||||
if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_});
|
||||
else
|
||||
RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_});
|
||||
|
|
@ -2567,21 +2323,8 @@ private:
|
|||
session = ResolveAudioSession();
|
||||
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();
|
||||
if (pid == 0) {
|
||||
if (ctl[0] >= 0) dup2(ctl[0], STDIN_FILENO); // dup2 clears the close-on-exec
|
||||
if (geteuid() == 0) {
|
||||
// no session is said with an empty value: imsd-media reads a
|
||||
// missing variable as a caller older than 0.3.10
|
||||
|
|
@ -2595,43 +2338,16 @@ private:
|
|||
setenv("DTX", EnvOr("DTX", "0").c_str(), 1);
|
||||
setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 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;
|
||||
for (auto& s : argv) c.push_back(const_cast<char*>(s.c_str()));
|
||||
c.push_back(nullptr);
|
||||
execvp(c[0], c.data());
|
||||
_exit(127);
|
||||
}
|
||||
if (ctl[0] >= 0) close(ctl[0]);
|
||||
if (pid < 0 && ctl[1] >= 0) { close(ctl[1]); ctl[1] = -1; }
|
||||
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() {
|
||||
CloseMediaCtl();
|
||||
if (mediaPid_ <= 0) return;
|
||||
kill(mediaPid_, SIGTERM);
|
||||
for (int i = 0; i < 100; i++) {
|
||||
|
|
@ -2651,7 +2367,6 @@ private:
|
|||
if (r == 0) return; // still running
|
||||
int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
|
||||
mediaPid_ = -1; // reaped; don't re-terminate
|
||||
CloseMediaCtl();
|
||||
Execute(call_->OnMediaExit(code));
|
||||
MaybeDropCall();
|
||||
}
|
||||
|
|
@ -2729,10 +2444,7 @@ void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar*
|
|||
return;
|
||||
}
|
||||
if (m == "SendDtmf") {
|
||||
const gchar* uni = nullptr;
|
||||
const gchar* tones = nullptr;
|
||||
g_variant_get(params, "(&s&s)", &uni, &tones);
|
||||
TheEngine->SendDtmf(uni ? uni : "", tones ? tones : "");
|
||||
Log("SendDtmf accepted but not yet sent (TODO RFC4733)");
|
||||
g_dbus_method_invocation_return_value(inv, nullptr);
|
||||
return;
|
||||
}
|
||||
|
|
@ -2796,22 +2508,6 @@ int main(int argc, char** argv) {
|
|||
std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)");
|
||||
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 local = EnvOr("LOCAL", "");
|
||||
|
|
@ -2822,31 +2518,7 @@ int main(int argc, char** argv) {
|
|||
std::println(std::cerr, "imsd: no global IPv6 on {} — is the ims PDN up?", dev);
|
||||
return 1;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, EnvOr("PCSCF", "(unset)"), EnvOr("PCSCF_PORT", "5060")));
|
||||
if (!session) LogAudioSession();
|
||||
imsd::bus::AuthorizeCalls = !session;
|
||||
|
||||
|
|
|
|||
|
|
@ -26,23 +26,11 @@ 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
|
||||
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>
|
||||
imsd-media --selftest (payload pack/depay roundtrip, both formats,
|
||||
both AMR codecs; G.711 table roundtrip; the
|
||||
telephone-event payload, codes and Q.23
|
||||
frequency pairs)
|
||||
both AMR codecs; G.711 table roundtrip)
|
||||
Env: CODEC MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP
|
||||
OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP DTMF_PT DTMF_RX_PT
|
||||
OCTET_ALIGN AUDIO_USER MIC_SRC PCM_DUMP
|
||||
(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.
|
||||
AMR_MODE is the encoder mode of whichever AMR codec is active; default 2
|
||||
|
|
@ -57,7 +45,6 @@ with no PipeWire and no network.)
|
|||
#include <dlfcn.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <poll.h>
|
||||
#include <pwd.h>
|
||||
#include <signal.h>
|
||||
#include <sys/socket.h>
|
||||
|
|
@ -484,13 +471,8 @@ Child SpawnPw(bool play, bool toChild, int rate) {
|
|||
if (toChild) { dup2(pipefd[0], STDIN_FILENO); }
|
||||
else { dup2(pipefd[1], STDOUT_FILENO); }
|
||||
close(pipefd[0]); close(pipefd[1]);
|
||||
int devnull = open("/dev/null", O_RDWR);
|
||||
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);
|
||||
}
|
||||
int devnull = open("/dev/null", O_WRONLY);
|
||||
if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); }
|
||||
std::vector<char*> cargv;
|
||||
for (auto& s : argv) cargv.push_back(const_cast<char*>(s.c_str()));
|
||||
cargv.push_back(nullptr);
|
||||
|
|
@ -525,32 +507,19 @@ struct TxState {
|
|||
sockaddr_storage latched{}; socklen_t latchedLen = 0;
|
||||
std::uint64_t tx = 0;
|
||||
std::uint64_t txMic = 0;
|
||||
std::uint64_t txDtmf = 0;
|
||||
std::atomic<bool> micOn{false};
|
||||
};
|
||||
|
||||
// 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) {
|
||||
void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) {
|
||||
std::uint8_t hdr[12];
|
||||
sockaddr_storage a1;
|
||||
sockaddr_storage a2;
|
||||
socklen_t l1;
|
||||
socklen_t l2;
|
||||
std::uint32_t stamp;
|
||||
sockaddr_storage a1, a2; socklen_t l1, l2;
|
||||
{
|
||||
std::scoped_lock g(st.lock);
|
||||
stamp = ts.value_or(st.ts);
|
||||
hdr[0] = 0x80;
|
||||
hdr[1] = static_cast<std::uint8_t>((marker ? 0x80 : 0) | (pt & 0x7F));
|
||||
hdr[1] = static_cast<std::uint8_t>(st.pt & 0x7F);
|
||||
hdr[2] = (st.seq >> 8) & 0xFF; hdr[3] = st.seq & 0xFF;
|
||||
hdr[4] = (stamp >> 24) & 0xFF; hdr[5] = (stamp >> 16) & 0xFF;
|
||||
hdr[6] = (stamp >> 8) & 0xFF; hdr[7] = stamp & 0xFF;
|
||||
hdr[4] = (st.ts >> 24) & 0xFF; hdr[5] = (st.ts >> 16) & 0xFF;
|
||||
hdr[6] = (st.ts >> 8) & 0xFF; hdr[7] = st.ts & 0xFF;
|
||||
hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF;
|
||||
hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF;
|
||||
st.seq = (st.seq + 1) & 0xFFFF;
|
||||
|
|
@ -568,126 +537,8 @@ std::uint32_t RtpSendPacket(int sock, TxState& st, int pt, bool marker, std::opt
|
|||
sendTo(a1, l1);
|
||||
// avoid double-send when latched == dst
|
||||
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>>>
|
||||
Depay(Codec c, std::span<const std::uint8_t> pl, bool octetAlign) {
|
||||
return octetAlign ? DepayOctet(c, pl) : DepayBe(c, pl);
|
||||
|
|
@ -722,14 +573,12 @@ std::vector<std::int16_t> G711DecodeFrame(Codec c, std::span<const std::uint8_t>
|
|||
std::atomic<bool> Quit{false};
|
||||
void OnSig(int) { Quit.store(true); }
|
||||
|
||||
// playout queue item: an audio packet, or (`event`) a telephone-event one
|
||||
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; bool event = false; };
|
||||
// playout queue item
|
||||
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; };
|
||||
|
||||
// --selftest: pack->depay roundtrip of every frame type, both payload
|
||||
// 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;
|
||||
// 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.
|
||||
// 16-bit range, and a 1 kHz sine surviving with the codec's nominal SNR.
|
||||
int SelfTest() {
|
||||
for (Codec c : {Codec::AmrWb, Codec::AmrNb}) {
|
||||
for (int ft = 0; ft < 16; ft++) {
|
||||
|
|
@ -754,42 +603,6 @@ int SelfTest() {
|
|||
std::println(std::cerr, "selftest FAIL G.711 digital zero");
|
||||
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 (int v = -32768; v <= 32767; v++) {
|
||||
std::uint8_t b = G711Encode(c, static_cast<std::int16_t>(v));
|
||||
|
|
@ -848,8 +661,6 @@ int main(int argc, char** argv) {
|
|||
bool rtpDump = EnvBool("RTP_DUMP", false);
|
||||
std::string micSrc = EnvOr("MIC_SRC", "");
|
||||
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 PrimeFrames = 8;
|
||||
constexpr int MaxFill = 25;
|
||||
|
|
@ -882,13 +693,8 @@ int main(int argc, char** argv) {
|
|||
signal(SIGINT, OnSig);
|
||||
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)
|
||||
// through the codec's encoder, one RTP packet per 20 ms frame — or the
|
||||
// event packet whose slot it is
|
||||
// through the codec's encoder, one RTP packet per 20 ms frame
|
||||
std::atomic<bool> stop{false};
|
||||
std::jthread micThread;
|
||||
if (mic) {
|
||||
|
|
@ -925,23 +731,23 @@ int main(int argc, char** argv) {
|
|||
next += std::chrono::milliseconds(20);
|
||||
std::this_thread::sleep_until(next);
|
||||
}
|
||||
st.micOn.store(true);
|
||||
std::memcpy(samples.data(), raw.data(), raw.size());
|
||||
if (gain != 1.0)
|
||||
for (auto& sample : samples) {
|
||||
int v = static_cast<int>(sample * gain);
|
||||
sample = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
|
||||
}
|
||||
std::vector<std::uint8_t> payload; // empty: a DTX gap, nothing to send in the slot
|
||||
std::vector<std::uint8_t> payload;
|
||||
if (amr) {
|
||||
std::vector<std::uint8_t> frame = enc.Encode(samples.data(), amrMode, dtx);
|
||||
if (!frame.empty()) payload = PayloadFromFrame(codec, frame, octetAlign);
|
||||
if (frame.empty()) continue;
|
||||
payload = PayloadFromFrame(codec, frame, octetAlign);
|
||||
} else {
|
||||
payload = G711Payload(codec, samples);
|
||||
}
|
||||
// the slot's packet: the event's, or this frame
|
||||
if (dtmf.SendSlot(sock, st, dtmfPt, payload) || payload.empty()) continue;
|
||||
st.micOn.store(true);
|
||||
{ std::scoped_lock g(st.lock); st.txMic++; }
|
||||
RtpSend(sock, st, payload);
|
||||
}
|
||||
st.micOn.store(false);
|
||||
close(fd);
|
||||
|
|
@ -977,8 +783,6 @@ int main(int argc, char** argv) {
|
|||
std::uint64_t cng = 0;
|
||||
std::uint64_t late = 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;
|
||||
if (decodeOn) {
|
||||
playThread = std::jthread([&] {
|
||||
|
|
@ -1010,7 +814,6 @@ int main(int argc, char** argv) {
|
|||
};
|
||||
std::vector<std::int16_t> zero(frameSamples, 0);
|
||||
std::optional<std::uint32_t> expect;
|
||||
std::optional<std::uint32_t> toneTs; // the event being rendered, by its start stamp
|
||||
for (;;) {
|
||||
PktItem item;
|
||||
{
|
||||
|
|
@ -1020,55 +823,6 @@ int main(int argc, char** argv) {
|
|||
item = std::move(playq.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;
|
||||
if (amr) {
|
||||
for (auto& [hdr, speech] : Depay(codec, item.payload, octetAlign)) {
|
||||
|
|
@ -1113,65 +867,9 @@ 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
|
||||
std::vector<std::uint8_t> silence = SilenceFrame(codec, 0, octetAlign);
|
||||
for (int i = 0; i < 5; i++) dtmf.SendSlot(sock, st, dtmfPt, silence); // latch burst, the one path
|
||||
for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst
|
||||
|
||||
std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr;
|
||||
double t0 = Now();
|
||||
|
|
@ -1190,8 +888,7 @@ int main(int argc, char** argv) {
|
|||
break;
|
||||
}
|
||||
if (now - lastTx >= 0.02 && !st.micOn.load()) {
|
||||
// no mic: the slot's packet is the event's, or a silence frame
|
||||
dtmf.SendSlot(sock, st, dtmfPt, silence);
|
||||
RtpSend(sock, st, silence);
|
||||
lastTx = now;
|
||||
}
|
||||
std::uint8_t buf[65535];
|
||||
|
|
@ -1229,14 +926,7 @@ int main(int argc, char** argv) {
|
|||
std::fwrite(lb, 1, 4, dump);
|
||||
std::fwrite(buf, 1, static_cast<std::size_t>(n), dump);
|
||||
}
|
||||
// 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)) {
|
||||
if (playThread.joinable() && n >= 12 && (buf[1] & 0x7F) == pt) {
|
||||
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) |
|
||||
(buf[5] << 16) | (buf[6] << 8) | buf[7];
|
||||
|
|
@ -1245,14 +935,13 @@ int main(int argc, char** argv) {
|
|||
playq.pop_front();
|
||||
qdrop++;
|
||||
}
|
||||
playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n), isEvent});
|
||||
playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n)});
|
||||
qcv.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
stop.store(true);
|
||||
qcv.notify_all();
|
||||
if (ctlThread.joinable()) ctlThread.join();
|
||||
if (micThread.joinable()) micThread.join();
|
||||
if (playThread.joinable()) playThread.join();
|
||||
if (dump) std::fclose(dump);
|
||||
|
|
@ -1272,14 +961,14 @@ int main(int argc, char** argv) {
|
|||
// .stats sidecar (tiny; always written)
|
||||
if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) {
|
||||
std::print(sf,
|
||||
"{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"tx_dtmf\": {}, \"rx\": {}, \"rx_bytes\": {}, "
|
||||
"\"rx_played\": {}, \"rx_dtmf\": {}, \"tones\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
|
||||
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"dtmf_pt\": {}, \"dtmf_rx_pt\": {}, \"mic\": {}, "
|
||||
"{{\"codec\": \"{}\", \"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, "
|
||||
"\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
|
||||
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, "
|
||||
"\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}",
|
||||
CodecName(codec), st.tx, st.txMic, st.txDtmf, rx, rxBytes, rxPlayed, rxDtmf, tones, cng, late, qdrop, firstSrc,
|
||||
rIp, rPort, pt, dtmfPt, dtmfRxPt, mic, play, amrMode, mediaEnded);
|
||||
CodecName(codec), st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc,
|
||||
rIp, rPort, pt, mic, play, amrMode, mediaEnded);
|
||||
std::fclose(sf);
|
||||
}
|
||||
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);
|
||||
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);
|
||||
return mediaEnded ? ExitMediaTimeout : 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,239 +0,0 @@
|
|||
// 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,10 +18,8 @@ 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
|
||||
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
|
||||
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 — and
|
||||
DTMF, which reaches the media leg only on an active call whose SDP
|
||||
negotiated telephone-event, as the tones a keypad has —
|
||||
the other way), and the terminating (UAS) side: 100/180 on an inbound INVITE,
|
||||
200-with-answer on Accept, 486 reject, the remote-CANCEL 200+487 pair —
|
||||
unit-testable against recorded network dialogs with no I/O (tests/Engine).
|
||||
|
||||
State/reason strings are the frozen D-Bus vocabulary the dialer maps on
|
||||
|
|
@ -105,10 +103,8 @@ export namespace imsd::engine {
|
|||
// due it also says the old registration is gone (KPN drops it the
|
||||
// moment an initial REGISTER arrives, throttled or not), as does a
|
||||
// fresh attempt that got past the SA setup, a binding the network
|
||||
// terminated, and a bring-up resume that failed while a fresh
|
||||
// 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
|
||||
// terminated, and a resume that failed at bring-up (OldGone) — no
|
||||
// reconnect over the old SA from then on. Every wait
|
||||
// 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
|
||||
// refused or throttled fresh attempt, a terminated binding's probation
|
||||
|
|
@ -168,18 +164,16 @@ export namespace imsd::engine {
|
|||
double freshHold = 0;
|
||||
int throttleMinutes = ThrottleMinutesMin;
|
||||
bool oldGone = false; // the old registration is gone: the next process has nothing to reconnect over
|
||||
int refusals = 0; // refused reconnects counted so far (RefusalsBeforeFresh): a restart's refusal is the next one, not the first
|
||||
// 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; }
|
||||
// a schedule that has advanced is kept too, until a success resets it, and so is the gone verdict
|
||||
bool Any() const { return hold > 0 || freshHold > 0 || throttleMinutes > ThrottleMinutesMin || oldGone; }
|
||||
};
|
||||
Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone, refusals}; }
|
||||
Held Holds(double now) const { return {std::max(0.0, holdUntil - now), std::max(0.0, freshHoldUntil - now), throttleMinutes, oldGone}; }
|
||||
// A restarted process takes them over; nothing here shortens a
|
||||
// hold it already has, or lengthens one past RetryAfterMax.
|
||||
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.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);
|
||||
refusals = std::max(refusals, h.refusals);
|
||||
if (h.oldGone) OldGone(); // no reconnect over SAs the network has written off
|
||||
}
|
||||
|
||||
|
|
@ -282,24 +276,12 @@ export namespace imsd::engine {
|
|||
|
||||
// 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).
|
||||
// `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 {
|
||||
std::string remoteIp;
|
||||
int remotePort = 0;
|
||||
int payloadType = -1;
|
||||
bool octetAlign = false;
|
||||
std::string codec = "AMR-WB";
|
||||
std::optional<int> dtmfPt;
|
||||
std::optional<int> dtmfRxPt;
|
||||
bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; }
|
||||
};
|
||||
|
||||
|
|
@ -309,7 +291,6 @@ export namespace imsd::engine {
|
|||
SendResponse, // answer the just-handled inbound request on its origin channel
|
||||
StartMedia, // bring up the media leg described by `media`
|
||||
StopMedia, // tear the media leg down
|
||||
SendDtmf, // hand `text` (the tones) to the running media leg
|
||||
State, // emit CallStateChanged(uni, `state`, `reason`)
|
||||
Deleted, // emit CallDeleted(uni); drop this machine
|
||||
SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline()
|
||||
|
|
@ -323,11 +304,6 @@ export namespace imsd::engine {
|
|||
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
|
||||
// imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants.
|
||||
inline constexpr double InviteTimeout = 90.0;
|
||||
|
|
@ -455,7 +431,7 @@ export namespace imsd::engine {
|
|||
spec.payloadType = leg.payloadType;
|
||||
spec.octetAlign = leg.octetAlign;
|
||||
spec.codec = leg.codec;
|
||||
spec.dtmfPt = leg.dtmfPt;
|
||||
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), imsd::sdp::CodecRate(leg.codec));
|
||||
localSdp_ = imsd::sdp::BuildAnswer(spec);
|
||||
// session timers: echo the caller's Session-Expires; with no
|
||||
// refresher stated, make the caller (uac) the refresher — its
|
||||
|
|
@ -628,45 +604,6 @@ export namespace imsd::engine {
|
|||
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
|
||||
// it while it waits on a CANCEL's 487): terminate bookkeeping only —
|
||||
// the CANCEL already sent stands, and the late final for this
|
||||
|
|
@ -746,7 +683,6 @@ export namespace imsd::engine {
|
|||
bool userCancelled_ = false; // CANCEL came from OnHangup, not a deadline
|
||||
std::set<std::string> pracked_;
|
||||
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
|
||||
// after a final response is a new transaction AND a clean new dialog
|
||||
|
|
@ -839,13 +775,6 @@ export namespace imsd::engine {
|
|||
leg.payloadType = ans.payloadType;
|
||||
leg.octetAlign = ans.octetAlign;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -887,7 +816,6 @@ export namespace imsd::engine {
|
|||
return a;
|
||||
}
|
||||
Action StartMediaAct(const MediaLeg& leg) {
|
||||
dtmfPt_ = leg.dtmfPt;
|
||||
Action a{Action::Type::StartMedia};
|
||||
a.media = leg;
|
||||
return a;
|
||||
|
|
|
|||
|
|
@ -19,14 +19,7 @@ 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).
|
||||
the 401's own list is stored there).
|
||||
|
||||
Pure std C++ (the shell runs the commands and captures the text). Command
|
||||
shapes and the parser are pinned by tests/Ipsec.
|
||||
|
|
@ -121,11 +114,9 @@ export namespace imsd::ipsec {
|
|||
}
|
||||
}
|
||||
|
||||
// The full `ip xfrm` argv sequence to install a fresh SA pair: the four
|
||||
// ESP states, then the four transport policies — no flush: the caller
|
||||
// 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
|
||||
// The full `ip xfrm` argv sequence to install a fresh SA pair: flush
|
||||
// state+policy, add the four ESP states, add the four transport policies.
|
||||
// Order matches imscall.setup_sa exactly. Callers validate p.alg with
|
||||
// KernelAuth() first; an unknown alg goes to the kernel by its own name
|
||||
// and is rejected there, never mapped to a default.
|
||||
std::vector<std::vector<std::string>> BuildSetupCommands(const SaParams& p) {
|
||||
|
|
@ -136,6 +127,8 @@ export namespace imsd::ipsec {
|
|||
std::vector<std::string> enc = detail::EncArgs(p);
|
||||
int plen = imsd::util::Is6(local) ? 128 : 32;
|
||||
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(pcscf, local, p.spiUc, 1, auth, ikx, enc));
|
||||
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, auth, ikx, enc));
|
||||
|
|
@ -147,6 +140,10 @@ export namespace imsd::ipsec {
|
|||
return cmds;
|
||||
}
|
||||
|
||||
std::vector<std::vector<std::string>> BuildTeardownCommands() {
|
||||
return {{"ip", "xfrm", "state", "flush"}, {"ip", "xfrm", "policy", "flush"}};
|
||||
}
|
||||
|
||||
// ---- Security-Server (RFC 3329 §2.2) -----------------------------------
|
||||
//
|
||||
// The P-CSCF answers the challenge with the mechanisms it supports, each
|
||||
|
|
@ -370,21 +367,6 @@ export namespace imsd::ipsec {
|
|||
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
|
||||
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.
|
||||
|
|
@ -392,38 +374,26 @@ export namespace imsd::ipsec {
|
|||
// state: (src==LOCAL,dst==P): reqid1->spiPs reqid2->spiPc
|
||||
// (src==P,dst==LOCAL): reqid1->spiUc reqid2->spiUs
|
||||
// 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.
|
||||
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;
|
||||
bool havePs = false;
|
||||
bool havePc = false;
|
||||
bool havePortPs = false;
|
||||
bool havePortPc = false;
|
||||
for (std::string_view b : detail::SrcBlocks(stateText)) {
|
||||
const std::string src = imsd::util::CanonicalAddr(detail::TokenAfter(b, "src "));
|
||||
const std::string dst = imsd::util::CanonicalAddr(detail::TokenAfter(b, "dst "));
|
||||
std::string_view src = detail::TokenAfter(b, "src ");
|
||||
std::string_view dst = detail::TokenAfter(b, "dst ");
|
||||
auto spi = detail::ParseHex32(detail::TokenAfter(b, "spi "));
|
||||
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
|
||||
if (!spi || !reqid) continue;
|
||||
if (src == l && dst == p && *reqid == 1) { sa.spiPs = *spi; havePs = true; }
|
||||
if (src == l && dst == p && *reqid == 2) { sa.spiPc = *spi; havePc = true; }
|
||||
if (src == p && dst == l && *reqid == 1) sa.spiUc = *spi;
|
||||
if (src == p && dst == l && *reqid == 2) sa.spiUs = *spi;
|
||||
if (src == local && dst == pcscf && *reqid == 1) { sa.spiPs = *spi; havePs = true; }
|
||||
if (src == local && dst == pcscf && *reqid == 2) { sa.spiPc = *spi; havePc = true; }
|
||||
if (src == pcscf && dst == local && *reqid == 1) sa.spiUc = *spi;
|
||||
if (src == pcscf && dst == local && *reqid == 2) sa.spiUs = *spi;
|
||||
}
|
||||
for (std::string_view b : detail::SrcBlocks(policyText)) {
|
||||
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;
|
||||
if (b.find("dir out") == std::string_view::npos) continue;
|
||||
auto dport = detail::ParseDec(detail::TokenAfter(b, "dport "));
|
||||
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
|
||||
if (!dport || !reqid) continue;
|
||||
|
|
@ -464,115 +434,16 @@ export namespace imsd::ipsec {
|
|||
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
|
||||
// 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.
|
||||
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;
|
||||
for (std::string_view b : detail::SrcBlocks(stateText)) {
|
||||
const std::string src = imsd::util::CanonicalAddr(detail::TokenAfter(b, "src "));
|
||||
if (src.empty() || imsd::util::CanonicalAddr(detail::TokenAfter(b, "dst ")) != p) continue;
|
||||
if (std::ranges::find(locals, src) == locals.end()) locals.push_back(src);
|
||||
std::string_view src = detail::TokenAfter(b, "src ");
|
||||
if (src.empty() || detail::TokenAfter(b, "dst ") != pcscf) continue;
|
||||
if (std::ranges::find(locals, src) == locals.end()) locals.emplace_back(src);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,260 +0,0 @@
|
|||
// 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,9 +9,7 @@ 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
|
||||
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
|
||||
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
|
||||
address/port, the negotiated payload type, the codec and octet-align mode — it
|
||||
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)
|
||||
and G.711 (PCMA/PCMU) — the last two are what a PSTN gateway offers when a
|
||||
|
|
@ -84,11 +82,6 @@ export namespace imsd::sdp {
|
|||
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
|
||||
// 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
|
||||
|
|
@ -114,8 +107,8 @@ export namespace imsd::sdp {
|
|||
s += "t=0 0\r\n";
|
||||
std::string pts;
|
||||
for (const auto& c : codecs) pts += std::format(" {}", pt(c));
|
||||
if (wb) pts += std::format(" {}", OfferedTelephoneEventPt(16000));
|
||||
if (nb) pts += std::format(" {}", OfferedTelephoneEventPt(8000));
|
||||
if (wb) pts += " 98";
|
||||
if (nb) pts += " 100";
|
||||
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 += "b=RS:512\r\n";
|
||||
|
|
@ -132,12 +125,12 @@ export namespace imsd::sdp {
|
|||
}
|
||||
}
|
||||
if (wb) {
|
||||
s += std::format("a=rtpmap:{} telephone-event/16000\r\n", OfferedTelephoneEventPt(16000));
|
||||
s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(16000));
|
||||
s += "a=rtpmap:98 telephone-event/16000\r\n";
|
||||
s += "a=fmtp:98 0-15\r\n";
|
||||
}
|
||||
if (nb) {
|
||||
s += std::format("a=rtpmap:{} telephone-event/8000\r\n", OfferedTelephoneEventPt(8000));
|
||||
s += std::format("a=fmtp:{} 0-15\r\n", OfferedTelephoneEventPt(8000));
|
||||
s += "a=rtpmap:100 telephone-event/8000\r\n";
|
||||
s += "a=fmtp:100 0-15\r\n";
|
||||
}
|
||||
s += "a=ptime:20\r\n";
|
||||
s += "a=maxptime:240\r\n";
|
||||
|
|
@ -157,68 +150,22 @@ export namespace imsd::sdp {
|
|||
int payloadType = -1;
|
||||
bool octetAlign = false;
|
||||
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,
|
||||
// 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
|
||||
// no rtpmap at all). Empty when neither applies.
|
||||
std::string CodecName(std::string_view body, int pt) {
|
||||
const std::string enc = Rtpmap(body, pt);
|
||||
if (!enc.empty()) return enc.substr(0, enc.find('/'));
|
||||
std::string key = std::format("a=rtpmap:{} ", pt);
|
||||
if (auto at = Find(body, key)) {
|
||||
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 == 8) return "PCMA";
|
||||
return "";
|
||||
|
|
@ -255,10 +202,7 @@ export namespace imsd::sdp {
|
|||
// non-empty `prefs` replaces the codec order (and restricts acceptance
|
||||
// to the codecs named) — the CODECS override. With no acceptable codec
|
||||
// at all, the first pt is used and codec comes back EMPTY — pretending
|
||||
// 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.
|
||||
// otherwise would defeat codec checks upstream.
|
||||
Answer ParseAnswer(std::string_view body, std::span<const std::string> prefs = {}) {
|
||||
Answer a;
|
||||
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
|
||||
|
|
@ -270,10 +214,17 @@ export namespace imsd::sdp {
|
|||
auto port = ParseInt(portTok);
|
||||
// both the port and the " RTP/AVP " literal are required
|
||||
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;
|
||||
a.port = *port;
|
||||
|
||||
std::vector<int> pts = AudioFormats(body);
|
||||
// payload-type list: 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);
|
||||
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;
|
||||
|
||||
auto hasOctetAlign = [&](int p) {
|
||||
|
|
@ -310,10 +261,28 @@ export namespace imsd::sdp {
|
|||
a.codec.clear();
|
||||
}
|
||||
a.octetAlign = a.codec.starts_with("AMR") && hasOctetAlign(a.payloadType);
|
||||
if (!a.codec.empty()) a.dtmfPt = TelephoneEventPt(body, CodecRate(a.codec));
|
||||
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 {
|
||||
std::string local; // connection address (v4 or v6)
|
||||
int rtpPort = 0;
|
||||
|
|
@ -324,11 +293,14 @@ export namespace imsd::sdp {
|
|||
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
|
||||
// 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),
|
||||
// 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
|
||||
// telephone-event kept when offered. b=AS is the codec's peak payload
|
||||
// rate plus IP/UDP/RTP overhead at 20 ms. Byte layout pinned by tests/Sdp.
|
||||
std::string BuildAnswer(const AnswerSpec& s) {
|
||||
std::string ipver = Is6(s.local) ? "IP6" : "IP4";
|
||||
|
|
|
|||
|
|
@ -4,9 +4,8 @@
|
|||
// lint-disable-file fixed-width-types
|
||||
/*
|
||||
Imsd:Util — small self-contained primitives the rest of the engine builds on:
|
||||
random SIP tokens, IPv6 detection + host/port formatting, the RFC 5952
|
||||
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
|
||||
random SIP tokens, IPv6 detection + host/port formatting, 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
|
||||
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.
|
||||
|
|
@ -41,114 +40,6 @@ export namespace imsd::util {
|
|||
: 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
|
||||
// material (the Python rand()). The generator is a parameter so tests can
|
||||
// pin output; the default constructor seeds from random_device.
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export module Imsd;
|
|||
export import :Util;
|
||||
export import :Aka;
|
||||
export import :Ipsec;
|
||||
export import :Qmi;
|
||||
export import :Sip;
|
||||
export import :Sdp;
|
||||
export import :Messages;
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ package() {
|
|||
cd "$srcdir/imsd-$pkgver"
|
||||
install -Dm755 imsd "$pkgdir"/usr/bin/imsd
|
||||
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 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh
|
||||
install -Dm755 imsd-handover.sh "$pkgdir"/usr/libexec/imsd-handover.sh
|
||||
|
|
@ -60,17 +59,18 @@ package() {
|
|||
# account created at install — imsd-dialerd owns those session bus names
|
||||
install -Dm644 "$srcdir"/org.kde.modem.daemon.desktop \
|
||||
"$pkgdir"/etc/skel/.config/autostart/org.kde.modem.daemon.desktop
|
||||
# enabled by preset: VoLTE surviving reboots must not depend on a manual
|
||||
# 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)
|
||||
# enabled by preset: the unit is a no-op until /etc/imsd.env exists, and
|
||||
# VoLTE surviving reboots must not depend on a manual systemctl enable
|
||||
install -Dm644 "$srcdir"/80-imsd.preset \
|
||||
"$pkgdir"/usr/lib/systemd/system-preset/80-imsd.preset
|
||||
mkdir -p "$pkgdir"/etc/systemd/system/multi-user.target.wants
|
||||
ln -s /usr/lib/systemd/system/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-ofono-restart-calls.sh \
|
||||
"$pkgdir"/usr/libexec/imsd-ofono-restart-calls.sh
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ crafter-build -- $XTARGET
|
|||
crafter-build -- --product=media $XTARGET
|
||||
crafter-build -- --product=dialerd $XTARGET
|
||||
crafter-build -- --product=ofonod $XTARGET
|
||||
crafter-build -- --product=pcscf $XTARGET
|
||||
crafter-build test
|
||||
|
||||
# --- bundle + package
|
||||
|
|
|
|||
|
|
@ -5,15 +5,10 @@
|
|||
#
|
||||
# Finds-or-creates the ims bearer via ModemManager, connects it, and
|
||||
# configures the muxed netdev with the MM-assigned address (the s34 recipe,
|
||||
# automated), reads the P-CSCF list the network put in the PDN's PCO from the
|
||||
# modem (imsd-pcscf) unless PCSCF= names one, and gives the configured or
|
||||
# the first discovered address (the daemon's fresh target) a host route —
|
||||
# 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.
|
||||
# automated) and a host route to the P-CSCF — never a default route (F15
|
||||
# below). Idempotent — safe to run when the PDN is already up. Runs as
|
||||
# ExecStartPre of imsd.service, so imsd only starts once the PDN exists; a
|
||||
# nonzero exit fails the unit and systemd retries per Restart/RestartSec.
|
||||
#
|
||||
# Configuration, via the unit's EnvironmentFile /etc/imsd.env (also read
|
||||
# directly so manual runs behave the same):
|
||||
|
|
@ -27,16 +22,8 @@
|
|||
# (journal/ims 2026-09-16/17 bench: Odido, O2 UK, Swisscom).
|
||||
# IMS_REG_TIMEOUT max seconds to wait for network registration before
|
||||
# connect attempts start counting anyway (default 300)
|
||||
# PCSCF one P-CSCF address, by hand: it wins over discovery
|
||||
# (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
|
||||
# PCSCF the P-CSCF address imsd registers with (its own
|
||||
# required setting); gets the host route over the ims PDN
|
||||
|
||||
log() { echo "ims-pdn-up: $*"; }
|
||||
|
||||
|
|
@ -57,15 +44,9 @@ bearer_paths() {
|
|||
# `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).
|
||||
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
|
||||
sed -n "s/^$1=//p" "$IMSD_ENV" 2>/dev/null | tail -n1 | tr -d '"'
|
||||
sed -n "s/^$1=//p" /etc/imsd.env 2>/dev/null | tail -n1 | tr -d '"'
|
||||
}
|
||||
IP_TYPE=${IMS_IP_TYPE:-$(envval IMS_IP_TYPE)}
|
||||
IP_TYPE=${IP_TYPE:-ipv6}
|
||||
|
|
@ -86,44 +67,6 @@ while :; do
|
|||
done
|
||||
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
|
||||
# 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
|
||||
|
|
@ -224,7 +167,7 @@ if [ -z "$BEARER" ]; then
|
|||
waited=0 last=
|
||||
while :; do
|
||||
STATE=$(modem_state)
|
||||
case "$STATE" in registered|connecting|connected) break ;; failed) sim_check ;; esac
|
||||
case "$STATE" in registered|connecting|connected) break ;; esac
|
||||
[ "$STATE" != "$last" ] && log "waiting for registration (state: ${STATE:-unknown})"
|
||||
last=$STATE
|
||||
if [ "$waited" -ge "$REG_TIMEOUT" ]; then
|
||||
|
|
@ -232,10 +175,6 @@ if [ -z "$BEARER" ]; then
|
|||
log "modem still disabled after $REG_TIMEOUT s (radio off): not connecting"
|
||||
exit 1
|
||||
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"
|
||||
break
|
||||
fi
|
||||
|
|
@ -261,31 +200,10 @@ if [ -z "$BEARER" ]; then
|
|||
fi
|
||||
|
||||
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
|
||||
n=$((n + 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
|
||||
[ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; 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
|
||||
if [ -z "$B" ]; then
|
||||
if [ -n "$PROFILE_ID" ]; then
|
||||
|
|
@ -299,7 +217,6 @@ while [ -z "$BEARER" ]; do
|
|||
log "created bearer $B ($SPEC)"
|
||||
else
|
||||
log "create-bearer attempt $n failed: $(squash "$OUT"); retrying in 10 s"
|
||||
REFUSED=0 # not the network's answer to a connect
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
|
@ -307,24 +224,7 @@ while [ -z "$BEARER" ]; do
|
|||
if OUT=$(mmcli -b "$B" --connect 2>&1); then
|
||||
BEARER=$B
|
||||
else
|
||||
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
|
||||
log "connect attempt $n failed (state: $(modem_state)): $(squash "$OUT"); retrying in 10 s"
|
||||
case "$OUT" in *call-already-present*)
|
||||
# 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
|
||||
|
|
@ -391,108 +291,30 @@ 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
|
||||
ip -6 route flush default dev "$IFACE" && log "dropped the RA default route on $IFACE"
|
||||
fi
|
||||
# ---- the P-CSCF. PCSCF= when the admin set one (one IPv6 address; it wins
|
||||
# and discovery is skipped), else the list the network put in the PDN's PCO,
|
||||
# read from the modem by imsd-pcscf: a QMI WDS client bound to this netdev's
|
||||
# mux data port — the binding ModemManager gives its own clients, and only
|
||||
# such a client is shown the call's settings. The list goes to imsd as
|
||||
# PCSCF_DISCOVERED= (its own name: this script inherits the previous run's
|
||||
# /run/imsd.env through the unit, so a stale discovered value must never
|
||||
# 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"
|
||||
}
|
||||
# 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. The PDN is brought up IPv6-only (the address above), so
|
||||
# the P-CSCF must be an IPv6 address; anything else on the IMS network
|
||||
# (its DNS servers, a traceroute target) needs its own host route. Without
|
||||
# this route imsd cannot reach the P-CSCF, so a failure is the unit's
|
||||
# failure.
|
||||
PCSCF=${PCSCF:-$(envval PCSCF)}
|
||||
DISCOVERED=
|
||||
DISCOVERY=skipped
|
||||
case "$PCSCF" in
|
||||
'')
|
||||
if [ "$IP_TYPE" = ipv4 ]; then
|
||||
log "P-CSCF discovery: not for IMS_IP_TYPE=$IP_TYPE — the bring-up configures only the PDN's IPv6 address"
|
||||
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 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) ||
|
||||
{ log "host route to P-CSCF $PCSCF failed: $(squash "$OUT")"; exit 1; }
|
||||
log "host route to P-CSCF $PCSCF dev $IFACE" ;;
|
||||
*) log "PCSCF '$PCSCF' is not an IPv6 address; the ims PDN is brought up IPv6-only"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ---- hand the connected ims netdev, the PDN's own address and the
|
||||
# discovered P-CSCF list to imsd (imsd.service reads /run/imsd.env): the
|
||||
# daemon carries no baked-in interface name and registers on the address
|
||||
# the network assigned — not on the kernel's temporary SLAAC address from
|
||||
# the RA, which the netdev lists first and which is deprecated after a day
|
||||
# and removed after two (ims ledger F18; the SAs and the Contact would go
|
||||
# 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"
|
||||
# ---- hand the connected ims netdev and the PDN's own address to imsd
|
||||
# (imsd.service reads /run/imsd.env): the daemon carries no baked-in
|
||||
# interface name and registers on the address the network assigned — not
|
||||
# on the kernel's temporary SLAAC address from the RA, which the netdev
|
||||
# lists first and which is deprecated after a day and removed after two
|
||||
# (ims ledger F18; the SAs and the Contact would go with it)
|
||||
printf 'DEV=%s\nLOCAL=%s\n' "$IFACE" "$ADDR" > /run/imsd.env
|
||||
|
||||
# ---- 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
|
||||
|
|
|
|||
|
|
@ -11,9 +11,7 @@ Documentation=https://forgejo.catcrafts.net/Catcrafts/imsd
|
|||
# — Plasma Mobile uses imsd-dialerd instead. Both may run at once (different
|
||||
# buses, different consumers), but a real ofonod must not.
|
||||
Conflicts=ofono.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)
|
||||
After=imsd.service
|
||||
Wants=imsd.service
|
||||
|
||||
[Service]
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@
|
|||
# trigger restarts every unit marked needs-restart (enqueue-marked). A
|
||||
# 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
|
||||
# restart resumes the registration over its existing SAs, goes on waiting
|
||||
# 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.
|
||||
# restart resumes the registration over its existing SAs, or goes on
|
||||
# waiting when the network had imsd wait; a call in progress ends.
|
||||
#
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -20,13 +20,10 @@ ExecStartPre=/usr/libexec/ims-pdn-up.sh
|
|||
ExecStartPre=-/usr/libexec/imsd-handover.sh
|
||||
TimeoutStartSec=900
|
||||
# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev,
|
||||
# LOCAL= the PDN address ModemManager assigned to it, PCSCF_DISCOVERED= the
|
||||
# P-CSCF list the network put in the PDN's PCO, read from the modem,
|
||||
# PCSCF_DISCOVERY= how that list came to be: found, none, failed, skipped;
|
||||
# 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.
|
||||
# LOCAL= the PDN address ModemManager assigned to it).
|
||||
# /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's
|
||||
# P-CSCF address; see AGENTS.md §10, or the README) and wins over the
|
||||
# runtime-derived values.
|
||||
EnvironmentFile=-/run/imsd.env
|
||||
EnvironmentFile=-/etc/imsd.env
|
||||
# registration state (imsreg.state) + media stats live here
|
||||
|
|
|
|||
|
|
@ -12,12 +12,10 @@ 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)
|
||||
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)
|
||||
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 "$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 "$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)
|
||||
trap 'rm -rf "$stage"' EXIT
|
||||
|
|
@ -26,7 +24,6 @@ cp "$BIN" "$stage/imsd-$VER/imsd"
|
|||
cp "$MEDIA" "$stage/imsd-$VER/imsd-media"
|
||||
cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd"
|
||||
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 \
|
||||
packaging/imsd-dialerd.desktop packaging/imsd-ofonod.service \
|
||||
packaging/imsd-ofono-restart-calls.sh \
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@
|
|||
# make-sysroot.sh — build an Alpine aarch64 sysroot for cross-compiling imsd.
|
||||
#
|
||||
# Downloads apk-tools-static + the Alpine signing keys from the CDN and
|
||||
# populates a minimal rootfs (musl, libc++, glib and their headers, the Linux
|
||||
# UAPI headers) that
|
||||
# populates a minimal rootfs (musl, libc++, glib and their headers) that
|
||||
# clang can use via --sysroot. No root, no qemu, no device needed:
|
||||
#
|
||||
# packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine
|
||||
|
|
@ -23,10 +22,8 @@ ARCH=aarch64
|
|||
# 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,
|
||||
# llvm-runtimes 23's source did not compile against its headers; upgraded
|
||||
# with it, it did. linux-headers is in it for <linux/qrtr.h>, which
|
||||
# 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"
|
||||
# with it, it did.
|
||||
PKGS="musl-dev libc++ libc++-dev llvm-runtimes glib-dev compiler-rt llvm-libunwind-dev gcc"
|
||||
|
||||
work=$(mktemp -d)
|
||||
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) {
|
||||
// Five executables come out of this repo; which one a build produces is
|
||||
// Four executables come out of this repo; which one a build produces is
|
||||
// chosen by `--product=`:
|
||||
// daemon (default) — imsd, the control-plane daemon (GDBus + engine),
|
||||
// and the unit tests, which link imsd-core.
|
||||
|
|
@ -91,19 +91,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
// (org.ofono.*) bridging to imsd, for Phosh and
|
||||
// GNOME Mobile (GDBus translation plus the imsd-bus
|
||||
// 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 wantDialerd = false;
|
||||
bool wantOfonod = false;
|
||||
bool wantPcscf = false;
|
||||
for (std::string_view a : args) {
|
||||
if (a == "--product=media") wantMedia = true;
|
||||
if (a == "--product=dialerd") wantDialerd = true;
|
||||
if (a == "--product=ofonod") wantOfonod = true;
|
||||
if (a == "--product=pcscf") wantPcscf = true;
|
||||
}
|
||||
if (wantOfonod) {
|
||||
Configuration ofonod;
|
||||
|
|
@ -167,12 +161,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
ApplyStandardArgs(*Core, args);
|
||||
Core->type = ConfigurationType::LibraryStatic;
|
||||
{
|
||||
std::array<fs::path, 9> ifaces = {
|
||||
std::array<fs::path, 8> ifaces = {
|
||||
"interfaces/Imsd",
|
||||
"interfaces/Imsd-Util",
|
||||
"interfaces/Imsd-Aka",
|
||||
"interfaces/Imsd-Ipsec",
|
||||
"interfaces/Imsd-Qmi",
|
||||
"interfaces/Imsd-Sip",
|
||||
"interfaces/Imsd-Sdp",
|
||||
"interfaces/Imsd-Messages",
|
||||
|
|
@ -182,23 +175,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
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
|
||||
// (net.catcrafts.IMS1, same ABI as the Python imsd.py) around the core.
|
||||
Configuration cfg;
|
||||
|
|
@ -221,7 +197,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
cfg.AddTest("Util").Dependencies({ Core.get() });
|
||||
cfg.AddTest("Aka").Dependencies({ Core.get() });
|
||||
cfg.AddTest("Ipsec").Dependencies({ Core.get() });
|
||||
cfg.AddTest("Qmi").Dependencies({ Core.get() });
|
||||
cfg.AddTest("Messages").Dependencies({ Core.get() });
|
||||
cfg.AddTest("Engine").Dependencies({ Core.get() });
|
||||
cfg.AddTest("Sip").Dependencies({ Core.get() });
|
||||
|
|
|
|||
|
|
@ -11,10 +11,7 @@
|
|||
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488 (naming
|
||||
// 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 ->
|
||||
// 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:
|
||||
// 480, BYE both ways. Then RegRecovery, the registration recovery policy:
|
||||
// the refusal and silence thresholds, local refusals, the reconnect and
|
||||
// fresh backoffs, the throttle schedule, Retry-After, which waits a Dial
|
||||
// may cut short, when the old registration counts as gone, the holds a
|
||||
|
|
@ -93,21 +90,6 @@ namespace {
|
|||
"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);
|
||||
}
|
||||
// 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) {
|
||||
return std::format(
|
||||
"SIP/2.0 {} {}\r\n"
|
||||
|
|
@ -227,7 +209,6 @@ int main() {
|
|||
Check(media && media->media.remotePort == 6004, "media remote port");
|
||||
Check(media && media->media.payloadType == 97, "media pt");
|
||||
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);
|
||||
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
|
||||
Check(m.State() == CallState::Active, "state active");
|
||||
|
|
@ -239,12 +220,6 @@ int main() {
|
|||
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");
|
||||
|
||||
// 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
|
||||
auto amx = m.OnMediaExit(3);
|
||||
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
|
||||
|
|
@ -255,73 +230,6 @@ int main() {
|
|||
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
|
||||
{
|
||||
imsd::util::Rng rng(7);
|
||||
|
|
@ -460,12 +368,6 @@ 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=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");
|
||||
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("Contact: <sip:001010123456789@"), "200 carries our Contact");
|
||||
// the 180 and 200 carry the SAME To tag (one dialog)
|
||||
|
|
@ -1195,20 +1097,6 @@ int main() {
|
|||
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(!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;
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -78,18 +78,6 @@ int main() {
|
|||
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");
|
||||
}
|
||||
// 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
|
||||
Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt");
|
||||
{
|
||||
|
|
@ -101,92 +89,6 @@ int main() {
|
|||
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
|
||||
{
|
||||
SaParams p;
|
||||
|
|
@ -197,21 +99,23 @@ int main() {
|
|||
p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400;
|
||||
p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421;
|
||||
auto cmds = BuildSetupCommands(p);
|
||||
Check(cmds.size() == 8, "4 states + 4 policies, no flush (OwnXfrmDeleteCommands takes imsd's previous entries out)");
|
||||
Check(cmds.size() == 10, "flush x2 + 4 states + 4 policies");
|
||||
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
|
||||
Check(Join(cmds[0]) ==
|
||||
Check(Join(cmds[2]) ==
|
||||
"ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 "
|
||||
"proto esp spi 400 mode transport reqid 1 "
|
||||
"auth-trunc hmac(sha1) 0xabababababababababababababababab 96 "
|
||||
"enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
|
||||
"state add reqid1 outbound byte-exact");
|
||||
// first out policy: sport PORT_UC dport PORT_PS reqid 1
|
||||
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");
|
||||
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");
|
||||
// v4 uses /32
|
||||
SaParams p4 = p;
|
||||
p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1";
|
||||
auto c4 = BuildSetupCommands(p4);
|
||||
Check(c4[4][5] == "10.0.0.2/32", "v4 prefix length /32");
|
||||
Check(c4[6][5] == "10.0.0.2/32", "v4 prefix length /32");
|
||||
}
|
||||
// null cipher -> cipher_null + empty key arg
|
||||
{
|
||||
|
|
@ -221,7 +125,7 @@ int main() {
|
|||
p.ck = {};
|
||||
p.ealg = "null";
|
||||
auto cmds = BuildSetupCommands(p);
|
||||
auto& st = cmds[0];
|
||||
auto& st = cmds[2];
|
||||
Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key");
|
||||
}
|
||||
|
||||
|
|
@ -233,7 +137,7 @@ int main() {
|
|||
p.ck = {};
|
||||
p.alg = "hmac-md5-96"; p.ealg = "null";
|
||||
auto cmds = BuildSetupCommands(p);
|
||||
Check(cmds[0][17] == "hmac(md5)" && cmds[0][18] == "0x01010101010101010101010101010101" && cmds[0][19] == "96", "md5: auth-trunc hmac(md5) IK 96");
|
||||
Check(cmds[2][17] == "hmac(md5)" && cmds[2][18] == "0x01010101010101010101010101010101" && cmds[2][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(KnownEalg("null") && KnownEalg("aes-cbc") && KnownEalg("des-ede3-cbc") && !KnownEalg("aes-gcm") && !KnownEalg("") && !KnownEalg("3des"), "KnownEalg set (canonical names only)");
|
||||
}
|
||||
|
|
@ -324,15 +228,6 @@ int main() {
|
|||
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
|
||||
{
|
||||
// spec-shaped list: every entry carries the SPI set, md5 preferred by q — we offered sha1, so sha1 wins (audit A1)
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
// 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,23 +206,8 @@ int main() {
|
|||
"a=rtpmap:104 AMR-WB/16000/1\r\n"
|
||||
"a=rtpmap:105 telephone-event/16000\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, 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, 8000) == 106, "narrowband telephone-event pt");
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
|
||||
// lint-disable-file fixed-width-types
|
||||
// Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex
|
||||
// decoding, host/port formatting, the RFC 5952 canonical IPv6 text and the
|
||||
// address-list parser, and the deterministic-seed RNG. MD5 and
|
||||
// decoding, host/port formatting, and the deterministic-seed RNG. MD5 and
|
||||
// base64 are our own (the standard library ships neither) and the AKAv1-MD5
|
||||
// digest depends on them exactly, so these are pinned hard.
|
||||
import std;
|
||||
|
|
@ -96,47 +95,6 @@ int main() {
|
|||
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");
|
||||
return Failures;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue