RegRecovery retried a refused refresh once over the same registration before escalating, on the reading that one 503 may be a hiccup. A refusal whose Retry-After is at least a second is the network's considered answer: on O2 UK (2026-10-08, imsd 0.3.10) the keepalive was refused 503 with Retry-After 414 and the refresh after that wait 503 with Retry-After 772, so 0.3.10 scheduled the fresh registration no earlier than 414 + 772 s after the keepalive's refusal. Refusals without such a Retry-After (none, 0, unparsable) keep the two-refusal rule, so KPN's bare 403 is unchanged. Tested with the Engine suite, whose checks of a refusal carrying a Retry-After now expect the fresh step.
2506 lines
123 KiB
C++
2506 lines
123 KiB
C++
// SPDX-License-Identifier: GPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
// lint-disable-file fixed-width-types no-char-pointer
|
|
/*
|
|
imsd — the userspace IMS/VoLTE daemon (control plane).
|
|
|
|
Owns net.catcrafts.IMS1 on the system bus with the project's frozen D-Bus ABI
|
|
(AGENTS.md §8.1) and drives it with the engine core: register on the IMS PDN (USIM AKA
|
|
+ IPsec sec-agree, fresh or warm-resume, with a keepalive re-REGISTER refresh),
|
|
run outgoing and incoming calls through the Imsd:Engine call machine (inbound
|
|
INVITEs on the protected-port listeners become ringing calls a dialer answers
|
|
via Accept), and spawn one imsd-media data-plane process per answered call.
|
|
GLib/GDBus lives only here and in ImsdBus (the polkit gate in front of call
|
|
control, shared with imsd-ofonod) — the integration daemons imsd-dialerd and
|
|
imsd-ofonod carry their own; every message, digest, SA command, and
|
|
call-state decision comes from the GLib-free core.
|
|
|
|
Threads: a dedicated engine thread runs the SIP loop (blocking framed recv,
|
|
protected-port listeners, media reaping, keepalive); the GLib main loop owns
|
|
the bus. D-Bus method calls push commands to the engine queue; engine events
|
|
reach the bus via g_idle_add, where the calls table + status snapshot live
|
|
(touched only on the main thread).
|
|
|
|
--session own the name on the session bus (development; no policy, no
|
|
polkit)
|
|
*/
|
|
|
|
#include <arpa/inet.h>
|
|
#include <fcntl.h>
|
|
#include <gio/gio.h>
|
|
#include <glib-unix.h>
|
|
#include <ifaddrs.h>
|
|
#include <netinet/in.h>
|
|
#include <poll.h>
|
|
#include <pthread.h>
|
|
#include <pwd.h>
|
|
#include <signal.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/wait.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
import std;
|
|
import Imsd;
|
|
import ImsdBus;
|
|
|
|
namespace {
|
|
|
|
// ---- static config (env-overridable, same knobs as imsd.py) ---------------
|
|
constexpr const char* Version = "0.3.10";
|
|
constexpr const char* BusName = "net.catcrafts.IMS1";
|
|
constexpr const char* ObjPath = "/net/catcrafts/IMS1";
|
|
constexpr const char* Iface = "net.catcrafts.IMS1";
|
|
constexpr int PortUc = imsd::util::PortUc;
|
|
constexpr int PortUs = imsd::util::PortUs;
|
|
constexpr int InitPort = 5060;
|
|
|
|
std::string EnvOr(const char* k, std::string d) {
|
|
const char* v = std::getenv(k);
|
|
return v ? std::string(v) : std::move(d);
|
|
}
|
|
|
|
std::string SelfDir; // dir of argv[0], for locating imsd-media
|
|
|
|
void Log(std::string_view m) { std::println("imsd: {}", m); std::fflush(stdout); }
|
|
|
|
bool Is6(std::string_view a) { return a.contains(':'); }
|
|
|
|
// ---- subprocess exec ------------------------------------------------------
|
|
|
|
struct ProcResult { int rc = -1; std::string out; };
|
|
|
|
// Run argv, capture stdout (stderr discarded), wait. No shell.
|
|
ProcResult RunCapture(const std::vector<std::string>& argv) {
|
|
int pipefd[2];
|
|
if (pipe(pipefd) != 0) return {};
|
|
pid_t pid = fork();
|
|
if (pid == 0) {
|
|
dup2(pipefd[1], STDOUT_FILENO);
|
|
close(pipefd[0]); close(pipefd[1]);
|
|
int dn = open("/dev/null", O_WRONLY);
|
|
if (dn >= 0) { dup2(dn, STDERR_FILENO); close(dn); }
|
|
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 (pid < 0) { close(pipefd[0]); close(pipefd[1]); return {}; }
|
|
close(pipefd[1]);
|
|
std::string out;
|
|
char buf[4096];
|
|
ssize_t n;
|
|
while ((n = read(pipefd[0], buf, sizeof buf)) > 0)
|
|
out.append(buf, static_cast<std::size_t>(n));
|
|
close(pipefd[0]);
|
|
int status = 0;
|
|
waitpid(pid, &status, 0);
|
|
return {WIFEXITED(status) ? WEXITSTATUS(status) : -1, std::move(out)};
|
|
}
|
|
|
|
// Run argv, discard output, return exit code.
|
|
int RunCmd(const std::vector<std::string>& argv) { return RunCapture(argv).rc; }
|
|
|
|
// argv as one journal line with key material (the "0x…" tokens) masked.
|
|
std::string RedactKeys(const std::vector<std::string>& argv) {
|
|
std::string s;
|
|
for (const auto& a : argv) {
|
|
if (!s.empty()) s += ' ';
|
|
s += a.starts_with("0x") ? "<key>" : a;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
// ---- small text extractors (401 header params, qmicli "completed:") -------
|
|
|
|
std::optional<std::string> QuotedAfter(std::string_view text, std::string_view key) {
|
|
std::size_t at = text.find(key);
|
|
if (at == std::string_view::npos) return std::nullopt;
|
|
std::size_t start = at + key.size();
|
|
std::size_t end = text.find('"', start);
|
|
if (end == std::string_view::npos) return std::nullopt;
|
|
return std::string(text.substr(start, end - start));
|
|
}
|
|
|
|
std::optional<long> IntAfter(std::string_view text, std::string_view key) {
|
|
std::size_t at = text.find(key);
|
|
if (at == std::string_view::npos) return std::nullopt;
|
|
std::size_t i = at + key.size();
|
|
std::size_t start = i;
|
|
while (i < text.size() && text[i] >= '0' && text[i] <= '9') i++;
|
|
if (i == start) return std::nullopt;
|
|
long v = 0;
|
|
std::from_chars(text.data() + start, text.data() + i, v);
|
|
return v;
|
|
}
|
|
|
|
// value after "completed:" — integer or the hex ("AB:CD:..") token
|
|
std::optional<long> CompletedInt(std::string_view out) {
|
|
return IntAfter(out, "completed: ");
|
|
}
|
|
std::optional<std::string> CompletedHex(std::string_view out) {
|
|
std::size_t at = out.find("completed:");
|
|
if (at == std::string_view::npos) return std::nullopt;
|
|
std::size_t i = at + 10;
|
|
while (i < out.size() && (out[i] == ' ' || out[i] == '\t')) i++;
|
|
std::size_t start = i;
|
|
auto ok = [](char c) {
|
|
return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') ||
|
|
(c >= 'a' && c <= 'f') || c == ':';
|
|
};
|
|
while (i < out.size() && ok(out[i])) i++;
|
|
if (i == start) return std::nullopt;
|
|
std::string s(out.substr(start, i - start));
|
|
std::string h;
|
|
for (char c : s) if (c != ':') h.push_back(static_cast<char>(std::tolower(c)));
|
|
return h;
|
|
}
|
|
|
|
// The UE address on the ims PDN when LOCAL is not given: a global IPv6 on
|
|
// <dev> that is neither temporary nor RA-derived (`dynamic`) — the one
|
|
// ims-pdn-up.sh assigned from ModemManager — else the first non-temporary
|
|
// one, else the first. The kernel lists the temporary (privacy) address
|
|
// first, and registering on it binds Contact, Via and the SAs to an address
|
|
// that is deprecated after a day and removed after two (ims ledger F18).
|
|
std::optional<std::string> DetectLocal(const std::string& dev) {
|
|
auto r = RunCapture({"ip", "-6", "addr", "show", "dev", dev, "scope", "global"});
|
|
std::optional<std::string> first, nonTemporary, assigned;
|
|
std::size_t pos = 0;
|
|
while ((pos = r.out.find("inet6 ", pos)) != std::string::npos) {
|
|
std::size_t start = pos + 6;
|
|
pos = start;
|
|
std::size_t eol = r.out.find('\n', start);
|
|
if (eol == std::string::npos) eol = r.out.size();
|
|
std::size_t slash = r.out.find('/', start);
|
|
if (slash == std::string::npos || slash > eol) continue;
|
|
std::string addr = r.out.substr(start, slash - start);
|
|
std::string_view flags = std::string_view(r.out).substr(slash, eol - slash);
|
|
bool temporary = flags.contains(" temporary");
|
|
bool dynamic = flags.contains(" dynamic");
|
|
if (!first) first = addr;
|
|
if (!temporary && !nonTemporary) nonTemporary = addr;
|
|
if (!temporary && !dynamic && !assigned) assigned = addr;
|
|
}
|
|
if (assigned) return assigned;
|
|
if (nonTemporary) return nonTemporary;
|
|
return first;
|
|
}
|
|
|
|
// Whether `addr` is configured on `dev` now. Compared as bytes, so any
|
|
// spelling of the address matches; when the interface list cannot be read
|
|
// the answer is yes — nothing here acts on a guess that it is gone.
|
|
bool OnLink(const std::string& dev, const std::string& addr) {
|
|
int fam = Is6(addr) ? AF_INET6 : AF_INET;
|
|
unsigned char want[16] = {};
|
|
if (inet_pton(fam, addr.c_str(), want) != 1) return false;
|
|
ifaddrs* ifs = nullptr;
|
|
if (getifaddrs(&ifs) != 0) return true;
|
|
bool found = false;
|
|
for (ifaddrs* i = ifs; i && !found; i = i->ifa_next) {
|
|
if (!i->ifa_addr || i->ifa_addr->sa_family != fam || dev != i->ifa_name) continue;
|
|
const void* have = fam == AF_INET6 ? static_cast<const void*>(&reinterpret_cast<sockaddr_in6*>(i->ifa_addr)->sin6_addr)
|
|
: static_cast<const void*>(&reinterpret_cast<sockaddr_in*>(i->ifa_addr)->sin_addr);
|
|
found = std::memcmp(have, want, fam == AF_INET6 ? 16 : 4) == 0;
|
|
}
|
|
freeifaddrs(ifs);
|
|
return found;
|
|
}
|
|
|
|
// The account whose PipeWire session carries call audio: AUDIO_USER when
|
|
// it names an account, else logind's seat0 session (`loginctl show-seat
|
|
// seat0` -> `show-session`) when that is a user session — not phrog's
|
|
// greeter — with a PipeWire socket. imsd-media gets the answer as
|
|
// AUDIO_USER. As packaged (root, on the system bus) imsd resolves it at
|
|
// start, so a wrong name or a missing session is in the journal before the
|
|
// first call rather than after two silent rounds of trial (alyx report 14,
|
|
// 2026-09-20), and again when a call's media starts, because the answer
|
|
// changes when someone logs in.
|
|
struct AudioSession { std::string name; unsigned uid = 0; std::string how; };
|
|
|
|
std::string LoginctlProp(std::string_view text, std::string_view key) {
|
|
for (auto line : std::views::split(text, '\n')) {
|
|
std::string_view l(line);
|
|
if (l.size() > key.size() && l.starts_with(key) && l[key.size()] == '=') return std::string(l.substr(key.size() + 1));
|
|
}
|
|
return {};
|
|
}
|
|
|
|
AudioSession ResolveAudioSession() {
|
|
AudioSession s;
|
|
passwd pw{};
|
|
passwd* res = nullptr;
|
|
char buf[4096];
|
|
std::string want = EnvOr("AUDIO_USER", "");
|
|
if (!want.empty()) {
|
|
if (getpwnam_r(want.c_str(), &pw, buf, sizeof buf, &res) == 0 && res) {
|
|
s.name = want; s.uid = res->pw_uid; s.how = "AUDIO_USER";
|
|
return s;
|
|
}
|
|
Log(std::format("AUDIO_USER '{}' is not an account; using logind's seat0 session instead", want));
|
|
}
|
|
auto seat = RunCapture({"loginctl", "show-seat", "seat0", "-p", "ActiveSession", "--value"});
|
|
std::string id = seat.out;
|
|
while (!id.empty() && (id.back() == '\n' || id.back() == ' ')) id.pop_back();
|
|
if (seat.rc != 0 || id.empty()) { s.how = "no session on seat0"; return s; }
|
|
auto sess = RunCapture({"loginctl", "show-session", id, "-p", "Name", "-p", "Class", "-p", "User"});
|
|
std::string name = LoginctlProp(sess.out, "Name"), cls = LoginctlProp(sess.out, "Class"), uid = LoginctlProp(sess.out, "User");
|
|
unsigned u = 0;
|
|
auto [end, err] = std::from_chars(uid.data(), uid.data() + uid.size(), u);
|
|
if (sess.rc != 0 || name.empty() || err != std::errc{} || end != uid.data() + uid.size()) { s.how = std::format("seat0 session {} not readable", id); return s; }
|
|
if (!cls.starts_with("user")) { s.how = std::format("seat0 session {} is {}'s {} session, nobody logged in", id, name, cls); return s; }
|
|
std::error_code ec;
|
|
if (!std::filesystem::exists(std::format("/run/user/{}/pipewire-0", u), ec)) { s.how = std::format("seat0 session {} ({}) has no PipeWire socket in /run/user/{}", id, name, u); return s; }
|
|
s.name = name; s.uid = u; s.how = std::format("logind seat0 session {}", id);
|
|
return s;
|
|
}
|
|
|
|
std::string AudioSessionLine(const AudioSession& s) {
|
|
if (!s.name.empty()) return std::format("audio session: {} (uid {}, {})", s.name, s.uid, s.how);
|
|
return std::format("audio session: none — {}", s.how);
|
|
}
|
|
|
|
void LogAudioSession() {
|
|
AudioSession s = ResolveAudioSession();
|
|
Log(AudioSessionLine(s) + (s.name.empty() ? "; looked up again at each call, set AUDIO_USER to pin one" : ""));
|
|
}
|
|
|
|
// ---- sockaddr helper ------------------------------------------------------
|
|
socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) {
|
|
std::memset(&ss, 0, sizeof ss);
|
|
std::string s(ip);
|
|
if (Is6(ip)) {
|
|
auto* a = reinterpret_cast<sockaddr_in6*>(&ss);
|
|
a->sin6_family = AF_INET6; a->sin6_port = htons(static_cast<uint16_t>(port));
|
|
inet_pton(AF_INET6, s.c_str(), &a->sin6_addr);
|
|
return sizeof(sockaddr_in6);
|
|
}
|
|
auto* a = reinterpret_cast<sockaddr_in*>(&ss);
|
|
a->sin_family = AF_INET; a->sin_port = htons(static_cast<uint16_t>(port));
|
|
inet_pton(AF_INET, s.c_str(), &a->sin_addr);
|
|
return sizeof(sockaddr_in);
|
|
}
|
|
|
|
// ---- SIP over the protected TCP flow --------------------------------------
|
|
enum class Transport { Tcp, Udp };
|
|
constexpr std::string_view TransportName(Transport t) { return t == Transport::Udp ? "UDP" : "TCP"; }
|
|
|
|
// The protected client flow: our requests to the P-CSCF's protected server
|
|
// port and their responses. TCP is the shape every carrier that works so far
|
|
// serves; UDP is the alternative for a P-CSCF that never answers the TCP
|
|
// connect (O2 UK, 2026-09-17). Both run through the same SA pair — the xfrm
|
|
// policies select on ports only — and the same Via port.
|
|
// A P-CSCF refusing a fresh security association (KPN: Security-Server
|
|
// spi-s=0 to a fresh REGISTER on an address whose registration is still
|
|
// live). Handled in-process: a
|
|
// systemd restart every RestartSec would never clear it.
|
|
struct ThrottledError : std::runtime_error { using std::runtime_error::runtime_error; };
|
|
// A REGISTER the network refused: the protected one, or a refresh, answered
|
|
// with a final other than 200 (a refresh's first 401 is its challenge); the
|
|
// unprotected initial one answered with a final from 300 up (its 401 is the
|
|
// challenge). The status and its Retry-After feed imsd::engine::RegRecovery.
|
|
struct RefusedError : std::runtime_error {
|
|
int status;
|
|
long retryAfter;
|
|
RefusedError(const std::string& what, int st, long ra) : std::runtime_error(what), status(st), retryAfter(ra) {}
|
|
};
|
|
// Bytes of SIP that fit one ESP-protected IPv6 packet at the ims PDN's
|
|
// 1280-byte MTU (40 IPv6 + 8 ESP + 16 IV + 8 UDP + padding + 12 ICV).
|
|
constexpr std::size_t UdpSinglePacketBudget = 1190;
|
|
|
|
class SipFlow {
|
|
public:
|
|
// TCP: connect up to `tries` times (12-s timeout each, 10 s apart). An
|
|
// attempt the network never answers counts towards `silentLimit` (0 =
|
|
// unlimited); an attempt refused locally — connect() itself failing:
|
|
// EADDRNOTAVAIL (the 4-tuple still in TIME_WAIT from the previous flow),
|
|
// ENETUNREACH (no route yet) — does not, so a restart within 60 s of the
|
|
// last one keeps retrying instead of concluding the P-CSCF is silent. UDP: bind the same client port and connect() the
|
|
// datagram socket to the same peer — the kernel then delivers that peer's
|
|
// datagrams here in preference to the unconnected ServerPorts socket on
|
|
// the same port. A UDP connect() cannot be refused by the peer, so it is
|
|
// one attempt.
|
|
bool Connect(const std::string& local, int lport, const std::string& pcscf, int pport, Transport t, int tries = 10, int silentLimit = 0) {
|
|
transport_ = t;
|
|
lastConnectLocal_ = false;
|
|
int fam = Is6(local) ? AF_INET6 : AF_INET;
|
|
if (t == Transport::Udp) {
|
|
int fd = socket(fam, SOCK_DGRAM, 0);
|
|
if (fd < 0) return SocketFailed();
|
|
int one = 1;
|
|
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
|
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
|
|
sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la);
|
|
if (!Bind(fd, la, ll, local, lport)) return false;
|
|
sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa);
|
|
if (connect(fd, reinterpret_cast<sockaddr*>(&pa), pl) != 0) {
|
|
// a UDP connect() cannot be refused by the peer: no route
|
|
// yet, and the like — local, like the TCP path's ENETUNREACH
|
|
Log(std::format("UDP connect: {}", std::strerror(errno)));
|
|
close(fd); lastConnectLocal_ = true; return false;
|
|
}
|
|
fd_ = fd; alive_ = true; fb_ = {}; sizeWarned_ = false;
|
|
return true;
|
|
}
|
|
int silent = 0;
|
|
for (int attempt = 0; attempt < tries; attempt++) {
|
|
int fd = socket(fam, SOCK_STREAM, 0);
|
|
if (fd < 0) return SocketFailed();
|
|
int one = 1;
|
|
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
|
// REUSEPORT (paired with the listener's): a reconnect must bind
|
|
// the protected client port while the ServerPorts listener holds
|
|
// it in LISTEN state — REUSEADDR alone only covers TIME_WAIT.
|
|
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
|
|
sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la);
|
|
if (!Bind(fd, la, ll, local, lport)) return false;
|
|
sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa);
|
|
// non-blocking connect + poll: Linux connect() ignores SO_SNDTIMEO,
|
|
// so bound it ourselves (a silent P-CSCF must not hang the engine).
|
|
fcntl(fd, F_SETFL, O_NONBLOCK);
|
|
int rc = connect(fd, reinterpret_cast<sockaddr*>(&pa), pl);
|
|
bool connected = (rc == 0);
|
|
bool localRefusal = (rc < 0 && errno != EINPROGRESS);
|
|
int err = localRefusal ? errno : 0;
|
|
if (rc < 0 && errno == EINPROGRESS) {
|
|
pollfd p{fd, POLLOUT, 0};
|
|
if (poll(&p, 1, 12000) > 0) {
|
|
socklen_t el = sizeof err;
|
|
getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &el);
|
|
connected = (err == 0);
|
|
} else err = ETIMEDOUT;
|
|
}
|
|
if (connected) {
|
|
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) & ~O_NONBLOCK);
|
|
fd_ = fd; alive_ = true; fb_ = {}; sizeWarned_ = false;
|
|
return true;
|
|
}
|
|
close(fd);
|
|
lastConnectLocal_ = localRefusal;
|
|
if (!localRefusal) silent++;
|
|
// An answer (RST → ECONNREFUSED, ICMP → EHOSTUNREACH/ENETUNREACH)
|
|
// counts towards the silent limit like a timeout: either way the
|
|
// P-CSCF does not serve TCP on that port. The log says which.
|
|
const char* why = err == ETIMEDOUT ? "no answer" : (err == EADDRNOTAVAIL ? "TIME_WAIT?" : std::strerror(err));
|
|
if (attempt == tries - 1) {
|
|
Log(std::format("connect failed ({}) [{}/{}]", why, attempt + 1, tries));
|
|
return false;
|
|
}
|
|
if (silentLimit > 0 && silent >= silentLimit) {
|
|
Log(std::format("connect: no answer from the P-CSCF {} times ({})", silent, why));
|
|
return false;
|
|
}
|
|
Log(std::format("connect failed; retry in 10s ({}) [{}/{}]", why, attempt + 1, tries));
|
|
sleep(10);
|
|
}
|
|
return false;
|
|
}
|
|
// socket() failing is ours, not the network's: logged, and a local
|
|
// refusal like a connect() the kernel turns down.
|
|
bool SocketFailed() {
|
|
Log(std::format("connect failed: socket: {}", std::strerror(errno)));
|
|
lastConnectLocal_ = true;
|
|
return false;
|
|
}
|
|
// A bind the kernel refuses (the address is not on the link:
|
|
// EADDRNOTAVAIL) is logged and counts like the network's silence — it is
|
|
// not a transient refusal to retry every 5 s. Closes fd on failure.
|
|
bool Bind(int fd, const sockaddr_storage& la, socklen_t ll, const std::string& local, int lport) {
|
|
if (bind(fd, reinterpret_cast<const sockaddr*>(&la), ll) == 0) return true;
|
|
int err = errno;
|
|
close(fd);
|
|
lastConnectLocal_ = false;
|
|
Log(std::format("connect failed: cannot bind {} ({})", imsd::util::HostPort(local, lport), std::strerror(err)));
|
|
return false;
|
|
}
|
|
// Whether the last failed Connect() ended on a local refusal (our own
|
|
// 4-tuple still in TIME_WAIT, no route yet) rather than on the network's
|
|
// silence or answer — the recovery policy counts only the latter.
|
|
bool LastConnectLocal() const { return lastConnectLocal_; }
|
|
// MSG_NOSIGNAL: a flow the network killed (CSFB excursion, P-CSCF idle
|
|
// drop) must surface as a send error, not a SIGPIPE.
|
|
bool Send(std::string_view msg) {
|
|
if (transport_ == Transport::Udp && msg.size() > UdpSinglePacketBudget && !sizeWarned_) {
|
|
sizeWarned_ = true;
|
|
Log(std::format("UDP: {} bytes of SIP exceed the single-packet ESP budget (~{}) at MTU 1280; the kernel sends it as IPv6 fragments — a P-CSCF that cannot reassemble ESP fragments drops it silently", msg.size(), UdpSinglePacketBudget));
|
|
}
|
|
std::size_t off = 0;
|
|
while (off < msg.size()) {
|
|
ssize_t w = send(fd_, msg.data() + off, msg.size() - off, MSG_NOSIGNAL);
|
|
if (w <= 0) { alive_ = false; return false; }
|
|
off += static_cast<std::size_t>(w);
|
|
}
|
|
return true;
|
|
}
|
|
void SendKeepalive() {
|
|
if (transport_ == Transport::Udp) return; // RFC 5626 4.4.1: CRLF keepalives are for connection-oriented transports
|
|
if (send(fd_, "\r\n\r\n", 4, MSG_NOSIGNAL) <= 0) alive_ = false;
|
|
}
|
|
|
|
std::optional<std::string> RecvMsg(double timeoutSec) {
|
|
auto deadline = std::chrono::steady_clock::now() +
|
|
std::chrono::duration_cast<std::chrono::steady_clock::duration>(std::chrono::duration<double>(timeoutSec));
|
|
for (;;) {
|
|
if (auto m = fb_.TryExtract()) return m;
|
|
auto now = std::chrono::steady_clock::now();
|
|
if (now >= deadline) return std::nullopt;
|
|
int ms = static_cast<int>(std::chrono::duration_cast<std::chrono::milliseconds>(deadline - now).count());
|
|
pollfd p{fd_, POLLIN, 0};
|
|
int r = poll(&p, 1, ms);
|
|
if (r <= 0) { if (r == 0) return std::nullopt; if (errno == EINTR) continue; return std::nullopt; }
|
|
char buf[65535];
|
|
ssize_t n = recv(fd_, buf, sizeof buf, 0);
|
|
if (transport_ == Transport::Udp) {
|
|
// One datagram = one message (RFC 3261 18.3): never accumulated
|
|
// across datagrams. An empty datagram is not EOF. A recv error
|
|
// is the kernel relaying an ICMP for the inner (decrypted) UDP
|
|
// — port unreachable → ECONNREFUSED, prohibited/policy → EACCES
|
|
// — i.e. the peer answering "no"; an ICMP quoting the ESP
|
|
// packet itself never reaches us (esp6_err ignores all but
|
|
// PKT_TOOBIG), so UDP has no dead-flow signal beyond these and
|
|
// the refresh timeouts.
|
|
if (n < 0) {
|
|
if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) continue;
|
|
Log(std::format("UDP flow: recv error: {}", std::strerror(errno)));
|
|
alive_ = false; return std::nullopt;
|
|
}
|
|
if (n == 0) continue;
|
|
std::string why;
|
|
if (auto m = imsd::sip::DatagramMessage(std::string_view(buf, static_cast<std::size_t>(n)), &why)) return m;
|
|
if (!why.empty()) Log(std::format("UDP: {} — ignored", why));
|
|
continue;
|
|
}
|
|
// EOF/RST is not a timeout: mark the flow dead so the engine's
|
|
// reconnect path notices within one loop turn instead of at the
|
|
// next (possibly 30-min-away) keepalive refresh.
|
|
if (n <= 0) { alive_ = false; return std::nullopt; }
|
|
fb_.Append(std::string_view(buf, static_cast<std::size_t>(n)));
|
|
}
|
|
}
|
|
int Fd() const { return fd_; }
|
|
bool Alive() const { return fd_ >= 0 && alive_; }
|
|
Transport CurrentTransport() const { return transport_; }
|
|
// What was read and not yet dispatched goes with the flow: nothing
|
|
// read from it can be answered once it is closed.
|
|
void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; fb_ = {}; }
|
|
private:
|
|
int fd_ = -1;
|
|
bool alive_ = false;
|
|
bool lastConnectLocal_ = false;
|
|
Transport transport_ = Transport::Tcp;
|
|
bool sizeWarned_ = false;
|
|
imsd::sip::FrameBuffer fb_;
|
|
};
|
|
|
|
// ---- protected-port listeners (terminating requests: remote BYE/UPDATE) ---
|
|
struct Inbound {
|
|
std::string msg;
|
|
std::function<void(const std::string&)> reply;
|
|
};
|
|
|
|
class ServerPorts {
|
|
public:
|
|
void Open(const std::string& local) {
|
|
int fam = Is6(local) ? AF_INET6 : AF_INET;
|
|
for (int port : {PortUc, PortUs}) {
|
|
int ls = socket(fam, SOCK_STREAM, 0);
|
|
if (ls >= 0) {
|
|
int one = 1;
|
|
setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
|
// paired with SipFlow::Connect's REUSEPORT (reconnect binds
|
|
// the client port while this listener holds it)
|
|
setsockopt(ls, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);
|
|
sockaddr_storage a; socklen_t l = MakeAddr(local, port, a);
|
|
if (bind(ls, reinterpret_cast<sockaddr*>(&a), l) == 0 && listen(ls, 4) == 0) {
|
|
fcntl(ls, F_SETFL, O_NONBLOCK);
|
|
listeners_.push_back(ls);
|
|
} else { close(ls); }
|
|
}
|
|
int us = socket(fam, SOCK_DGRAM, 0);
|
|
if (us >= 0) {
|
|
int one = 1;
|
|
setsockopt(us, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
|
sockaddr_storage a; socklen_t l = MakeAddr(local, port, a);
|
|
if (bind(us, reinterpret_cast<sockaddr*>(&a), l) == 0) {
|
|
fcntl(us, F_SETFL, O_NONBLOCK);
|
|
udp_.push_back(us);
|
|
if (port == PortUc) udpUc_ = us;
|
|
} else { close(us); }
|
|
}
|
|
Log(std::format("listening on protected port {} (TCP+UDP)", port));
|
|
}
|
|
}
|
|
|
|
std::vector<Inbound> Poll() {
|
|
std::vector<Inbound> out;
|
|
for (int ls : listeners_) {
|
|
for (;;) {
|
|
int c = accept(ls, nullptr, nullptr);
|
|
if (c < 0) break;
|
|
fcntl(c, F_SETFL, O_NONBLOCK);
|
|
conns_.push_back({c, {}});
|
|
}
|
|
}
|
|
for (int us : udp_) {
|
|
for (;;) {
|
|
char buf[65535];
|
|
sockaddr_storage src; socklen_t sl = sizeof src;
|
|
ssize_t n = recvfrom(us, buf, sizeof buf, 0, reinterpret_cast<sockaddr*>(&src), &sl);
|
|
if (n <= 0) break;
|
|
std::string_view sv(buf, static_cast<std::size_t>(n));
|
|
bool blank = sv.find_first_not_of("\r\n \t") == std::string_view::npos;
|
|
if (blank) continue;
|
|
std::string msg(sv);
|
|
if (imsd::sip::Status(msg).has_value()) {
|
|
// A response here means the P-CSCF answered to the Via port
|
|
// instead of the source port of our request (rport) — it
|
|
// never reaches the client flow. Say so.
|
|
int sp = src.ss_family == AF_INET6 ? ntohs(reinterpret_cast<sockaddr_in6*>(&src)->sin6_port) : ntohs(reinterpret_cast<sockaddr_in*>(&src)->sin_port);
|
|
Log(std::format("UDP response on server socket {} from port {} ignored: {}", us == udpUc_ ? PortUc : PortUs, sp, sv.substr(0, sv.find("\r\n"))));
|
|
continue;
|
|
}
|
|
if (!imsd::sip::Status(msg).has_value()) { // request only
|
|
sockaddr_storage s = src; socklen_t sll = sl;
|
|
int rs = us;
|
|
// RFC 3261 18.2.2: rport-less UDP requests get their
|
|
// response at the Via sent-by port, not the source
|
|
// port. KPN's P-CSCF sends in-dialog requests from its
|
|
// protected client port but silently drops responses
|
|
// sent back there (s57: every UPDATE/BYE 200 ignored,
|
|
// audit kills the call); the Via port is its protected
|
|
// server port, whose SA pairs with OUR client-port
|
|
// socket — respond from that one so the uc->ps SA
|
|
// carries it.
|
|
if (auto vp = imsd::sip::ViaResponsePort(msg)) {
|
|
if (s.ss_family == AF_INET6)
|
|
reinterpret_cast<sockaddr_in6*>(&s)->sin6_port =
|
|
htons(static_cast<uint16_t>(*vp));
|
|
else
|
|
reinterpret_cast<sockaddr_in*>(&s)->sin_port =
|
|
htons(static_cast<uint16_t>(*vp));
|
|
if (udpUc_ >= 0) rs = udpUc_;
|
|
}
|
|
out.push_back({std::move(msg), [rs, s, sll](const std::string& r) mutable {
|
|
sendto(rs, r.data(), r.size(), 0, reinterpret_cast<sockaddr*>(&s), sll);
|
|
}});
|
|
}
|
|
}
|
|
}
|
|
for (auto it = conns_.begin(); it != conns_.end();) {
|
|
bool eof = false;
|
|
for (;;) {
|
|
char buf[65535];
|
|
ssize_t n = recv(it->fd, buf, sizeof buf, 0);
|
|
if (n == 0) { eof = true; break; }
|
|
if (n < 0) break;
|
|
it->fb.Append(std::string_view(buf, static_cast<std::size_t>(n)));
|
|
}
|
|
while (auto m = it->fb.TryExtract()) {
|
|
if (!imsd::sip::Status(*m).has_value()) {
|
|
int fd = it->fd;
|
|
out.push_back({*m, [fd](const std::string& r) {
|
|
(void)!write(fd, r.data(), r.size());
|
|
}});
|
|
}
|
|
}
|
|
if (eof) { close(it->fd); it = conns_.erase(it); } else { ++it; }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
void CloseAll() {
|
|
for (int f : listeners_) close(f);
|
|
for (int f : udp_) close(f);
|
|
for (auto& c : conns_) close(c.fd);
|
|
listeners_.clear(); udp_.clear(); conns_.clear();
|
|
udpUc_ = -1;
|
|
}
|
|
private:
|
|
struct Conn { int fd; imsd::sip::FrameBuffer fb; };
|
|
int udpUc_ = -1;
|
|
std::vector<int> listeners_, udp_;
|
|
std::vector<Conn> conns_;
|
|
};
|
|
|
|
// ---- registration state ---------------------------------------------------
|
|
struct RegState {
|
|
std::string callid, ftag;
|
|
long cseq = 2;
|
|
std::uint32_t spiUc = 0;
|
|
std::uint32_t spiUs = 0;
|
|
long expiry = 0;
|
|
int slot = 0;
|
|
std::string aid;
|
|
// Contact user-part of the stored binding (UUID since the s53 oracle
|
|
// diff; empty = legacy IMSI binding from an older state file).
|
|
std::string contactUser;
|
|
};
|
|
|
|
// Writable directory for the daemon's own files: the persisted registration
|
|
// context, media stats, and optional debug dumps. Root daemon (the packaged
|
|
// service): /var/lib/imsd; --session/dev runs: the XDG state dir.
|
|
std::string StateDir() {
|
|
if (geteuid() == 0) return "/var/lib/imsd";
|
|
if (const char* x = std::getenv("XDG_STATE_HOME")) return std::format("{}/imsd", std::string(x));
|
|
return std::format("{}/.local/state/imsd", EnvOr("HOME", "/tmp"));
|
|
}
|
|
|
|
// tiny JSON reader/writer for the persisted registration context.
|
|
std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); }
|
|
|
|
void PersistState(const RegState& r, const std::string& route, const std::string& ppi, const std::string& aor, const std::string& transport) {
|
|
std::string j = std::format(
|
|
"{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, "
|
|
"\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", "
|
|
"\"aor\": \"{}\", \"contact_user\": \"{}\", \"transport\": \"{}\"}}",
|
|
r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, aor,
|
|
r.contactUser, transport);
|
|
std::string path = StatePath();
|
|
std::error_code ec;
|
|
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
|
|
if (std::FILE* f = std::fopen(path.c_str(), "w")) {
|
|
std::fwrite(j.data(), 1, j.size(), f);
|
|
std::fclose(f);
|
|
}
|
|
}
|
|
|
|
// Raw dump of a received message for offline diffing against the stock-modem
|
|
// oracle (rung-5b registration-parity work). One file per message kind,
|
|
// overwritten on every registration cycle. The dumps carry the subscriber's
|
|
// IMSI/MSISDN and addresses, so they are opt-in (DUMP_SIP=1) and mode 0600.
|
|
void DumpRaw(std::string_view name, std::string_view msg) {
|
|
if (EnvOr("DUMP_SIP", "0") != "1") return;
|
|
std::string path = std::format("{}/{}", EnvOr("DUMP_DIR", StateDir()), name);
|
|
int fd = open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
|
if (fd < 0) return;
|
|
(void)!write(fd, msg.data(), msg.size());
|
|
close(fd);
|
|
}
|
|
|
|
struct PersistedState {
|
|
std::optional<std::string> callid, ftag, route, ppi, aor, contactUser, transport;
|
|
std::optional<long> cseq, spiUc, spiUs, expiry;
|
|
};
|
|
std::optional<PersistedState> LoadState() {
|
|
std::FILE* f = std::fopen(StatePath().c_str(), "r");
|
|
if (!f) return std::nullopt;
|
|
std::string s;
|
|
char buf[1024]; std::size_t n;
|
|
while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n);
|
|
std::fclose(f);
|
|
PersistedState ps;
|
|
ps.callid = QuotedAfter(s, "\"callid\": \"");
|
|
ps.ftag = QuotedAfter(s, "\"ftag\": \"");
|
|
ps.route = QuotedAfter(s, "\"route\": \"");
|
|
ps.ppi = QuotedAfter(s, "\"ppi\": \"");
|
|
ps.aor = QuotedAfter(s, "\"aor\": \"");
|
|
ps.contactUser = QuotedAfter(s, "\"contact_user\": \"");
|
|
ps.transport = QuotedAfter(s, "\"transport\": \""); // absent in pre-0.3.5 files: TCP
|
|
ps.cseq = IntAfter(s, "\"cseq\": ");
|
|
ps.spiUc = IntAfter(s, "\"spi_uc\": ");
|
|
ps.spiUs = IntAfter(s, "\"spi_us\": ");
|
|
ps.expiry = IntAfter(s, "\"expiry\": ");
|
|
return ps;
|
|
}
|
|
|
|
// ---- daemon-level shared state (main thread only) -------------------------
|
|
struct CallInfo {
|
|
std::string uni, number, state, reason;
|
|
std::int64_t startedAt = 0;
|
|
std::int64_t answeredAt = 0;
|
|
std::string direction = "outgoing";
|
|
};
|
|
struct StatusSnapshot {
|
|
bool registered = false;
|
|
std::string local, pcscf, ppi;
|
|
std::int64_t expiry = 0;
|
|
std::int64_t cseq = 0;
|
|
};
|
|
|
|
// engine -> main-loop event
|
|
struct Event {
|
|
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
|
|
};
|
|
|
|
GDBusConnection* DbusConn = nullptr;
|
|
std::map<std::string, CallInfo> Calls; // main thread only
|
|
StatusSnapshot StatusSnap; // main thread only
|
|
GMainLoop* MainLoop = nullptr;
|
|
bool FatalExit = false; // set by an engine-fatal event: main() exits non-zero so systemd restarts us
|
|
|
|
std::int64_t NowEpoch() {
|
|
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
// The recovery policy's holds, kept for the next process: the network's
|
|
// word (a Retry-After, the schedule after a refused or throttled fresh
|
|
// attempt, a terminated binding's wait) outlives a restart of imsd, and
|
|
// an upgrade restarts it. They are filed under the PDN's address —
|
|
// another one, or a reboot, starts clean — so the file names that
|
|
// address and the boot, and lives in /run. Seconds left at
|
|
// `written_at`, which is on the boot clock: no clock step moves it, and
|
|
// it runs through a suspend as the network's timers do. Whether the old
|
|
// registration is gone travels with them: a restart inside a hold must
|
|
// not resume over SAs the network has written off.
|
|
std::int64_t BootSeconds() {
|
|
timespec ts{};
|
|
clock_gettime(CLOCK_BOOTTIME, &ts);
|
|
return ts.tv_sec;
|
|
}
|
|
|
|
std::string BootId() {
|
|
std::string id;
|
|
if (std::FILE* f = std::fopen("/proc/sys/kernel/random/boot_id", "r")) {
|
|
char buf[64];
|
|
id.assign(buf, std::fread(buf, 1, sizeof buf, f));
|
|
std::fclose(f);
|
|
}
|
|
while (!id.empty() && std::isspace(static_cast<unsigned char>(id.back()))) id.pop_back();
|
|
return id;
|
|
}
|
|
|
|
// The recovery policy runs on the same clock: a hold in the process and
|
|
// its copy in the file age alike, through a suspend as the network's
|
|
// timers do.
|
|
double Boot() {
|
|
timespec ts{};
|
|
clock_gettime(CLOCK_BOOTTIME, &ts);
|
|
return static_cast<double>(ts.tv_sec) + static_cast<double>(ts.tv_nsec) / 1e9;
|
|
}
|
|
|
|
std::string HoldsPath() {
|
|
if (const char* o = std::getenv("HOLDS_FILE")) return o;
|
|
if (geteuid() == 0) return "/run/imsd.holds";
|
|
return std::format("{}/imsd.holds", EnvOr("XDG_RUNTIME_DIR", "/tmp"));
|
|
}
|
|
|
|
void PersistHolds(const imsd::engine::RegRecovery::Held& h, const std::string& pdn) {
|
|
std::string path = HoldsPath();
|
|
if (!h.Any()) {
|
|
// nothing to keep: the file goes — when it is ours. A device node or a
|
|
// link the HOLDS_FILE knob points at (/dev/null switches the file off) is
|
|
// emptied instead, never unlinked.
|
|
std::error_code ec;
|
|
auto st = std::filesystem::symlink_status(path, ec);
|
|
if (std::filesystem::is_regular_file(st)) std::remove(path.c_str());
|
|
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\": {}}}\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);
|
|
}
|
|
}
|
|
|
|
std::optional<imsd::engine::RegRecovery::Held> LoadHolds(const std::string& pdn) {
|
|
std::FILE* f = std::fopen(HoldsPath().c_str(), "r");
|
|
if (!f) return std::nullopt;
|
|
std::string s;
|
|
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 (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;
|
|
h.hold = std::max<long>(0, IntAfter(s, "\"hold\": ").value_or(0) - elapsed);
|
|
h.freshHold = std::max<long>(0, IntAfter(s, "\"fresh_hold\": ").value_or(0) - elapsed);
|
|
h.throttleMinutes = static_cast<int>(IntAfter(s, "\"throttle_minutes\": ").value_or(h.throttleMinutes));
|
|
h.oldGone = s.find("\"old_gone\": true") != std::string::npos;
|
|
if (!h.Any()) return std::nullopt;
|
|
return h;
|
|
}
|
|
|
|
// build an a{sv} from a CallInfo (matches imsd.py _d typing)
|
|
GVariant* CallVariant(const CallInfo& c) {
|
|
GVariantBuilder b;
|
|
g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}"));
|
|
g_variant_builder_add(&b, "{sv}", "uni", g_variant_new_string(c.uni.c_str()));
|
|
g_variant_builder_add(&b, "{sv}", "number", g_variant_new_string(c.number.c_str()));
|
|
g_variant_builder_add(&b, "{sv}", "state", g_variant_new_string(c.state.c_str()));
|
|
g_variant_builder_add(&b, "{sv}", "reason", g_variant_new_string(c.reason.c_str()));
|
|
g_variant_builder_add(&b, "{sv}", "startedAt", g_variant_new_int64(c.startedAt));
|
|
g_variant_builder_add(&b, "{sv}", "answeredAt", g_variant_new_int64(c.answeredAt));
|
|
g_variant_builder_add(&b, "{sv}", "direction", g_variant_new_string(c.direction.c_str()));
|
|
return g_variant_builder_end(&b);
|
|
}
|
|
|
|
void EmitSignal(const char* name, GVariant* params) {
|
|
if (!DbusConn) return;
|
|
g_dbus_connection_emit_signal(DbusConn, nullptr, ObjPath, Iface, name, params, nullptr);
|
|
}
|
|
|
|
gboolean OnIdleEvent(gpointer data) {
|
|
std::unique_ptr<Event> ev(static_cast<Event*>(data));
|
|
switch (ev->type) {
|
|
case Event::Type::Added: {
|
|
Calls[ev->info.uni] = ev->info;
|
|
EmitSignal("CallAdded", g_variant_new("(s@a{sv})", ev->info.uni.c_str(), CallVariant(ev->info)));
|
|
break;
|
|
}
|
|
case Event::Type::State: {
|
|
auto it = Calls.find(ev->uni);
|
|
if (it != Calls.end()) {
|
|
it->second.state = ev->state;
|
|
it->second.reason = ev->reason;
|
|
if (ev->state == "active" && it->second.answeredAt == 0) it->second.answeredAt = NowEpoch();
|
|
}
|
|
EmitSignal("CallStateChanged", g_variant_new("(sss)", ev->uni.c_str(), ev->state.c_str(), ev->reason.c_str()));
|
|
break;
|
|
}
|
|
case Event::Type::Deleted:
|
|
Calls.erase(ev->uni);
|
|
EmitSignal("CallDeleted", g_variant_new("(s)", ev->uni.c_str()));
|
|
break;
|
|
case Event::Type::Registration:
|
|
StatusSnap = ev->status;
|
|
EmitSignal("RegistrationChanged", g_variant_new("(b)", ev->status.registered ? TRUE : FALSE));
|
|
break;
|
|
case Event::Type::Status:
|
|
StatusSnap = ev->status;
|
|
break;
|
|
case Event::Type::Fatal:
|
|
Log(std::format("engine fatal: {} — exiting for systemd restart", ev->text));
|
|
FatalExit = true; // non-zero exit, or Restart=on-failure never fires
|
|
g_main_loop_quit(MainLoop);
|
|
break;
|
|
}
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
void PostEvent(std::unique_ptr<Event> ev) {
|
|
g_idle_add(OnIdleEvent, ev.release());
|
|
}
|
|
|
|
// ================= engine ==================================================
|
|
class Engine {
|
|
public:
|
|
Engine() {
|
|
// 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());
|
|
rtpPort_ = std::atoi(EnvOr("RTP_PORT", "50004").c_str());
|
|
precond_ = EnvOr("PRECOND", "0") == "1";
|
|
ealgOffer_ = EnvOr("EALG", "aes-cbc");
|
|
// SIP_TRANSPORT: the protected leg's transport. auto (default) = TCP,
|
|
// and when the P-CSCF never answers the TCP connect, a fresh
|
|
// registration over UDP; tcp / udp force one. The challenge (first
|
|
// REGISTER) is UDP regardless.
|
|
sipPolicy_ = EnvOr("SIP_TRANSPORT", "auto");
|
|
if (sipPolicy_ != "auto" && sipPolicy_ != "tcp" && sipPolicy_ != "udp") {
|
|
Log(std::format("SIP_TRANSPORT={} unknown; using auto", sipPolicy_));
|
|
sipPolicy_ = "auto";
|
|
}
|
|
transport_ = sipPolicy_ == "udp" ? Transport::Udp : Transport::Tcp;
|
|
mediaBin_ = ResolveMediaBin();
|
|
// CODECS: restrict + reorder the codecs we offer and accept (bench
|
|
// knob — a gateway that transcodes every caller up to AMR-WB never
|
|
// lets the narrowband path run otherwise). Empty = defaults.
|
|
codecs_ = imsd::sdp::ParseCodecList(EnvOr("CODECS", ""));
|
|
if (!codecs_.empty()) {
|
|
std::string list;
|
|
for (const auto& c : codecs_) list += (list.empty() ? "" : ",") + c;
|
|
Log(std::format("CODECS override active: {}", list));
|
|
}
|
|
// EMERGENCY_NUMBERS: comma-separated additions to the builtin
|
|
// 112/911 (a lab core's advertised short code, carrier extras).
|
|
std::string extra = EnvOr("EMERGENCY_NUMBERS", "");
|
|
for (std::size_t pos = 0; pos <= extra.size();) {
|
|
std::size_t comma = extra.find(',', pos);
|
|
std::size_t end = comma == std::string::npos ? extra.size() : comma;
|
|
std::string n = extra.substr(pos, end - pos);
|
|
std::erase(n, ' ');
|
|
if (!n.empty()) emergencyExtra_.push_back(std::move(n));
|
|
if (comma == std::string::npos) break;
|
|
pos = comma + 1;
|
|
}
|
|
}
|
|
|
|
void SetLocal(std::string l) { local_ = l; pdnLocal_ = std::move(l); }
|
|
void SetDevMode(bool d) { devMode_ = d; }
|
|
const std::string& Local() const { return local_; }
|
|
|
|
// Dial() runs on the D-Bus thread: allocate the uni, queue the work.
|
|
std::string Dial(const std::string& number) {
|
|
std::scoped_lock g(cmdLock_);
|
|
std::string uni = std::format("ims-call-{}", ++callSeq_);
|
|
cmds_.push_back({Cmd::Kind::Dial, uni, number});
|
|
return uni;
|
|
}
|
|
void HangUp(const std::string& uni) {
|
|
std::scoped_lock g(cmdLock_);
|
|
cmds_.push_back({Cmd::Kind::HangUp, uni, {}});
|
|
}
|
|
void Accept(const std::string& uni) {
|
|
std::scoped_lock g(cmdLock_);
|
|
cmds_.push_back({Cmd::Kind::Accept, uni, {}});
|
|
}
|
|
void Stop() {
|
|
{ std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); }
|
|
quit_.store(true);
|
|
}
|
|
|
|
void Run() {
|
|
if (auto h = LoadHolds(pdnLocal_)) {
|
|
recovery_.Restore(Boot(), *h);
|
|
auto kept = recovery_.Holds(Boot()); // what the policy took over: the file's values after the clamp
|
|
if (kept.hold > 0 || kept.freshHold > 0)
|
|
Log(std::format("a hold the network set before this start stands ({:.0f} s for any attempt, {:.0f} s for a fresh registration; the refusal schedule at {} min)", kept.hold, kept.freshHold, kept.throttleMinutes));
|
|
else if (kept.throttleMinutes > imsd::engine::RegRecovery::ThrottleMinutesMin)
|
|
Log(std::format("the refusal schedule stands at {} min from before this start: a refused or throttled fresh registration waits that long", kept.throttleMinutes));
|
|
if (kept.oldGone) Log("the old registration was gone before this start: no resume over its SAs, the fresh registration once its wait has run");
|
|
}
|
|
for (;;) {
|
|
try {
|
|
BringUp();
|
|
break;
|
|
} catch (const Stopping&) {
|
|
return;
|
|
} catch (const ThrottledError& e) {
|
|
DeferBringUp(e.what(), imsd::engine::RegRecovery::Failure::Throttled, 0);
|
|
if (quit_.load()) return;
|
|
continue;
|
|
} catch (const RefusedError& e) {
|
|
// A refused initial registration (403, 5xx) waits like a
|
|
// throttle: a fatal would restart imsd every RestartSec with an
|
|
// initial REGISTER each time.
|
|
DeferBringUp(e.what(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter);
|
|
if (quit_.load()) return;
|
|
continue;
|
|
} catch (const std::exception& e) {
|
|
if (devMode_) {
|
|
// dev/session mode (no modem): keep the ABI up, unregistered,
|
|
// so the D-Bus surface can be exercised without a phone.
|
|
Log(std::format("bring-up skipped (dev mode): {}", e.what()));
|
|
registered_ = false;
|
|
EmitStatus(true);
|
|
DevLoop();
|
|
return;
|
|
}
|
|
Log(std::format("bring-up FAILED: {}", e.what()));
|
|
auto ev = std::make_unique<Event>();
|
|
ev->type = Event::Type::Fatal; ev->text = e.what();
|
|
PostEvent(std::move(ev));
|
|
return;
|
|
}
|
|
}
|
|
try { server_.Open(local_); }
|
|
catch (...) { Log("server ports unavailable; remote hangup/incoming not seen"); }
|
|
OnRegistered();
|
|
|
|
while (!quit_.load()) {
|
|
if (auto cmd = PopCmd()) { HandleCmd(*cmd); continue; }
|
|
for (Inbound& in : server_.Poll()) {
|
|
reply_ = in.reply;
|
|
DispatchRequest(in.msg);
|
|
}
|
|
MaybeRefresh();
|
|
MaybeReconnect();
|
|
MaybeResubscribe();
|
|
if (!call_ && sip_.Alive() && Mono() - lastKa_ > 30) { sip_.SendKeepalive(); lastKa_ = Mono(); }
|
|
ReapMedia();
|
|
if (call_ && call_->State() != imsd::engine::CallState::Active && Mono() > deadline_) {
|
|
Execute(call_->OnDeadline());
|
|
MaybeDropCall();
|
|
}
|
|
auto msg = sip_.RecvMsg(0.3);
|
|
if (!msg) continue;
|
|
auto st = imsd::sip::Status(*msg);
|
|
if (!st) { reply_ = [this](const std::string& r) { sip_.Send(r); }; DispatchRequest(*msg); continue; }
|
|
std::string cseq(imsd::sip::Header(*msg, "CSeq").value_or(""));
|
|
std::string callid(imsd::sip::Header(*msg, "Call-ID").value_or(""));
|
|
if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) {
|
|
Execute(call_->OnInviteResponse(*msg));
|
|
MaybeDropCall();
|
|
} else {
|
|
OnSubscribeResponse(*msg, *st);
|
|
}
|
|
}
|
|
// shutdown: release an active call, keep the SA/registration for resume
|
|
if (call_) { Execute(call_->OnHangup()); MaybeDropCall(); }
|
|
sip_.Close();
|
|
Log("engine stopped (SA left up for resume)");
|
|
}
|
|
|
|
private:
|
|
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
|
|
// it out here, in process, on the recovery policy's schedule, then try
|
|
// the bring-up again. There is nothing to reconnect over meanwhile (the
|
|
// resume just found the stored SAs unusable, or there are none) and no
|
|
// recovery step runs from inside the wait — portPs_ says so to
|
|
// MaybeReconnect, whatever the attempt left in it — so a Dial
|
|
// meanwhile collapses at once, as in dev mode.
|
|
void DeferBringUp(std::string_view why, imsd::engine::RegRecovery::Failure f, long retryAfter) {
|
|
FreshAttemptFailed(f, retryAfter, std::format("bring-up deferred: {}", why));
|
|
portPs_ = 0;
|
|
registered_ = false;
|
|
EmitStatus(true);
|
|
WaitForPolicy();
|
|
}
|
|
|
|
// Wait until the policy's next step is due. A Dial/Accept/HangUp
|
|
// arriving meanwhile must not wait in the queue and run stale once
|
|
// registration succeeds; with no flow the call collapses to terminated
|
|
// at once (DevLoop's rule). A wait can be long (an hour on the
|
|
// schedule), so the REGISTER after one names the access network read
|
|
// after it, not before. False when the engine is stopping.
|
|
bool WaitForPolicy() {
|
|
bool waited = false;
|
|
while (!quit_.load() && recovery_.Next(Boot()).at > Boot()) {
|
|
waited = true;
|
|
if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; }
|
|
std::this_thread::sleep_for(std::chrono::seconds(1));
|
|
}
|
|
if (waited && !quit_.load()) RefreshPani();
|
|
return !quit_.load();
|
|
}
|
|
|
|
void KeepHolds() { PersistHolds(recovery_.Holds(Boot()), pdnLocal_); }
|
|
|
|
// dev/session mode without a modem: process commands so the ABI answers,
|
|
// but there is no SIP socket — a Dial collapses to terminated/error.
|
|
void DevLoop() {
|
|
while (!quit_.load()) {
|
|
if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; }
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
|
}
|
|
}
|
|
|
|
// config
|
|
std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_;
|
|
// 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).
|
|
std::string pdnLocal_;
|
|
std::string sipPolicy_; // SIP_TRANSPORT: auto | tcp | udp
|
|
Transport transport_ = Transport::Tcp; // the protected leg's transport in effect
|
|
void SetTransport(Transport t) { transport_ = t; ctx_.transport = std::string(TransportName(t)); }
|
|
std::vector<std::string> emergencyExtra_;
|
|
int pcscfPort_ = 5060;
|
|
int rtpPort_ = 50004;
|
|
bool precond_ = false;
|
|
std::vector<std::string> codecs_; // CODECS override; empty = defaults
|
|
|
|
// registration
|
|
imsd::msg::Context ctx_;
|
|
RegState reg_;
|
|
std::string route_, ppi_, ss_;
|
|
bool registered_ = false;
|
|
SipFlow sip_;
|
|
ServerPorts server_;
|
|
std::string subCallid_; // live reg-event subscription dialog (empty: none)
|
|
bool subPending_ = false; // SUBSCRIBE sent, no final yet
|
|
double subSentAt_ = 0;
|
|
double subRetryAt_ = 0; // a failed/ended subscription is renewed at this time (0: none due)
|
|
double subRenewAt_ = 0; // the live subscription is subscribed anew at this time, ahead of its granted expiry (0: none)
|
|
double subRetryDelay_ = 60; // 60 s doubling to 10 min between failed SUBSCRIBEs
|
|
int portPs_ = 0; // P-CSCF protected server port (reconnect target)
|
|
imsd::engine::RegRecovery recovery_; // what to do when the registration stops working
|
|
bool saReplaced_ = false; // the running fresh attempt got past the SA setup (the old registration's SAs are gone)
|
|
|
|
// call
|
|
imsd::util::Rng rng_;
|
|
std::optional<imsd::engine::CallMachine> call_;
|
|
bool dropCall_ = false;
|
|
bool closeFlowWhenIdle_ = false; // a binding was terminated under a call: its flow closes with the call
|
|
pid_t mediaPid_ = -1;
|
|
double deadline_ = 0;
|
|
// reply_ answers the inbound request being dispatched right now;
|
|
// callReply_ is the call's durable response channel — refreshed from
|
|
// reply_ on every in-dialog request, so an Accept()'s 200 OK (which
|
|
// happens outside any inbound dispatch) still reaches the caller.
|
|
std::function<void(const std::string&)> reply_;
|
|
std::function<void(const std::string&)> callReply_;
|
|
|
|
// timers
|
|
double lastRefresh_ = 0;
|
|
double lastKa_ = 0;
|
|
std::atomic<bool> quit_{false};
|
|
bool devMode_ = false;
|
|
|
|
// command queue
|
|
std::mutex cmdLock_;
|
|
std::deque<Cmd> cmds_;
|
|
// atomic: Dial (D-Bus thread) and an inbound INVITE (engine thread) both mint unis
|
|
std::atomic<std::uint64_t> callSeq_{0};
|
|
|
|
static double Mono() {
|
|
return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
std::optional<Cmd> PopCmd() {
|
|
std::scoped_lock g(cmdLock_);
|
|
if (cmds_.empty()) return std::nullopt;
|
|
Cmd c = cmds_.front(); cmds_.pop_front();
|
|
return c;
|
|
}
|
|
|
|
static std::string ResolveMediaBin() {
|
|
if (const char* e = std::getenv("IMSD_MEDIA")) return e;
|
|
if (!SelfDir.empty()) {
|
|
std::string p = std::format("{}/imsd-media", SelfDir);
|
|
if (access(p.c_str(), X_OK) == 0) return p;
|
|
}
|
|
return "/usr/libexec/imsd-media";
|
|
}
|
|
|
|
// Refresh P-Access-Network-Info from the live serving cell (mmcli 3GPP
|
|
// location). Serving cell changes as the phone moves, so this runs
|
|
// before every REGISTER/INVITE-carrying step; on failure the previous
|
|
// value (possibly empty = header omitted) stays.
|
|
void RefreshPani() {
|
|
auto loc = RunCapture({"mmcli", "-m", "any", "--location-get"});
|
|
if (auto p = imsd::aka::ParsePani(loc.out)) ctx_.panInfo = *p;
|
|
}
|
|
|
|
// ---- bring-up: warm resume if an SA + state file exist, else fresh ----
|
|
void BringUp() {
|
|
if (pcscf_.empty()) throw std::runtime_error("PCSCF not set — configure the carrier's P-CSCF address " "(P-CSCF discovery from the PDN's PCO is not implemented yet)");
|
|
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");
|
|
auto modem = RunCapture({"mmcli", "-m", "any"});
|
|
auto simPath = imsd::aka::ParsePrimarySimPath(modem.out);
|
|
if (!simPath) throw std::runtime_error("no primary SIM path");
|
|
auto siminfo = RunCapture({"mmcli", "-i", *simPath});
|
|
auto info = imsd::aka::ParseSimInfo(siminfo.out);
|
|
if (!info) throw std::runtime_error("SIM IMSI/operator not found");
|
|
ctx_.id = imsd::aka::MakeIdentity(info->imsi, info->mcc, info->mnc);
|
|
ctx_.local = local_;
|
|
ctx_.ealgOffer = ealgOffer_;
|
|
SetTransport(transport_);
|
|
Log(std::format("SIP transport: {} ({})", TransportName(transport_), sipPolicy_));
|
|
// USER_AGENT overrides (some networks fingerprint UAs — setting the
|
|
// stock firmware's build string gives oracle parity, journal/ims.md
|
|
// s53); USER_AGENT= (empty) omits the header.
|
|
ctx_.userAgent = EnvOr("USER_AGENT", std::format("imsd/{}", Version));
|
|
if (auto imei = imsd::aka::ParseEquipmentId(modem.out))
|
|
if (auto urn = imsd::aka::ImeiUrn(*imei)) ctx_.instanceId = *urn;
|
|
reg_.slot = sel->slot;
|
|
reg_.aid = sel->aid;
|
|
Log(std::format("SIM slot={} IMSI={} domain={}", sel->slot, info->imsi, ctx_.id.domain));
|
|
RefreshPani();
|
|
|
|
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: 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.
|
|
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
|
|
try { if (recovery_.Reconnectable() && TryResume()) return; }
|
|
catch (const RefusedError& e) {
|
|
Log(std::format("resume failed: {} — the next registration is a fresh one", e.what()));
|
|
CloseFlowAfterWait();
|
|
if (e.retryAfter > 0) recovery_.OnReconnectFailure(Boot(), imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, e.status);
|
|
}
|
|
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
|
|
// fresh registration waits
|
|
recovery_.OldGone();
|
|
portPs_ = 0;
|
|
local_ = pdnLocal_; ctx_.local = local_;
|
|
KeepHolds();
|
|
if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep()));
|
|
if (!WaitForPolicy()) throw Stopping{};
|
|
FreshRegister();
|
|
}
|
|
|
|
bool TryResume() {
|
|
auto state = RunCapture({"ip", "xfrm", "state"});
|
|
auto policy = RunCapture({"ip", "xfrm", "policy"});
|
|
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
|
|
// F18). Resume there while it is still up — a restart, an upgrade
|
|
// among them, keeps the registration for the price of a re-REGISTER,
|
|
// where a fresh one is an AKA run, a new SA pair and, when KPN
|
|
// refuses it, the old registration and a 21-minute wait (KPN
|
|
// refuses a fresh pair on an address whose registration is live and
|
|
// has accepted one on another address of the link meanwhile; other
|
|
// carriers' rules are unknown) — and move at the next fresh
|
|
// registration.
|
|
if (!sa) {
|
|
for (const std::string& other : imsd::ipsec::SaLocals(state.out, pcscf_)) {
|
|
if (other == local_ || !OnLink(dev_, other)) continue;
|
|
sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, other);
|
|
if (!sa) continue;
|
|
Log(std::format("the registration's SAs are on {}, not the PDN's own address {}: resuming there until the next fresh registration", other, local_));
|
|
local_ = other; ctx_.local = other;
|
|
break;
|
|
}
|
|
}
|
|
if (!sa) return false;
|
|
auto ps = LoadState();
|
|
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("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
|
|
// is re-learned from the next 200's P-Associated-URI.
|
|
ppi_ = ps->ppi.value_or("");
|
|
ss_ = sa->securityServer;
|
|
// The public identity our requests are signed with (From) — restored
|
|
// here so a call placed before the refresh 200 re-learns it does not
|
|
// fall back to the barred temporary IMPU.
|
|
ctx_.aor = ps->aor.value_or("");
|
|
ctx_.route = route_; ctx_.ppi = ppi_; ctx_.securityServer = ss_;
|
|
ctx_.portUc = PortUc; ctx_.portUs = PortUs;
|
|
Log(std::format("RESUME via existing SA (cseq {})", ps->cseq.value_or(1)));
|
|
|
|
portPs_ = sa->portPs;
|
|
SetTransport(t);
|
|
// 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("resume connect failed; falling back to fresh");
|
|
return false;
|
|
}
|
|
reg_.callid = *ps->callid;
|
|
reg_.ftag = ps->ftag.value_or("");
|
|
// The refresh must carry the SAME Contact URI as the stored binding;
|
|
// an absent key means the binding predates the oracle diff and used
|
|
// the IMSI (the empty-contactUser fallback).
|
|
reg_.contactUser = ps->contactUser.value_or("");
|
|
ctx_.contactUser = reg_.contactUser;
|
|
reg_.cseq = ps->cseq.value_or(1);
|
|
reg_.spiUc = static_cast<std::uint32_t>(ps->spiUc.value_or(sa->spiUc));
|
|
reg_.spiUs = static_cast<std::uint32_t>(ps->spiUs.value_or(sa->spiUs));
|
|
reg_.expiry = ps->expiry.value_or(0);
|
|
auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs);
|
|
reg_.cseq = used;
|
|
if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e;
|
|
UpdateRouteAndPpi(okMsg);
|
|
LogBindings(okMsg);
|
|
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
|
Log("REGISTERED (resumed + true-refreshed)");
|
|
return true;
|
|
}
|
|
|
|
// The registrar's 200 OK echoes EVERY current binding for the implicit
|
|
// set — the network's own view. Log them: a stale binding at a dead
|
|
// SLAAC address is still tried by terminating routing until it expires.
|
|
void LogBindings(const std::string& ok) {
|
|
for (auto ct : imsd::sip::Headers(ok, "Contact"))
|
|
Log(std::format("binding: {}", ct));
|
|
}
|
|
|
|
// A fresh registration. The attempt mints its own Contact user-part into
|
|
// ctx_ for its REGISTERs; on any failure the stored binding's comes back,
|
|
// so requests over the existing SA meanwhile (a reconnect, a refresh)
|
|
// carry the binding the network knows. reg_ takes the new one on the 200.
|
|
void FreshRegister() {
|
|
saReplaced_ = false;
|
|
try { FreshRegisterAttempt(); }
|
|
catch (...) { ctx_.contactUser = reg_.contactUser; throw; }
|
|
}
|
|
|
|
void FreshRegisterAttempt() {
|
|
Log("FRESH registration");
|
|
// Oracle diff (journal/ims.md s53): stock's Contact user-part is a
|
|
// fresh UUID per registration, not the IMSI. Minted before reg1 so
|
|
// both REGISTERs and the stored binding carry the same URI.
|
|
std::string contactUser = imsd::util::Uuid4(rng_);
|
|
ctx_.contactUser = contactUser;
|
|
Log(std::format("contact user-part: {}", contactUser));
|
|
std::string callidReg = std::format("{}@{}", rng_.Token(16), local_);
|
|
std::string ftag = rng_.Token(8);
|
|
std::uint32_t spiUc = rng_.UInt(0x10000, 0xFFFFFFF);
|
|
std::uint32_t spiUs = rng_.UInt(0x10000, 0xFFFFFFF);
|
|
ctx_.portUc = PortUc; ctx_.portUs = PortUs; ctx_.initPort = InitPort;
|
|
|
|
// reg1 over UDP from the init port -> 401
|
|
int fam = Is6(local_) ? AF_INET6 : AF_INET;
|
|
int us = socket(fam, SOCK_DGRAM, 0);
|
|
int one = 1; setsockopt(us, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
|
sockaddr_storage la; socklen_t ll = MakeAddr(local_, InitPort, la);
|
|
if (bind(us, reinterpret_cast<sockaddr*>(&la), ll) != 0) {
|
|
close(us); throw std::runtime_error("bind init port failed");
|
|
}
|
|
std::string reg1 = imsd::msg::BuildRegisterInitial(ctx_, callidReg, ftag, rng_.Token(16), spiUc, spiUs);
|
|
sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf_, pcscfPort_, pa);
|
|
sendto(us, reg1.data(), reg1.size(), 0, reinterpret_cast<sockaddr*>(&pa), pl);
|
|
std::string resp = AwaitInitialFinal(us, callidReg);
|
|
if (auto st1 = imsd::sip::Status(resp); st1 != 401) {
|
|
DumpRaw("imsd-register-fail.raw", resp);
|
|
std::string what = std::format("expected 401, got {}", imsd::sip::RefusalSummary(resp));
|
|
if (st1 && *st1 >= 300) throw RefusedError(what, *st1, imsd::sip::RetryAfter(resp).value_or(0));
|
|
throw std::runtime_error(what);
|
|
}
|
|
auto nonceB64 = QuotedAfter(resp, "nonce=\"");
|
|
if (!nonceB64) throw std::runtime_error("no nonce in 401");
|
|
auto raw = imsd::util::FromBase64(*nonceB64);
|
|
if (!raw || raw->size() < 32) throw std::runtime_error("bad nonce");
|
|
std::vector<std::uint8_t> rand16(raw->begin(), raw->begin() + 16);
|
|
std::vector<std::uint8_t> autn16(raw->begin() + 16, raw->begin() + 32);
|
|
// Every Security-Server instance is one comma-list (RFC 3261
|
|
// §7.3.1); the joined value is what Security-Verify echoes — RFC
|
|
// 3329 §2.2 wants the whole list back, not the chosen entry.
|
|
auto ssLines = imsd::sip::Headers(resp, "Security-Server");
|
|
if (ssLines.empty()) throw std::runtime_error("no Security-Server");
|
|
ss_.clear();
|
|
for (std::string_view line : ssLines) {
|
|
if (!ss_.empty()) ss_ += ',';
|
|
ss_ += line;
|
|
}
|
|
Log(std::format("Security-Server: {}", ss_));
|
|
// Algorithms from the mechanism we select against our offer (RFC
|
|
// 3329 §2.3.1), SPIs/ports from the entry carrying them (O2 lists
|
|
// six and marks one; ims ledger F11). An answer we cannot use is a
|
|
// deferral on the throttle schedule, not a fatal: a fatal restarts
|
|
// imsd every RestartSec, an initial REGISTER each time.
|
|
auto ss = imsd::ipsec::ParseSecurityServer(ss_, "hmac-sha-1-96", ealgOffer_);
|
|
if (!ss) throw ThrottledError("Security-Server has no ipsec-3gpp mechanism");
|
|
const imsd::ipsec::SecurityMechanism& mech = ss->Selected();
|
|
const imsd::ipsec::SecurityMechanism& carrier = ss->Carrier();
|
|
Log(std::format("selected mechanism {} of {}{}: {}", ss->selected + 1, ss->mechanisms.size(), ss->offerMatched ? "" : " (none matches our offer; taking the P-CSCF's)", mech.text));
|
|
if (ss->carrier != ss->selected) Log(std::format("SPIs/ports from mechanism {}: {}", ss->carrier + 1, carrier.text));
|
|
if (carrier.spiPs == 0u) throw ThrottledError("fresh-SA throttle active (spi-s=0)");
|
|
if (!carrier.spiPs || !carrier.spiPc || !carrier.portPs || !carrier.portPc) throw ThrottledError("Security-Server carries no spi-c/spi-s/port-c/port-s");
|
|
std::string alg = mech.alg.empty() ? "hmac-sha-1-96" : mech.alg; // absent: what we offered
|
|
std::string ealg = imsd::ipsec::NormalizeEalg(mech.ealg); // absent: the null cipher (RFC 3329)
|
|
if (!imsd::ipsec::KernelAuth(alg)) throw ThrottledError(std::format("Security-Server alg={} unsupported", alg));
|
|
if (!imsd::ipsec::KnownEalg(ealg)) throw ThrottledError(std::format("Security-Server ealg={} unsupported", ealg));
|
|
if (!mech.prot.empty() && mech.prot != "esp") throw ThrottledError(std::format("Security-Server prot={} unsupported", mech.prot));
|
|
if (!mech.mod.empty() && mech.mod != "trans") throw ThrottledError(std::format("Security-Server mod={} unsupported", mech.mod));
|
|
std::uint32_t spiPs = *carrier.spiPs;
|
|
std::uint32_t spiPc = *carrier.spiPc;
|
|
int portPs = *carrier.portPs;
|
|
int portPc = *carrier.portPc;
|
|
Log(std::format("SA: alg={} ealg={}{} spi-c={} spi-s={} port-c={} port-s={}", alg, ealg, mech.ealg.empty() ? " (absent)" : "", spiPc, spiPs, portPc, portPs));
|
|
|
|
auto aka = Authenticate(rand16, autn16);
|
|
if (!aka) throw std::runtime_error("USIM AKA failed");
|
|
|
|
// install the IPsec SA pair
|
|
imsd::ipsec::SaParams sp;
|
|
sp.local = local_; sp.pcscf = pcscf_;
|
|
sp.ik = aka->ik; sp.ck = aka->ck;
|
|
sp.spiUc = spiUc; sp.spiUs = spiUs; sp.spiPc = spiPc; sp.spiPs = spiPs;
|
|
sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc;
|
|
sp.alg = alg;
|
|
sp.ealg = ealg;
|
|
saReplaced_ = true; // from here on the old registration's SAs are gone
|
|
for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) {
|
|
int rc = RunCmd(cmd);
|
|
if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc));
|
|
}
|
|
|
|
ctx_.securityServer = ss_;
|
|
portPs_ = portPs;
|
|
// The fallback is for a P-CSCF that never served TCP. Once a TCP
|
|
// registration has succeeded on this phone (the state file records
|
|
// it), a silent connect is an outage, not a policy — two unanswered
|
|
// SYNs during a radio gap must not become a second initial REGISTER
|
|
// (the throttle trigger on KPN).
|
|
bool tcpKnownGood = false;
|
|
if (auto ps = LoadState(); ps && ps->callid.has_value()) {
|
|
std::string t = ps->transport.value_or("TCP");
|
|
tcpKnownGood = (t == "TCP");
|
|
// A phone whose last registration was UDP goes straight to UDP:
|
|
// a TCP probe on every boot would be a second initial REGISTER
|
|
// per boot on a carrier that never answers it.
|
|
if (sipPolicy_ == "auto" && t == "UDP" && transport_ == Transport::Tcp) {
|
|
Log("state file records a UDP registration; skipping the TCP probe");
|
|
SetTransport(Transport::Udp);
|
|
}
|
|
}
|
|
bool autoTcp = (sipPolicy_ == "auto" && transport_ == Transport::Tcp && !tcpKnownGood);
|
|
if (!sip_.Connect(local_, PortUc, pcscf_, portPs, transport_, 10, autoTcp ? 2 : 0)) {
|
|
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 flushes this one), same ports.
|
|
Log("protected TCP connect unanswered; registering fresh over UDP");
|
|
SetTransport(Transport::Udp);
|
|
FreshRegisterAttempt();
|
|
return;
|
|
}
|
|
throw std::runtime_error(std::format("protected {} connect failed", TransportName(transport_)));
|
|
}
|
|
Log(std::format("protected leg over {}", TransportName(transport_)));
|
|
|
|
std::string cnonce = rng_.Token(16);
|
|
std::string response = imsd::aka::DigestAkav1(*nonceB64, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain);
|
|
std::string auth = imsd::msg::AuthAka(ctx_, *nonceB64, cnonce, response);
|
|
std::string reg2 = imsd::msg::BuildRegisterProtected(ctx_, callidReg, ftag, rng_.Token(16), 2, spiUc, spiUs, auth);
|
|
if (!sip_.Send(reg2)) throw std::runtime_error("send protected REGISTER failed");
|
|
std::string ok = AwaitFinal(callidReg, 2, "protected REGISTER");
|
|
if (auto st = imsd::sip::Status(ok); st != 200) {
|
|
DumpRaw("imsd-register-fail.raw", ok);
|
|
throw RefusedError(std::format("REGISTER refused: {}", imsd::sip::RefusalSummary(ok)), *st, imsd::sip::RetryAfter(ok).value_or(0));
|
|
}
|
|
reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2;
|
|
reg_.contactUser = contactUser;
|
|
reg_.spiUc = spiUc; reg_.spiUs = spiUs;
|
|
reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0);
|
|
DumpRaw("imsd-register-200.raw", ok);
|
|
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);
|
|
Log("REGISTERED (fresh)");
|
|
}
|
|
|
|
// Closes the flow of a REGISTER wait that failed. A call that arrived
|
|
// under the wait (AwaitFinal dispatches requests) ends with it: a 486
|
|
// while the flow still carries one, and the bus told.
|
|
void CloseFlowAfterWait() {
|
|
if (call_) {
|
|
Log(std::format("call {} arrived under a registration that failed; ending it", call_->Uni()));
|
|
Execute(call_->OnHangup());
|
|
MaybeDropCall();
|
|
if (call_) { Execute(call_->Abandon("registration failed")); MaybeDropCall(); }
|
|
}
|
|
sip_.Close();
|
|
}
|
|
|
|
// The final of the REGISTER (callid, cseq) just sent, within 12 s of
|
|
// the send or of its last provisional. A request arriving on the client
|
|
// flow meanwhile (a NOTIFY, an OPTIONS) is dispatched and answered, not
|
|
// read as the reply; a provisional of our own transaction keeps the
|
|
// wait going; a response to our reg-event SUBSCRIBE is handled as the
|
|
// run loop handles it, any other transaction's is ignored. Throws
|
|
// when nothing answers or the flow dies under a dispatched request.
|
|
std::string AwaitFinal(const std::string& callid, long cseq, std::string_view what) {
|
|
std::string want = std::format("{} REGISTER", cseq);
|
|
double deadline = Mono() + 12.0;
|
|
for (;;) {
|
|
double left = deadline - Mono();
|
|
auto m = left > 0 ? sip_.RecvMsg(left) : std::nullopt;
|
|
if (!m) throw std::runtime_error(std::format("no reply to {}", what));
|
|
auto st = imsd::sip::Status(*m);
|
|
if (!st) {
|
|
reply_ = [this](const std::string& r) { sip_.Send(r); };
|
|
DispatchRequest(*m);
|
|
if (!sip_.Alive()) throw std::runtime_error(std::format("flow closed while awaiting the {} reply", what));
|
|
continue;
|
|
}
|
|
if (!imsd::sip::ResponseOf(*m, callid, want)) {
|
|
if (!OnSubscribeResponse(*m, *st)) Log(std::format("ignoring a response to another transaction while awaiting the {} reply", what));
|
|
continue;
|
|
}
|
|
if (*st >= 200) return *m;
|
|
Log(std::format("{} -> {} (provisional); waiting for the final", what, *st));
|
|
deadline = Mono() + 12.0;
|
|
}
|
|
}
|
|
|
|
// The final of the unprotected REGISTER (callid, CSeq 1) on the init
|
|
// socket, within 8 s of the send or of its last provisional. The socket
|
|
// is bound to the init port, not connected: what else it delivers — a
|
|
// late final of an earlier attempt, a stray datagram — is logged with
|
|
// its source port and skipped, never judged as the reply. Closes the
|
|
// socket; throws when nothing answers.
|
|
std::string AwaitInitialFinal(int us, const std::string& callid) {
|
|
double deadline = Mono() + 8.0;
|
|
char buf[65535];
|
|
for (;;) {
|
|
double left = deadline - Mono();
|
|
if (left <= 0) break;
|
|
long sec = static_cast<long>(left);
|
|
long usec = static_cast<long>((left - static_cast<double>(sec)) * 1e6);
|
|
if (sec == 0 && usec == 0) usec = 1000;
|
|
timeval tv{sec, usec};
|
|
setsockopt(us, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
|
|
sockaddr_storage src; socklen_t sl = sizeof src;
|
|
ssize_t n = recvfrom(us, buf, sizeof buf, 0, reinterpret_cast<sockaddr*>(&src), &sl);
|
|
if (n <= 0) break;
|
|
std::string m(buf, static_cast<std::size_t>(n));
|
|
if (!imsd::sip::ResponseOf(m, callid, "1 REGISTER")) {
|
|
int sp = src.ss_family == AF_INET6 ? ntohs(reinterpret_cast<sockaddr_in6*>(&src)->sin6_port) : ntohs(reinterpret_cast<sockaddr_in*>(&src)->sin_port);
|
|
std::string_view sv = m;
|
|
Log(std::format("ignoring a datagram on the init port from port {} that is not the unprotected REGISTER's reply: {}", sp, sv.substr(0, sv.find("\r\n"))));
|
|
continue;
|
|
}
|
|
int st = *imsd::sip::Status(m);
|
|
if (st >= 200) { close(us); return m; }
|
|
Log(std::format("unprotected REGISTER -> {} (provisional); waiting for the final", st));
|
|
deadline = Mono() + 8.0;
|
|
}
|
|
close(us);
|
|
throw std::runtime_error("no 401 to unprotected REGISTER");
|
|
}
|
|
|
|
// re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq).
|
|
std::pair<std::string, long> Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) {
|
|
std::string auth = imsd::msg::AuthEmpty(ctx_);
|
|
long cseq = startCseq;
|
|
bool challenged = false;
|
|
for (;;) {
|
|
std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth);
|
|
if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed");
|
|
std::string m = AwaitFinal(callid, cseq, "re-REGISTER");
|
|
auto st = imsd::sip::Status(m);
|
|
if (st == 200) return {m, cseq};
|
|
if (st == 401 && !challenged) {
|
|
challenged = true;
|
|
auto nonce = QuotedAfter(m, "nonce=\"");
|
|
auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt;
|
|
if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh");
|
|
std::vector<std::uint8_t> rnd(raw->begin(), raw->begin() + 16);
|
|
std::vector<std::uint8_t> autn(raw->begin() + 16, raw->begin() + 32);
|
|
auto aka = Authenticate(rnd, autn);
|
|
if (!aka) throw std::runtime_error("AKA failed on refresh");
|
|
std::string cnonce = rng_.Token(16);
|
|
std::string response = imsd::aka::DigestAkav1(*nonce, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain);
|
|
auth = imsd::msg::AuthAka(ctx_, *nonce, cnonce, response);
|
|
cseq++;
|
|
continue;
|
|
}
|
|
DumpRaw("imsd-register-fail.raw", m);
|
|
throw RefusedError(std::format("re-REGISTER refused: {}", imsd::sip::RefusalSummary(m)), *st, imsd::sip::RetryAfter(m).value_or(0));
|
|
}
|
|
}
|
|
|
|
void UpdateRouteAndPpi(const std::string& okMsg) {
|
|
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;
|
|
for (std::size_t i = 0; i < svc.size(); i++) { r += svc[i]; if (i + 1 < svc.size()) r += ", "; }
|
|
route_ = r; ctx_.route = r;
|
|
Log(std::format("200 Service-Route: {}", r));
|
|
}
|
|
for (auto p : imsd::sip::Headers(okMsg, "Path"))
|
|
Log(std::format("200 Path: {}", p));
|
|
auto pau = imsd::sip::Headers(okMsg, "P-Associated-URI");
|
|
if (pau.empty()) Log("200: NO P-Associated-URI");
|
|
for (auto a : pau)
|
|
Log(std::format("200 P-Associated-URI: {}", a));
|
|
for (auto a : imsd::sip::Headers(okMsg, "P-Associated-URI")) {
|
|
std::size_t lt = a.find("<tel:");
|
|
if (lt != std::string_view::npos) {
|
|
std::size_t gt = a.find('>', lt);
|
|
ppi_ = std::string(a.substr(lt + 1, gt - lt - 1));
|
|
ctx_.ppi = ppi_;
|
|
break;
|
|
}
|
|
}
|
|
// default public identity for self-targeted requests (reg-event
|
|
// SUBSCRIBE): first sip: P-Associated-URI entry, else the tel: one
|
|
// (the temporary IMPU is barred for anything but REGISTER)
|
|
for (auto a : imsd::sip::Headers(okMsg, "P-Associated-URI")) {
|
|
std::size_t lt = a.find("<sip:");
|
|
if (lt != std::string_view::npos) {
|
|
std::size_t gt = a.find('>', lt);
|
|
ctx_.aor = std::string(a.substr(lt + 1, gt - lt - 1));
|
|
break;
|
|
}
|
|
}
|
|
if (ctx_.aor.empty() && !ppi_.empty()) ctx_.aor = ppi_;
|
|
if (ppi_.empty()) Log("200: no tel: P-Associated-URI — P-Preferred-Identity omitted");
|
|
Log(std::format("self AOR: {}", ctx_.aor.empty() ? ctx_.id.impu : ctx_.aor));
|
|
}
|
|
|
|
// USIM AKA via qmicli UIM logical channel.
|
|
std::optional<imsd::aka::AkaResult> Authenticate(std::span<const std::uint8_t> rand16, std::span<const std::uint8_t> autn16) {
|
|
auto open = RunCapture({"qmicli", "-d", "qrtr://0",
|
|
std::format("--uim-open-logical-channel={},{}", reg_.slot, reg_.aid)});
|
|
auto ch = CompletedInt(open.out);
|
|
if (!ch) return std::nullopt;
|
|
int channel = static_cast<int>(*ch);
|
|
auto closeCh = [&] {
|
|
RunCapture({"qmicli", "-d", "qrtr://0",
|
|
std::format("--uim-close-logical-channel={},{}", reg_.slot, channel)});
|
|
};
|
|
std::string apdu = imsd::aka::BuildAkaApdu(channel, rand16, autn16);
|
|
auto r1 = RunCapture({"qmicli", "-d", "qrtr://0",
|
|
std::format("--uim-send-apdu={},{},{}", reg_.slot, channel, apdu)});
|
|
auto b1 = CompletedHex(r1.out);
|
|
if (!b1) { closeCh(); return std::nullopt; }
|
|
if (b1->size() >= 2 && b1->substr(0, 2) == "61") {
|
|
std::string gr = imsd::aka::BuildGetResponseApdu(channel, std::stoi(b1->substr(2, 2), nullptr, 16));
|
|
auto r2 = RunCapture({"qmicli", "-d", "qrtr://0",
|
|
std::format("--uim-send-apdu={},{},{}", reg_.slot, channel, gr)});
|
|
b1 = CompletedHex(r2.out);
|
|
if (!b1) { closeCh(); return std::nullopt; }
|
|
}
|
|
closeCh();
|
|
auto bytes = imsd::util::FromHex(*b1);
|
|
if (!bytes) return std::nullopt;
|
|
return imsd::aka::ParseAkaResponse(*bytes);
|
|
}
|
|
|
|
// ---- keepalive refresh (only while idle) ------------------------------
|
|
void MaybeRefresh() {
|
|
if (call_ || reg_.callid.empty()) return;
|
|
if (!sip_.Alive()) return; // MaybeReconnect owns dead-flow recovery
|
|
long exp = reg_.expiry ? reg_.expiry : 3600;
|
|
double interval;
|
|
if (const char* o = std::getenv("REFRESH_INTERVAL")) interval = std::atof(o);
|
|
else interval = std::max(120.0, std::min(exp * 0.5, 1800.0));
|
|
if (Mono() - lastRefresh_ < interval) return;
|
|
try {
|
|
RefreshPani();
|
|
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs);
|
|
reg_.cseq = used;
|
|
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
|
|
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
|
Log(std::format("keepalive re-REGISTER ok (cseq {})", used));
|
|
OnRegistered();
|
|
} catch (const RefusedError& e) {
|
|
// The network refused the refresh: the recovery policy counts it
|
|
// — a refusal with a Retry-After of at least a second makes the
|
|
// fresh registration the next step, behind the network's wait;
|
|
// one without gets one more refresh first, a second refusal then
|
|
// escalates — and the flow is dropped so the next step runs
|
|
// after the wait.
|
|
Log(std::format("keepalive re-REGISTER failed: {}", e.what()));
|
|
CloseFlowAfterWait();
|
|
RecoveryFailed(imsd::engine::RegRecovery::Failure::Refused, e.retryAfter, "keepalive refresh refused", e.status);
|
|
} catch (const std::exception& e) {
|
|
// A dead flow or a silent P-CSCF: the recovery is the same as a
|
|
// daemon restart's — drop the flow and let MaybeReconnect re-run
|
|
// the connect + true-refresh sequence at once.
|
|
Log(std::format("keepalive re-REGISTER failed: {}", e.what()));
|
|
CloseFlowAfterWait();
|
|
}
|
|
lastRefresh_ = Mono();
|
|
lastKa_ = Mono();
|
|
}
|
|
|
|
// The protected client flow can die under us while the SA and the
|
|
// registration stay valid — a CSFB excursion resets it, the P-CSCF drops
|
|
// idle flows — which is exactly the state a daemon restart resumes from
|
|
// (s51: "restart-to-recover"). Do what the restart does, in place:
|
|
// reconnect the TCP leg over the existing SA and true-refresh. When that
|
|
// keeps failing — the network refuses the refresh, or nothing answers —
|
|
// do the rest of what a restart does: register fresh. RegRecovery holds
|
|
// the thresholds and the pacing (a dead PDN must not turn this into a
|
|
// hot loop; a carrier that refuses fresh SAs must not be hammered).
|
|
void MaybeReconnect() {
|
|
if (call_ || sip_.Alive() || portPs_ == 0 || reg_.callid.empty()) return;
|
|
auto plan = recovery_.Next(Boot());
|
|
if (plan.at > Boot()) return;
|
|
if (plan.step == imsd::engine::RegRecovery::Step::Fresh) FreshRecover();
|
|
else ReconnectCycle();
|
|
}
|
|
|
|
// The policy's next step, as the journal line: what, and when.
|
|
std::string NextStep() {
|
|
auto plan = recovery_.Next(Boot());
|
|
double in = plan.at - Boot();
|
|
std::string what = plan.step == imsd::engine::RegRecovery::Step::Fresh ? "fresh registration" : "reconnect over the existing SA";
|
|
if (in <= 0) return std::format("next: {} now", what);
|
|
if (in >= 60) return std::format("next: {} in {:.0f} min", what, in / 60);
|
|
return std::format("next: {} in {:.0f} s", what, in);
|
|
}
|
|
|
|
// ModemManager's word on the phone's own service, as ims-pdn-up.sh reads
|
|
// it (`mmcli -K`). False only when the modem clearly has none: a state
|
|
// short of registered, or packet service detached. Anything else —
|
|
// ModemManager not answering, a state or packet-service value this
|
|
// code does not know — counts as service: the network's silence, not
|
|
// the phone's, is the default.
|
|
bool PacketServiceUp() {
|
|
auto r = RunCapture({"mmcli", "-m", "any", "-K"});
|
|
auto field = [&](std::string_view key) -> std::string {
|
|
std::size_t at = r.out.find(key);
|
|
if (at == std::string::npos) return {};
|
|
std::size_t end = r.out.find('\n', at);
|
|
if (end == std::string::npos) end = r.out.size();
|
|
std::size_t colon = r.out.find(':', at);
|
|
if (colon == std::string::npos || colon > end) return {};
|
|
std::string v = r.out.substr(colon + 1, end - colon - 1);
|
|
while (!v.empty() && v.front() == ' ') v.erase(v.begin());
|
|
while (!v.empty() && (v.back() == ' ' || v.back() == '\r')) v.pop_back();
|
|
return v;
|
|
};
|
|
std::string state = field("modem.generic.state "), ps = field("modem.3gpp.packet-service-state ");
|
|
for (const char* down : {"failed", "initializing", "locked", "disabled", "disabling", "enabling", "enabled", "searching"})
|
|
if (state == down) return false;
|
|
return ps != "detached";
|
|
}
|
|
|
|
// A cycle that failed while the modem reports no packet service is the
|
|
// phone's silence, not the network's: not counted, retried on the
|
|
// local-refusal pace like a connect() the kernel refused — a radio gap
|
|
// across a refresh must not escalate to a fresh registration, which KPN
|
|
// refuses while the old one is live, and drops it for.
|
|
void CoverageGap(std::string_view what) {
|
|
if (registered_) { registered_ = false; EmitStatus(true); }
|
|
recovery_.OnLocalRefusal(Boot());
|
|
Log(std::format("{} while the modem reports no packet service: not counted; {}", what, NextStep()));
|
|
}
|
|
|
|
void ReconnectCycle() {
|
|
using F = imsd::engine::RegRecovery::Failure;
|
|
Log("client flow dead; reconnecting");
|
|
sip_.Close();
|
|
if (!sip_.Connect(local_, PortUc, pcscf_, portPs_, transport_, /*tries=*/2)) {
|
|
if (AddressGone()) return;
|
|
if (sip_.LastConnectLocal()) {
|
|
if (registered_) { registered_ = false; EmitStatus(true); }
|
|
recovery_.OnLocalRefusal(Boot());
|
|
return;
|
|
}
|
|
if (!PacketServiceUp()) { CoverageGap("P-CSCF silent"); return; }
|
|
RecoveryFailed(F::Silent, 0, "P-CSCF silent");
|
|
return;
|
|
}
|
|
try {
|
|
RefreshPani();
|
|
auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs);
|
|
reg_.cseq = used;
|
|
if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e;
|
|
UpdateRouteAndPpi(msg);
|
|
LogBindings(msg);
|
|
PersistState(reg_, route_, ppi_, ctx_.aor, ctx_.transport);
|
|
Log("reconnected + re-registered");
|
|
OnRegistered();
|
|
} catch (const RefusedError& e) {
|
|
Log(std::format("reconnect re-REGISTER failed: {}", e.what()));
|
|
CloseFlowAfterWait();
|
|
RecoveryFailed(F::Refused, e.retryAfter, "refresh refused", e.status);
|
|
} catch (const std::exception& e) {
|
|
Log(std::format("reconnect re-REGISTER failed: {}", e.what()));
|
|
CloseFlowAfterWait();
|
|
// the binding terminated under the wait (the flow closed, or
|
|
// under a call the wait timed out): that verdict named the next
|
|
// step, this is no silence
|
|
if (!recovery_.Reconnectable()) return;
|
|
if (!PacketServiceUp()) { CoverageGap("refresh unanswered"); return; }
|
|
RecoveryFailed(F::Silent, 0, "refresh unanswered");
|
|
}
|
|
}
|
|
|
|
// A failed reconnect + refresh cycle: the status goes unregistered, the
|
|
// policy decides whether the next step is another cycle or a fresh
|
|
// registration, and when.
|
|
void RecoveryFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, std::string_view what, int status = 0) {
|
|
if (registered_) { registered_ = false; EmitStatus(true); }
|
|
bool wasReconnectable = recovery_.Reconnectable();
|
|
recovery_.OnReconnectFailure(Boot(), f, retryAfter, status);
|
|
KeepHolds();
|
|
Log(std::format("{} ({} refused, {} silent cycles){}; {}", what, recovery_.refusals, recovery_.silentCycles,
|
|
wasReconnectable && !recovery_.Reconnectable() ? std::format(" — the old registration is gone ({})", status) : "", NextStep()));
|
|
}
|
|
|
|
// The rest of what a restart does, in place: a new Call-ID, a new
|
|
// challenge, a new SA pair (FreshRegister flushes the old ones), then
|
|
// 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
|
|
// short backoff. The attempt's flow is closed whichever way it failed;
|
|
// past the SA setup the old registration's SAs are gone with it, so
|
|
// nothing is retried until the next fresh attempt.
|
|
void FreshRecover() {
|
|
using F = imsd::engine::RegRecovery::Failure;
|
|
Log(std::format("registering fresh (recovery attempt {})", recovery_.freshAttempts + 1));
|
|
sip_.Close();
|
|
saReplaced_ = false; // this attempt's, whatever fails first
|
|
// the subscription dialog is the registration's and goes with the
|
|
// attempt; a retry the network scheduled (its Retry-After) stands
|
|
// until a fresh registration replaces the old one; an attempt that
|
|
// fails before the SA setup leaves the old registration and that
|
|
// retry standing
|
|
subCallid_.clear(); subPending_ = false; subRenewAt_ = 0;
|
|
// 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
|
|
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_);
|
|
}
|
|
bool ok = false;
|
|
try {
|
|
RefreshPani();
|
|
FreshRegister();
|
|
subRetryAt_ = 0; // a new registration: its subscription starts afresh
|
|
OnRegistered();
|
|
ok = true;
|
|
Log("recovered by a fresh registration");
|
|
} catch (const ThrottledError& e) {
|
|
FreshAttemptFailed(F::Throttled, 0, std::format("fresh registration deferred: {}", e.what()));
|
|
} catch (const RefusedError& e) {
|
|
FreshAttemptFailed(F::Refused, e.retryAfter, std::format("fresh registration refused: {}", e.what()));
|
|
} catch (const std::exception& e) {
|
|
FreshAttemptFailed(F::Silent, 0, std::format("fresh registration failed: {}", e.what()));
|
|
}
|
|
if (!ok && !quit_.load() && recovery_.Reconnectable() && local_ != from) {
|
|
if (OnLink(dev_, from)) {
|
|
Log(std::format("back on {}: the old registration stands until the next fresh attempt", from));
|
|
MoveTo(from);
|
|
} else {
|
|
recovery_.OldGone();
|
|
KeepHolds(); // the verdict outlives a restart like the hold it goes with
|
|
Log(std::format("{} has left {}: the registration on it is gone; staying on {}; {}", from, dev_, local_, NextStep()));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Our address for everything from here on: the flow, the server ports,
|
|
// the requests' Via and Contact, the media leg.
|
|
void MoveTo(const std::string& addr) {
|
|
server_.CloseAll();
|
|
local_ = addr; ctx_.local = addr;
|
|
server_.Open(local_);
|
|
}
|
|
|
|
// After a failed fresh attempt, in recovery or at bring-up: the
|
|
// attempt's flow closed (a refused protected REGISTER leaves it open),
|
|
// the policy told, and its next step logged.
|
|
void FreshAttemptFailed(imsd::engine::RegRecovery::Failure f, long retryAfter, const std::string& what) {
|
|
CloseFlowAfterWait();
|
|
if (!OnLink(dev_, local_)) Log(what); // said before the address's own line: a refusal is always in the journal
|
|
if (AddressGone()) return;
|
|
bool wasReconnectable = recovery_.Reconnectable();
|
|
recovery_.OnFreshFailure(Boot(), f, retryAfter, saReplaced_);
|
|
KeepHolds();
|
|
Log(std::format("{}{}; {}", what, wasReconnectable && !recovery_.Reconnectable() ? " — the old registration is gone" : "", NextStep()));
|
|
}
|
|
|
|
// After a failed recovery step: has our address left the link? An
|
|
// older registration's address (a pre-0.3.10 temporary one that
|
|
// expired) takes that registration with it: imsd moves to the PDN's
|
|
// own address and the policy's fresh step runs there, every wait it
|
|
// holds included. The PDN's own address leaving means the PDN went
|
|
// away — no retry brings it back; a restart runs the PDN bring-up
|
|
// again. True when the step that failed has nothing left to do.
|
|
bool AddressGone() {
|
|
if (OnLink(dev_, local_)) return false;
|
|
if (local_ != pdnLocal_ && OnLink(dev_, pdnLocal_)) {
|
|
if (registered_) { registered_ = false; EmitStatus(true); }
|
|
std::string from = local_;
|
|
recovery_.OldGone();
|
|
MoveTo(pdnLocal_);
|
|
KeepHolds(); // the caller returns before its own write
|
|
Log(std::format("{} is no longer on {}: the registration on it is gone; on the PDN's own address {} now; {}", from, dev_, local_, NextStep()));
|
|
return true;
|
|
}
|
|
RequestRestart(std::format("{} is no longer on {}", local_, dev_));
|
|
return true;
|
|
}
|
|
|
|
// Leave through systemd (Restart=on-failure), which runs ims-pdn-up.sh
|
|
// again and registers on the PDN's own address.
|
|
void RequestRestart(const std::string& why) {
|
|
quit_.store(true);
|
|
auto ev = std::make_unique<Event>();
|
|
ev->type = Event::Type::Fatal; 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
|
|
// subscription.
|
|
void OnRegistered() {
|
|
recovery_.OnSuccess();
|
|
closeFlowWhenIdle_ = false; // this registration's flow stands, whatever a verdict said of the one before
|
|
KeepHolds();
|
|
bool was = registered_;
|
|
registered_ = true;
|
|
EmitStatus(!was);
|
|
lastRefresh_ = Mono(); lastKa_ = Mono();
|
|
// a refresh or a reconnect opens no subscription dialog while imsd
|
|
// holds one for the registration or has a retry scheduled; a fresh
|
|
// registration (or a start) has none yet, and a fresh attempt drops
|
|
// the one it holds before it sends. A retry already scheduled (the
|
|
// network's Retry-After, the backoff) is MaybeResubscribe's to send,
|
|
// not a registration's to bring forward — nor to start its backoff
|
|
// over: that resets with a subscription that starts afresh here, or
|
|
// is accepted
|
|
if (subCallid_.empty() && !subPending_ && subRetryAt_ == 0) { subRetryDelay_ = 60; SubscribeRegEvent(); }
|
|
}
|
|
|
|
// Subscribe to the reg event package (TS 24.229 5.1.1.3): one dialog per
|
|
// registration, sent over the registration flow; the S-CSCF's immediate
|
|
// NOTIFY (logged in DispatchRequest) is the network's own view of our
|
|
// bindings. Failure is non-fatal — registration is unaffected.
|
|
void SubscribeRegEvent() {
|
|
subRetryAt_ = 0; subRenewAt_ = 0;
|
|
subCallid_ = std::format("{}@{}", rng_.Token(16), local_);
|
|
std::string sub = imsd::msg::BuildSubscribeReg(ctx_, subCallid_, rng_.Token(8), rng_.Token(16));
|
|
if (sip_.Send(sub)) { Log("reg-event SUBSCRIBE sent"); subPending_ = true; subSentAt_ = Mono(); }
|
|
else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); ScheduleResubscribe(0); }
|
|
}
|
|
|
|
// A response on the client flow that belongs to our reg-event
|
|
// SUBSCRIBE, wherever the flow is read (the run loop, a REGISTER
|
|
// wait). False when it is not ours.
|
|
bool OnSubscribeResponse(const std::string& msg, int st) {
|
|
if (subCallid_.empty() || imsd::sip::Header(msg, "Call-ID") != subCallid_) return false;
|
|
if (!imsd::sip::Header(msg, "CSeq").value_or("").ends_with("SUBSCRIBE")) return false;
|
|
if (st >= 300) {
|
|
Log(std::format("reg-event SUBSCRIBE refused: {}", imsd::sip::RefusalSummary(msg)));
|
|
DumpRaw("imsd-subscribe-fail.raw", msg);
|
|
subPending_ = false; subCallid_.clear();
|
|
ScheduleResubscribe(imsd::sip::RetryAfter(msg).value_or(0));
|
|
} else {
|
|
Log(std::format("reg-event SUBSCRIBE -> {}", st));
|
|
if (st >= 200) {
|
|
DumpRaw("imsd-subscribe-200.raw", msg); subPending_ = false; subRetryDelay_ = 60;
|
|
if (auto e = imsd::sip::GrantedExpires(msg)) ArmSubscriptionRenewal(*e);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The subscription is part of being registered (TS 24.229 5.1.1.3): a
|
|
// refused or unanswered SUBSCRIBE is retried, 60 s doubling to 10 min
|
|
// (a Retry-After wins), and a subscription the S-CSCF ends is renewed.
|
|
// Until now one failure left the daemon deaf to the network's view of
|
|
// its binding — O2 UK 2026-09-24: SUBSCRIBE 500, the registration
|
|
// killed 8 s later, nothing heard.
|
|
void ScheduleResubscribe(long retryAfter) {
|
|
double wait = std::max(subRetryDelay_, static_cast<double>(std::min(retryAfter, imsd::engine::RegRecovery::RetryAfterMax)));
|
|
subRetryDelay_ = std::min(subRetryDelay_ * 2, 600.0);
|
|
subRetryAt_ = Mono() + wait;
|
|
Log(std::format("reg-event: next SUBSCRIBE in {:.0f} s", wait));
|
|
}
|
|
void MaybeResubscribe() {
|
|
if (subPending_ && Mono() - subSentAt_ > 32) { // Timer F: no final at all
|
|
Log("reg-event SUBSCRIBE unanswered");
|
|
subPending_ = false; subCallid_.clear();
|
|
ScheduleResubscribe(0);
|
|
}
|
|
if (call_ || !registered_ || !sip_.Alive()) return;
|
|
if (!subCallid_.empty() && !subPending_ && subRenewAt_ > 0 && Mono() >= subRenewAt_) {
|
|
// the granted lifetime is running out: a new dialog (the S-CSCF
|
|
// closes the previous one — KPN with noresource), on the same
|
|
// registration
|
|
Log("reg-event: the subscription nears its expiry; subscribing anew");
|
|
subCallid_.clear(); subRenewAt_ = 0;
|
|
SubscribeRegEvent();
|
|
return;
|
|
}
|
|
if (subRetryAt_ == 0 || Mono() < subRetryAt_) return;
|
|
SubscribeRegEvent();
|
|
}
|
|
|
|
// The network granted the subscription `expires` seconds (a SUBSCRIBE
|
|
// 2xx, a NOTIFY's Subscription-State): subscribe anew 10 min before the
|
|
// end, or halfway through a shorter grant.
|
|
void ArmSubscriptionRenewal(long expires) {
|
|
double ahead = expires > 1200 ? expires - 600.0 : expires / 2.0;
|
|
subRenewAt_ = Mono() + std::max(ahead, 30.0);
|
|
Log(std::format("reg-event: subscription granted {} s; renewed in {:.0f} s", expires, subRenewAt_ - Mono()));
|
|
}
|
|
|
|
// The network's view of our binding (RFC 3680 reginfo), read from any
|
|
// reg-event NOTIFY — a subscription an earlier process left behind
|
|
// carries the same account (O2, 2026-09-24: the previous dialog's NOTIFY
|
|
// reached a restarted daemon whose own SUBSCRIBE had just got 500). A
|
|
// binding the registrar terminated needs a new registration: the
|
|
// policy decides when (TS 24.229 5.4.1.5, 5.1.1.7 — now for deactivated
|
|
// and expired, after retry-after for probation, on the refusal schedule
|
|
// for rejected and unregistered). Active or not listed: nothing to do.
|
|
// Subscription state is per dialog: only our live dialog's end is ours
|
|
// to renew, unless the binding went with it.
|
|
void OnRegEventNotify(const std::string& msg, std::string_view body) {
|
|
using S = imsd::sip::RegInfoContact::State;
|
|
auto c = imsd::sip::RegInfoContactState(body, imsd::msg::ContactUser(ctx_));
|
|
bool bindingGone = false;
|
|
if (c.state == S::Terminated) {
|
|
bindingGone = true;
|
|
if (registered_) { registered_ = false; EmitStatus(true); }
|
|
// a call lives on the client flow: the flow closes when the
|
|
// call is dropped, not under it — the call goes on, or the
|
|
// network releases it, and the fresh step follows the call as
|
|
// every other recovery step does
|
|
if (call_) closeFlowWhenIdle_ = true;
|
|
else
|
|
sip_.Close();
|
|
subCallid_.clear(); subPending_ = false; subRetryAt_ = 0; subRenewAt_ = 0;
|
|
recovery_.OnBindingTerminated(Boot(), c.event, c.retryAfter);
|
|
KeepHolds();
|
|
Log(std::format("reg-event: our binding is terminated ({}); {}", c.event, NextStep()));
|
|
} else if (c.state == S::Active) {
|
|
Log("reg-event: our binding is active");
|
|
}
|
|
auto ss = imsd::sip::Header(msg, "Subscription-State");
|
|
std::string_view callid = imsd::sip::Header(msg, "Call-ID").value_or("");
|
|
bool ours = !subCallid_.empty() && callid == subCallid_;
|
|
if (ss && ss->starts_with("active") && ours)
|
|
if (auto e = IntAfter(*ss, "expires=")) ArmSubscriptionRenewal(*e);
|
|
if (ss && ss->starts_with("terminated") && !ours && !bindingGone) {
|
|
Log(std::format("reg-event: an earlier subscription ({}) ended ({}); ours ({}) stands", callid, *ss, subCallid_.empty() ? "none" : subCallid_));
|
|
} else if (ss && ss->starts_with("terminated") && !bindingGone) {
|
|
// RFC 6665 §4.1.3: renew after deactivated, timeout, giveup and
|
|
// probation (after its retry-after, on the 60 s retry floor); not after rejected
|
|
// or noresource. KPN's S-CSCF ends a registration's previous
|
|
// subscription with noresource when a new one replaces it (dev
|
|
// phone 2026-09-25, Call-IDs compared); the 2026-09-24 reading
|
|
// that it ends every subscription came from opening a new one
|
|
// at every refresh and taking that closure for our own
|
|
std::string reason;
|
|
if (auto r = ss->find("reason="); r != std::string_view::npos) {
|
|
std::string_view v = ss->substr(r + 7);
|
|
reason = std::string(v.substr(0, v.find_first_of(";, \t\r")));
|
|
}
|
|
subCallid_.clear(); subPending_ = false; subRenewAt_ = 0;
|
|
if (reason == "deactivated" || reason == "timeout" || reason == "giveup" || reason == "probation") {
|
|
Log(std::format("reg-event: subscription ended ({}); renewing", *ss));
|
|
ScheduleResubscribe(IntAfter(*ss, "retry-after=").value_or(0));
|
|
} else {
|
|
Log(std::format("reg-event: subscription ended ({}); the next refresh subscribes anew", *ss));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- command handling -------------------------------------------------
|
|
// A Dial while a call exists: refused, unless it is an emergency dial,
|
|
// which preempts whatever exists (TS 22.101) — released, and abandoned
|
|
// if it lingers awaiting a CANCEL's 487 (the late final is ignored by
|
|
// Call-ID mismatch). False when the dial was refused.
|
|
bool ClearForDial(const Cmd& c, bool emergency) {
|
|
if (!call_) return true;
|
|
if (!emergency) {
|
|
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);
|
|
return false;
|
|
}
|
|
Log(std::format("emergency dial: preempting call {}", call_->Uni()));
|
|
Execute(call_->OnHangup());
|
|
MaybeDropCall();
|
|
if (call_) { Execute(call_->Abandon("preempted by emergency dial")); MaybeDropCall(); }
|
|
return true;
|
|
}
|
|
|
|
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.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
|
|
// backoff short, never a wait the network imposed (a
|
|
// Retry-After, the schedule after a refused or throttled fresh
|
|
// attempt, the one after a binding the network rejected or
|
|
// unregistered, probation's retry-after — the policy keeps those).
|
|
// The shot's REGISTER wait dispatches
|
|
// requests, so an incoming call may have arrived under it: the
|
|
// same rule again.
|
|
if (!sip_.Alive()) {
|
|
recovery_.OnDial(); MaybeReconnect();
|
|
if (!ClearForDial(c, emergency)) return;
|
|
}
|
|
RefreshPani();
|
|
if (emergency)
|
|
Log(std::format("EMERGENCY dial ({}): urn:service:sos over the live registration, "
|
|
"fallback plain INVITE — stage 1, no emergency registration/PDN; "
|
|
"carrier-side behavior UNVERIFIED", c.number));
|
|
call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_, emergency, 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")); }
|
|
MaybeDropCall();
|
|
} else if (c.kind == Cmd::Kind::HangUp) {
|
|
if (call_ && call_->Uni() == c.uni) { Execute(call_->OnHangup()); MaybeDropCall(); }
|
|
} 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));
|
|
}
|
|
}
|
|
|
|
void DispatchRequest(const std::string& msg) {
|
|
std::string callid(imsd::sip::Header(msg, "Call-ID").value_or(""));
|
|
std::size_t sp = msg.find(' ');
|
|
std::string method = msg.substr(0, sp == std::string::npos ? 0 : sp);
|
|
if (call_ && callid == call_->CallId()) {
|
|
callReply_ = reply_; // freshest channel for this dialog
|
|
Execute(call_->OnRequest(msg));
|
|
MaybeDropCall();
|
|
return;
|
|
}
|
|
// ACK is never answered — e.g. the caller ACKing the 486/487 of a
|
|
// call machine we already dropped
|
|
if (method == "ACK") return;
|
|
// reg-event NOTIFY: log the S-CSCF's reginfo (every binding it holds
|
|
// for the implicit set, feature tags as stored), then 200 it below.
|
|
// MESSAGE (terminating SMS-over-IP while +g.3gpp.smsip is
|
|
// advertised): log the whole body — the 200 acks delivery, so the
|
|
// journal is the only place the payload survives until real SMSoIP
|
|
// handling exists.
|
|
if (method == "NOTIFY" || method == "MESSAGE") {
|
|
std::size_t at = msg.find("\r\n\r\n");
|
|
std::string_view body = at == std::string::npos
|
|
? std::string_view{}
|
|
: std::string_view(msg).substr(at + 4);
|
|
if (method == "MESSAGE") Log(std::format("MESSAGE from {} ({} bytes):\n{}", imsd::sip::CallerId(msg), body.size(), body));
|
|
else if (auto ev = imsd::sip::Header(msg, "Event"); ev && ev->starts_with("reg")) {
|
|
Log(std::format("reg-event NOTIFY ({} bytes):\n{}", body.size(), body));
|
|
if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg));
|
|
OnRegEventNotify(msg, body);
|
|
return;
|
|
}
|
|
if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg));
|
|
return;
|
|
}
|
|
if (method == "INVITE") {
|
|
OnIncomingInvite(msg);
|
|
return;
|
|
}
|
|
if (reply_)
|
|
reply_(imsd::msg::BuildResponse200(ctx_, msg)); // unsolicited (OPTIONS etc.)
|
|
}
|
|
|
|
// An out-of-dialog INVITE: a terminating (incoming) call.
|
|
void OnIncomingInvite(const std::string& msg) {
|
|
if (call_) {
|
|
Log("incoming INVITE while a call exists: 486");
|
|
if (reply_) reply_(imsd::msg::BuildInviteResponse(ctx_, msg, 486, "Busy Here", rng_.Token(10)));
|
|
return;
|
|
}
|
|
std::string uni = std::format("ims-call-{}", ++callSeq_);
|
|
DumpRaw("imsd-invite-in.raw", msg);
|
|
call_.emplace(ctx_, rng_, uni, imsd::engine::IncomingInvite{msg}, rtpPort_, codecs_);
|
|
callReply_ = reply_;
|
|
Log(std::format("incoming call {} from {}", uni, call_->Number()));
|
|
// Validate the offer BEFORE the UI hears of the call. A refused
|
|
// INVITE (488: nothing we can play) is answered and dropped without
|
|
// ever announcing it — announcing first produced a 24 ms
|
|
// added/terminated/deleted burst that left Plasma Dialer stuck as a
|
|
// lock-screen overlay with no call to show (field report 2026-09-01).
|
|
// The journal keeps the record: caller and reason are logged above
|
|
// and by the engine's teardown line.
|
|
auto actions = call_->OnInvite();
|
|
if (call_->Terminated()) {
|
|
Execute(actions, /*announce=*/false);
|
|
MaybeDropCall();
|
|
return;
|
|
}
|
|
CallInfo info{uni, call_->Number(), "incoming", "incoming", NowEpoch(), 0,
|
|
"incoming"};
|
|
PostAdded(info);
|
|
Execute(actions);
|
|
MaybeDropCall();
|
|
}
|
|
|
|
// ---- action execution -------------------------------------------------
|
|
// Returns false if a client-flow send failed (used to fail a dial's INVITE).
|
|
// `announce` false suppresses the D-Bus state/deleted events — for a call
|
|
// that was never announced (refused before ringing), so the bus never
|
|
// sees a call it cannot show.
|
|
bool Execute(const std::vector<imsd::engine::Action>& actions, bool announce = true) {
|
|
using T = imsd::engine::Action::Type;
|
|
bool sendOk = true;
|
|
for (const auto& a : actions) {
|
|
switch (a.type) {
|
|
case T::SendClient:
|
|
// the outgoing INVITE is the one request a field log could
|
|
// never show (DUMP_SIP dumped inbound messages only)
|
|
if (a.text.starts_with("INVITE ")) DumpRaw("imsd-invite-out.raw", a.text);
|
|
if (!sip_.Send(a.text)) { sendOk = false; Log("client send failed"); }
|
|
break;
|
|
case T::SendResponse:
|
|
if (callReply_) callReply_(a.text);
|
|
else if (reply_) reply_(a.text);
|
|
break;
|
|
case T::StartMedia: StartMedia(a.media); break;
|
|
case T::StopMedia: StopMedia(); break;
|
|
case T::State:
|
|
if (announce) PostState(call_->Uni(), a.state, a.reason);
|
|
break;
|
|
case T::Deleted:
|
|
if (announce) PostDeleted(call_->Uni());
|
|
dropCall_ = true;
|
|
break;
|
|
case T::SetDeadline: deadline_ = Mono() + a.seconds; break;
|
|
case T::Log: Log(a.text); break;
|
|
}
|
|
}
|
|
return sendOk;
|
|
}
|
|
|
|
void MaybeDropCall() {
|
|
if (!dropCall_) return;
|
|
call_.reset(); callReply_ = nullptr; dropCall_ = false;
|
|
if (closeFlowWhenIdle_) {
|
|
closeFlowWhenIdle_ = false;
|
|
Log("the call is over: closing the flow of the binding the network terminated under it");
|
|
sip_.Close();
|
|
}
|
|
}
|
|
|
|
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={} -> [{}]:{}", 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_});
|
|
std::string outBase = std::format("{}/dialer_{}", outDir_, call_ ? call_->Uni() : std::string("x"));
|
|
std::vector<std::string> argv = {
|
|
mediaBin_, local_, std::to_string(rtpPort_), leg.remoteIp,
|
|
std::to_string(leg.remotePort), std::to_string(leg.payloadType),
|
|
"86400", outBase};
|
|
AudioSession session;
|
|
if (geteuid() == 0) {
|
|
session = ResolveAudioSession();
|
|
Log(AudioSessionLine(session) + (session.name.empty() ? "; this call has no audio" : ""));
|
|
}
|
|
pid_t pid = fork();
|
|
if (pid == 0) {
|
|
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
|
|
setenv("AUDIO_USER", session.name.c_str(), 1);
|
|
}
|
|
setenv("MIC", EnvOr("MIC", "1").c_str(), 1);
|
|
setenv("PLAY", EnvOr("PLAY", "1").c_str(), 1);
|
|
setenv("GAIN", EnvOr("GAIN", "10").c_str(), 1);
|
|
setenv("PLAY_GAIN", EnvOr("PLAY_GAIN", "1.0").c_str(), 1);
|
|
setenv("AMR_MODE", EnvOr("AMR_MODE", "2").c_str(), 1);
|
|
setenv("DTX", EnvOr("DTX", "0").c_str(), 1);
|
|
setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1);
|
|
setenv("CODEC", leg.codec.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);
|
|
}
|
|
mediaPid_ = pid;
|
|
}
|
|
|
|
void StopMedia() {
|
|
if (mediaPid_ <= 0) return;
|
|
kill(mediaPid_, SIGTERM);
|
|
for (int i = 0; i < 100; i++) {
|
|
if (waitpid(mediaPid_, nullptr, WNOHANG) != 0) { mediaPid_ = -1; return; }
|
|
usleep(100000);
|
|
}
|
|
kill(mediaPid_, SIGKILL);
|
|
waitpid(mediaPid_, nullptr, 0);
|
|
mediaPid_ = -1;
|
|
}
|
|
|
|
void ReapMedia() {
|
|
if (!call_ || mediaPid_ <= 0) return;
|
|
if (call_->State() != imsd::engine::CallState::Active) return;
|
|
int status = 0;
|
|
pid_t r = waitpid(mediaPid_, &status, WNOHANG);
|
|
if (r == 0) return; // still running
|
|
int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
|
|
mediaPid_ = -1; // reaped; don't re-terminate
|
|
Execute(call_->OnMediaExit(code));
|
|
MaybeDropCall();
|
|
}
|
|
|
|
// ---- events -----------------------------------------------------------
|
|
void EmitStatus(bool registrationSignal) {
|
|
auto ev = std::make_unique<Event>();
|
|
ev->type = registrationSignal ? Event::Type::Registration : Event::Type::Status;
|
|
ev->status = StatusSnapshot{registered_, local_, pcscf_, ppi_, reg_.expiry, reg_.cseq};
|
|
PostEvent(std::move(ev));
|
|
}
|
|
void PostAdded(const CallInfo& info) {
|
|
auto ev = std::make_unique<Event>(); ev->type = Event::Type::Added; ev->info = info;
|
|
PostEvent(std::move(ev));
|
|
}
|
|
void PostState(const std::string& uni, const std::string& state, const std::string& reason) {
|
|
auto ev = std::make_unique<Event>(); ev->type = Event::Type::State;
|
|
ev->uni = uni; ev->state = state; ev->reason = reason;
|
|
PostEvent(std::move(ev));
|
|
}
|
|
void PostDeleted(const std::string& uni) {
|
|
auto ev = std::make_unique<Event>(); ev->type = Event::Type::Deleted; ev->uni = uni;
|
|
PostEvent(std::move(ev));
|
|
}
|
|
};
|
|
|
|
Engine* TheEngine = nullptr;
|
|
|
|
// ================= GDBus ===================================================
|
|
constexpr const char* IntrospectionXml = R"xml(
|
|
<node>
|
|
<interface name="net.catcrafts.IMS1">
|
|
<method name="Dial">
|
|
<arg type="s" name="number" direction="in"/>
|
|
<arg type="s" name="callUni" direction="out"/>
|
|
</method>
|
|
<method name="HangUp"><arg type="s" name="callUni" direction="in"/></method>
|
|
<method name="Accept"><arg type="s" name="callUni" direction="in"/></method>
|
|
<method name="SendDtmf">
|
|
<arg type="s" name="callUni" direction="in"/>
|
|
<arg type="s" name="tones" direction="in"/>
|
|
</method>
|
|
<method name="GetCalls"><arg type="aa{sv}" name="calls" direction="out"/></method>
|
|
<method name="GetStatus"><arg type="a{sv}" name="status" direction="out"/></method>
|
|
<signal name="CallAdded"><arg type="s" name="callUni"/><arg type="a{sv}" name="info"/></signal>
|
|
<signal name="CallStateChanged">
|
|
<arg type="s" name="callUni"/><arg type="s" name="state"/><arg type="s" name="reason"/>
|
|
</signal>
|
|
<signal name="CallDeleted"><arg type="s" name="callUni"/></signal>
|
|
<signal name="RegistrationChanged"><arg type="b" name="registered"/></signal>
|
|
</interface>
|
|
</node>)xml";
|
|
|
|
void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) {
|
|
std::string_view m = method;
|
|
if (m == "Dial") {
|
|
const gchar* number = nullptr;
|
|
g_variant_get(params, "(&s)", &number);
|
|
std::string uni = TheEngine->Dial(number ? number : "");
|
|
g_dbus_method_invocation_return_value(inv, g_variant_new("(s)", uni.c_str()));
|
|
return;
|
|
}
|
|
if (m == "HangUp") {
|
|
const gchar* uni = nullptr;
|
|
g_variant_get(params, "(&s)", &uni);
|
|
TheEngine->HangUp(uni ? uni : "");
|
|
g_dbus_method_invocation_return_value(inv, nullptr);
|
|
return;
|
|
}
|
|
if (m == "Accept") {
|
|
const gchar* uni = nullptr;
|
|
g_variant_get(params, "(&s)", &uni);
|
|
TheEngine->Accept(uni ? uni : "");
|
|
g_dbus_method_invocation_return_value(inv, nullptr);
|
|
return;
|
|
}
|
|
if (m == "SendDtmf") {
|
|
Log("SendDtmf accepted but not yet sent (TODO RFC4733)");
|
|
g_dbus_method_invocation_return_value(inv, nullptr);
|
|
return;
|
|
}
|
|
if (m == "GetCalls") {
|
|
GVariantBuilder b;
|
|
g_variant_builder_init(&b, G_VARIANT_TYPE("aa{sv}"));
|
|
for (auto& [uni, info] : Calls)
|
|
g_variant_builder_add_value(&b, CallVariant(info));
|
|
g_dbus_method_invocation_return_value(inv, g_variant_new("(aa{sv})", &b));
|
|
return;
|
|
}
|
|
if (m == "GetStatus") {
|
|
GVariantBuilder b;
|
|
g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}"));
|
|
g_variant_builder_add(&b, "{sv}", "registered", g_variant_new_boolean(StatusSnap.registered));
|
|
g_variant_builder_add(&b, "{sv}", "local", g_variant_new_string(StatusSnap.local.c_str()));
|
|
g_variant_builder_add(&b, "{sv}", "pcscf", g_variant_new_string(StatusSnap.pcscf.c_str()));
|
|
g_variant_builder_add(&b, "{sv}", "expiry", g_variant_new_int64(StatusSnap.expiry));
|
|
g_variant_builder_add(&b, "{sv}", "cseq", g_variant_new_int64(StatusSnap.cseq));
|
|
g_variant_builder_add(&b, "{sv}", "ppi", g_variant_new_string(StatusSnap.ppi.c_str()));
|
|
g_dbus_method_invocation_return_value(inv, g_variant_new("(a{sv})", &b));
|
|
return;
|
|
}
|
|
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
|
|
}
|
|
|
|
bool IsRead(std::string_view method) { return method == "GetStatus" || method == "GetCalls"; }
|
|
constexpr char ErrAccessDenied[] = "org.freedesktop.DBus.Error.AccessDenied";
|
|
const GDBusInterfaceVTable Vtable = { imsd::bus::Gated<HandleMethodCall, IsRead, ErrAccessDenied, Log>, nullptr, nullptr, {} };
|
|
|
|
void OnBusAcquired(GDBusConnection* conn, const gchar*, gpointer) {
|
|
DbusConn = conn;
|
|
GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(IntrospectionXml, nullptr);
|
|
g_dbus_connection_register_object(conn, ObjPath, node->interfaces[0], &Vtable, nullptr, nullptr, nullptr);
|
|
g_dbus_node_info_unref(node);
|
|
Log(std::format("object registered at {}", ObjPath));
|
|
}
|
|
|
|
gboolean OnTerm(gpointer loop) {
|
|
Log("SIGTERM — stopping engine (SA kept)");
|
|
if (TheEngine) TheEngine->Stop();
|
|
g_timeout_add_seconds(2, [](gpointer l) -> gboolean {
|
|
g_main_loop_quit(static_cast<GMainLoop*>(l)); return G_SOURCE_REMOVE; }, loop);
|
|
return G_SOURCE_REMOVE;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main(int argc, char** argv) {
|
|
bool session = false;
|
|
for (int i = 1; i < argc; i++)
|
|
if (std::string_view(argv[i]) == "--session") session = true;
|
|
|
|
if (argv[0]) {
|
|
std::string p = argv[0];
|
|
auto slash = p.find_last_of('/');
|
|
if (slash != std::string::npos) SelfDir = p.substr(0, slash);
|
|
}
|
|
|
|
if (!session && geteuid() != 0) {
|
|
std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)");
|
|
return 1;
|
|
}
|
|
|
|
std::string dev = EnvOr("DEV", "qmapmux0.0");
|
|
std::string local = EnvOr("LOCAL", "");
|
|
if (local.empty()) {
|
|
if (auto l = DetectLocal(dev)) local = *l;
|
|
}
|
|
if (local.empty() && !session) {
|
|
std::println(std::cerr, "imsd: no global IPv6 on {} — is the ims PDN up?", dev);
|
|
return 1;
|
|
}
|
|
Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, EnvOr("PCSCF", "(unset)"), EnvOr("PCSCF_PORT", "5060")));
|
|
if (!session) LogAudioSession();
|
|
imsd::bus::AuthorizeCalls = !session;
|
|
|
|
Engine engine;
|
|
engine.SetLocal(local);
|
|
engine.SetDevMode(session);
|
|
TheEngine = &engine;
|
|
|
|
MainLoop = g_main_loop_new(nullptr, FALSE);
|
|
guint owner = g_bus_own_name(
|
|
session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM, BusName,
|
|
G_BUS_NAME_OWNER_FLAGS_NONE, OnBusAcquired,
|
|
[](GDBusConnection*, const gchar* name, gpointer) { Log(std::format("owning {}", name)); },
|
|
[](GDBusConnection*, const gchar* name, gpointer lp) {
|
|
Log(std::format("lost {} — exiting", name));
|
|
g_main_loop_quit(static_cast<GMainLoop*>(lp));
|
|
},
|
|
MainLoop, nullptr);
|
|
|
|
g_unix_signal_add(SIGTERM, OnTerm, MainLoop);
|
|
g_unix_signal_add(SIGINT, OnTerm, MainLoop);
|
|
|
|
// Run the engine on a pthread with an explicit 8 MiB stack: musl's default
|
|
// thread stack is only 128 KiB, and the registration paths hold 64 KiB SIP
|
|
// recv buffers on the stack (a std::thread here segfaults on entry). The
|
|
// registration is bus-independent, so start it immediately.
|
|
pthread_t engineTid{};
|
|
pthread_attr_t attr;
|
|
pthread_attr_init(&attr);
|
|
pthread_attr_setstacksize(&attr, 8 * 1024 * 1024);
|
|
pthread_create(&engineTid, &attr, [](void*) -> void* { TheEngine->Run(); return nullptr; }, nullptr);
|
|
pthread_attr_destroy(&attr);
|
|
|
|
Log(std::format("starting on the {} bus", session ? "session" : "system"));
|
|
g_main_loop_run(MainLoop);
|
|
|
|
engine.Stop();
|
|
pthread_join(engineTid, nullptr);
|
|
g_bus_unown_name(owner);
|
|
g_main_loop_unref(MainLoop);
|
|
return FatalExit ? 1 : 0;
|
|
}
|