imsd: P-CSCF candidates from PCSCF_DISCOVERED when PCSCF= is not set

imsd reads PCSCF_DISCOVERED= — a comma-separated IPv6 list — from its
environment; nothing in this tree writes it yet. FreshPcscfFromEnv picks
the fresh target: PCSCF= when set — one address, it wins — else the first
entry of the list CanonicalIp6 accepts; the journal says which, and names
the entries it refused. imsd routes the P-CSCF it uses itself — the fresh
target before a fresh registration (BringUp, FreshRecover), the resumed SA
peer once its SAs are confirmed — with the same idempotent `ip -6 route
replace` as a call's media peer: the bring-up script routes only the configured
PCSCF=, so a target taken from PCSCF_DISCOVERED= (set by hand in this tree),
or a route lost with the PDN, had no route. The start line prints that target; a resume still follows the
kernel's SA peer and now says whether that peer is in today's list. With
neither variable and nothing to resume, the fatal names both.

Tested: crafter-build test, 8 passed; the configured, discovered, differing (the
discovered list's order flipped: the resume stays on the SA peer, the next
fresh registration's target is the other address) and empty arms in builds
of this series on the dev phone (KPN).
This commit is contained in:
Jorijn van der Graaf 2026-10-11 16:09:45 +02:00
commit bd52afd931
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
3 changed files with 105 additions and 43 deletions

View file

@ -79,8 +79,9 @@ partition with a test. The shell (`main.cpp`; from 1.0.0 also `pcscf.cpp`'s
QRTR control lookup and its matching of answers by type, transaction and
message id) holds protocol decisions no
test reaches — some older than 0.3.10, some added by 0.3.10, 0.3.11, 0.3.12 or 1.0.0: the P-CSCF in
use (`pcscf_`: the kernel's SA peer at a resume, `PCSCF=` at a fresh
registration, the move between them and back — §7.2); the reg-event
use (`pcscf_`: the kernel's SA peer at a resume, `PCSCF=` or the first of
`PCSCF_DISCOVERED=` at a fresh registration, the move between them and back —
§7.2); the reg-event
subscription schedule (§7.4: its retry
ladder, Retry-After clamp, Timer F, renewal point and RFC 6665 reasons); the
registration sequence and its transactions (`AwaitFinal`/`AwaitInitialFinal`:
@ -496,10 +497,14 @@ a 5-minute registration gate, connect retries). `Restart=on-failure`,
The daemon's own bring-up (`BringUp()` in `main.cpp`):
1. `PCSCF=` is where a fresh registration goes; a resume needs none — the
kernel's SAs name its P-CSCF (step 3). With none set, a bring-up that did
not resume fails with `no P-CSCF for a fresh registration: …`, and since
no fresh one follows: a resumable pair whose attempt failed (a silent
1. The P-CSCF a fresh registration goes to is `PCSCF=` when set, else the
first address of `PCSCF_DISCOVERED=` (a comma-separated IPv6 list read
from the environment; nothing in this tree writes it yet, so a bench sets
it by hand); the journal says which (`pcscf: configured …` / `pcscf:
discovered … (using …)`). A resume needs neither: the kernel's SAs name
its P-CSCF (step 3). With neither, a bring-up that did not resume fails
with `no P-CSCF for a fresh registration: …`, and since no fresh one
follows: a resumable pair whose attempt failed (a silent
connect, an unanswered refresh, a first refusal without a Retry-After —
the refusal count goes to the holds) stays for the restart to resume, unless the engine's rule
ends it — a 401, a refusal with a Retry-After or the second refusal
@ -507,8 +512,7 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`):
refusal is the second) give the resume up, a 403 once a fresh registration
is due or a binding a NOTIFY terminated under the attempt write the
registration off (`old_gone`) — so the pair counts as one nothing can
resume; a pair nothing can resume (its UE address left
the link, no state file, a file another attempt left behind, written off before this start or by the engine during the attempt, `RESUME=0`) is taken out of the kernel first (`the SA pair to …
resume; a pair nothing can resume (its UE address left the link, no state file, a file another attempt left behind, written off before this start or by the engine during the attempt, `RESUME=0`) is taken out of the kernel first (`the SA pair to …
is nothing to resume: removing it` — by exact key, what two rules select — the fingerprinted policies and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, §12), so the next bring-up finds nothing to resume; the holds
keep the Retry-After of a refusal and, for a registration the engine wrote
off, the write-off — a removed pair needs none.
@ -550,7 +554,8 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`):
registered fresh at every restart; an upgraded phone with such a setting
stops at bring-up with the spelling to use. A resume follows the kernel's SA peer (`SaPeer`), not
`PCSCF=`: a registration made to another P-CSCF than today's setting is
resumed there (`pcscf: resuming on …`) and moves at the next fresh
resumed there (`pcscf: resuming on …`; imsd routes that peer itself once
`ParseExistingSa` confirms the SAs) and moves at the next fresh
registration (`the fresh registration moves from P-CSCF X to Y`; an
attempt that fails before the SA setup goes back while the old registration
still stands — not written off by its address leaving the link, a NOTIFY
@ -560,12 +565,13 @@ The daemon's own bring-up (`BringUp()` in `main.cpp`):
`spi_uc`/`spi_us` against the pair's (`FileNamesAnotherPair`), a 0.3.x
file's too: an attempt that got through the SA setup and did not live to
record, to this or another P-CSCF — is not used; the
bring-up registers fresh (with no fresh target: the fatal, step 1). Every fresh registration goes to `PCSCF=`; with
none set, a bring-up that cannot resume fails with `no P-CSCF for a fresh
registration: …` and a recovery step that needs one restarts the daemon
through systemd.
4. **Fresh registration** otherwise (`FRESH registration`): an unprotected
REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and
bring-up registers fresh (with no fresh target: the fatal, step 1). Every fresh registration goes to `PCSCF=`, else the first
entry of `PCSCF_DISCOVERED=` that `CanonicalIp6` accepts; with neither, a bring-up that cannot resume fails
with `no P-CSCF for a fresh registration: …` and a recovery step that
needs one restarts the daemon through systemd.
4. **Fresh registration** otherwise (`FRESH registration`): the target's host
route (imsd's own `ip -6 route replace`; step 3 says which target), an
unprotected REGISTER on `PCSCF_PORT` (UDP, always), the 401 with the AKA challenge and
the `Security-Server` list, the AKA run over a UIM logical channel
(`--uim-open-logical-channel`, `--uim-send-apdu`), the SA pair installed
with `ip xfrm`, the protected REGISTER over the negotiated transport
@ -674,9 +680,9 @@ configures the muxed netdev. Idempotent; a non-zero exit fails the unit.
uncompressed one by the daemon with the spelling to use once the route
step took it); anything else on the IMS
network (its DNS servers, a traceroute target) needs its own host route
(`ip -6 route add <addr> dev qmapmux0.0`). imsd installs a host route per
media peer itself, and one to the SA peer of a registration it resumes
when that differs from the fresh target (§7.2).
(`ip -6 route add <addr> dev qmapmux0.0`). imsd installs a host route
per media peer itself, and one to the P-CSCF it registers to or resumes on
(§7.2 step 3).
- Opens imsd's protected ports 45061–45062 (TCP and UDP) on `qmapmux*` in
nftables when a `inet filter` table exists (pmOS's default INPUT chain drops
inbound on `qmapmux*`, which silently killed every network-initiated
@ -930,16 +936,20 @@ Lines the operator (and the scripts) look for, all prefixed `imsd: `:
until the next fresh attempt`, `X has left DEV: the registration on it is
gone; staying on Y; next: …`, `X is no longer on DEV: the registration on
it is gone; on the PDN's own address Y now; next: …`.
- `pcscf: resuming on X, the registration's SA peer; the configured P-CSCF
is Y — the next fresh registration moves there` (or `… (no P-CSCF
configured)`), `the fresh registration moves from P-CSCF X to Y`, `back to
P-CSCF X: the old registration stands until the next fresh attempt`,
`no reg state file; resume falls back to fresh` and `the state file names
- `pcscf: configured X` / `pcscf: configured X; discovery gave Y, Z (ignored:
PCSCF= wins)` / `pcscf: discovered Y, Z (using Y)` at start, `pcscf:
PCSCF_DISCOVERED entry '…' is not an IPv6 address; ignored`; `pcscf:
resuming on X, the registration's SA peer; the next fresh registration
goes to Y` (+ ` (the peer is not in today's list)`, or `… (no P-CSCF
configured or discovered)`), `the fresh registration moves from P-CSCF X
to Y`, `back to P-CSCF X: the old registration stands until the next fresh
attempt`, `no reg state file; resume falls back to fresh` and `the state file names
another SA pair (0x…/0x…[ to X]) than the kernel's (0x…/0x… to Y): another
attempt's; resume falls back to fresh` (` to X` only when the file records a
P-CSCF — a pre-1.0.0 file does not; with no fresh target both end in
`nothing can resume it: the pair is removed` instead), `no P-CSCF for a fresh
registration: none configured (PCSCF=)`.
`nothing can resume it: the pair is removed` instead), `no P-CSCF for a
fresh registration: none configured (PCSCF=) and none discovered
(PCSCF_DISCOVERED empty)`.
- `PCSCF='…' is not written as ip prints it; use PCSCF=…` (under `bring-up
FAILED:`) — one accepted spelling of the setting (§7.2 step 3); the
bring-up script's `ims-pdn-up: PCSCF '…': write the bare address, as ip
@ -1184,7 +1194,19 @@ minimum `/etc/imsd.env` are in the README.
The README's first table. The protected client/server ports are fixed at
45061/45062 (`imsd::util::PortUc`/`PortUs`) — changing them in source also
requires updating the firewall rules `ims-pdn-up.sh` installs.
requires updating the firewall rules `ims-pdn-up.sh` installs. `PCSCF` and
`PCSCF_DISCOVERED` are read at start (`FreshPcscfFromEnv`, for the start line
and again by the engine): the first is one address in RFC 5952 form (§7.2 step 3: another spelling is
refused at bring-up), and wins, the second a comma-separated IPv6 list whose
first entry `CanonicalIp6` accepts is the fresh target otherwise (an IPv4
address, a name, a literal with a zone or a dotted-quad tail is logged and
skipped). A resume goes to the kernel's SA
peer whatever they say (and routes it itself); beyond the resume's `pcscf:`
line, having a fresh target decides what a refused or failed resume
leads to — the fresh registration (a refusal without a Retry-After is not
counted) or, with none, the pair kept for the restart or removed (§7.2
step 1) — and a comma-separated `PCSCF=` stops the bring-up before the
resume.
### 10.2 imsd-media (per call, set through imsd's environment)
@ -1272,9 +1294,8 @@ only the imsd side of its bus choice.
(`DUMP_SIP=0`), and never leave the phone in a commit.
- The ims PDN is a walled garden: `ims-pdn-up.sh` keeps it from becoming
the default route and gives the P-CSCF a host route; imsd adds one per
call for the media peer (§7.3) and, at a resume, one for the registration's
SA peer when that differs from the fresh target (§7.2: the script routed
only the configured address). Nothing else is routed over it.
call for the media peer (§7.3) and one for the P-CSCF it registers to or resumes on (§7.2 step 3; the
script routes only the configured address). Nothing else is routed over it.
A change that routes anything else over it needs a reason and a bench.
- The fixed protected ports (45061/45062) are opened in nftables for
`qmapmux*` only, by the bring-up script.

View file

@ -98,7 +98,8 @@ session and root may place, answer and end calls; an ssh login may not.
| Variable | Default | Meaning |
|---|---|---|
| `PCSCF` | *(none)* | P-CSCF address (IPv6), exactly one — not a comma-separated list — in RFC 5952 §4 form (lowercase, leading zeros dropped, the longest zero run compressed, no brackets — what `ip` prints for a plain IPv6 address; an IPv4-mapped address only as hex, `::ffff:a00:1` — the dotted spelling RFC 5952 §5 and `ip` use for it is refused); upper case, leading zeros, an uncompressed run or brackets are refused at start with the spelling to use (in the packaged unit the bring-up script refuses brackets first, with the rule), a dotted-quad tail with the rule; before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` matched no SA in `ip xfrm`'s text and registered fresh at every restart — an upgraded phone with such a setting stops at start with the spelling to use. It is where a fresh registration goes; a registration resumed from the kernel's SAs stays on the P-CSCF it was made to until its next fresh registration. Without it a fresh registration fails with a clear error |
| `PCSCF` | *(the first of `PCSCF_DISCOVERED`)* | P-CSCF address (IPv6), exactly one — not a comma-separated list — in RFC 5952 §4 form (lowercase, leading zeros dropped, the longest zero run compressed, no brackets — what `ip` prints for a plain IPv6 address; an IPv4-mapped address only as hex, `::ffff:a00:1` — the dotted spelling RFC 5952 §5 and `ip` use for it is refused); upper case, leading zeros, an uncompressed run or brackets are refused at start with the spelling to use (in the packaged unit the bring-up script refuses brackets first, with the rule), a dotted-quad tail with the rule; before 1.0.0 an upper-case, zero-padded or uncompressed `PCSCF=` matched no SA in `ip xfrm`'s text and registered fresh at every restart — an upgraded phone with such a setting stops at start with the spelling to use. Set, it wins over `PCSCF_DISCOVERED`. It is where a fresh registration goes; a registration resumed from the kernel's SAs stays on the P-CSCF it was made to until its next fresh registration. With neither this nor `PCSCF_DISCOVERED`, a fresh registration fails with a clear error |
| `PCSCF_DISCOVERED` | *(empty)* | a comma-separated IPv6 P-CSCF list, the first of which is used when `PCSCF` is unset; entries `CanonicalIp6` does not read — an IPv4 address, a name, an IPv6 literal with a zone or a dotted-quad tail (an IPv4-mapped entry in hex only) — are logged and ignored. imsd reads it from its environment; nothing in this tree writes it yet (a bench sets it in `/etc/imsd.env`) |
| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER |
| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
| `LOCAL` | *(auto)* | UE address (compared in RFC 5952 form). The packaged service gets the PDN address ModemManager assigned, via `/run/imsd.env` from `ims-pdn-up.sh`; a manual run prefers a global IPv6 on `DEV` that is neither temporary nor `dynamic` (RA-derived), then any non-temporary one, then the first (the kernel's temporary SLAAC address is listed first, and a registration bound to it is lost when the kernel retires it) |

View file

@ -295,6 +295,20 @@ void LogAudioSession() {
Log(AudioSessionLine(s) + (s.name.empty() ? "; looked up again at each call, set AUDIO_USER to pin one" : ""));
}
// The P-CSCF a fresh registration goes to, from the environment: PCSCF=
// when set (one address, any spelling — a list is left as it came and
// refused at bring-up), else the first address of PCSCF_DISCOVERED=, the
// list the bring-up read from the PDN's PCO (comma-separated IPv6, PCO
// order). `discovered` and `rejected` get the list and what was not an
// IPv6 literal, for the caller's log lines.
std::string FreshPcscfFromEnv(std::vector<std::string>* discovered = nullptr, std::vector<std::string>* rejected = nullptr) {
std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
std::vector<std::string> list = imsd::util::ParseAddressList(EnvOr("PCSCF_DISCOVERED", ""), rejected);
if (discovered) *discovered = list;
if (!configured.empty()) return configured;
return list.empty() ? std::string() : list.front();
}
// ---- sockaddr helper ------------------------------------------------------
socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) {
std::memset(&ss, 0, sizeof ss);
@ -942,11 +956,25 @@ void PostEvent(std::unique_ptr<Event> ev) {
class Engine {
public:
Engine() {
// The P-CSCF a fresh registration goes to: PCSCF= (no baked-in
// default — carrier infrastructure). A resume follows the kernel's
// SAs instead (TryResume), whatever this says today.
pcscfTarget_ = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
pcscf_ = pcscfTarget_;
// The P-CSCF a fresh registration goes to: PCSCF= when set, else the
// first address the bring-up read from the PDN's PCO
// (PCSCF_DISCOVERED=); no baked-in default — carrier infrastructure.
// A resume follows the kernel's SAs instead (TryResume), whatever
// this says today.
{
std::vector<std::string> rejected;
pcscfTarget_ = FreshPcscfFromEnv(&pcscfCandidates_, &rejected);
pcscf_ = pcscfTarget_;
std::string configured = imsd::util::CanonicalAddr(EnvOr("PCSCF", ""));
std::string list;
for (const std::string& c : pcscfCandidates_) list += (list.empty() ? "" : ", ") + c;
if (!configured.empty())
Log(pcscfCandidates_.empty() ? std::format("pcscf: configured {}", configured)
: std::format("pcscf: configured {}; discovery gave {} (ignored: PCSCF= wins)", configured, list));
else if (!pcscfCandidates_.empty())
Log(std::format("pcscf: discovered {} (using {})", list, pcscfTarget_));
for (const std::string& r : rejected) Log(std::format("pcscf: PCSCF_DISCOVERED entry '{}' is not an IPv6 address; ignored", r));
}
pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str());
dev_ = EnvOr("DEV", "qmapmux0.0");
outDir_ = EnvOr("OUT_DIR", StateDir());
@ -1156,13 +1184,15 @@ private:
// config
std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_;
// The P-CSCF a fresh registration goes to (PCSCF=). pcscf_ is the one in
// The P-CSCF a fresh registration goes to (PCSCF=, else the first of
// PCSCF_DISCOVERED=) and the whole discovered list. pcscf_ is the one in
// use: the same, or the kernel's SA peer while a resumed registration
// made to another one lives (TryResume) — the next fresh registration
// moves to the target, and back when it fails before the SA setup
// (FreshRecover), as local_ does against pdnLocal_.
std::string pcscfTarget_;
static constexpr std::string_view NoFreshPcscf = "no P-CSCF for a fresh registration: none configured (PCSCF=)";
std::vector<std::string> pcscfCandidates_;
static constexpr std::string_view NoFreshPcscf = "no P-CSCF for a fresh registration: none configured (PCSCF=) and none discovered (PCSCF_DISCOVERED empty)";
// 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).
@ -1352,6 +1382,11 @@ private:
local_ = pdnLocal_; ctx_.local = local_;
KeepHolds();
pcscf_ = pcscfTarget_;
// the target's host route is imsd's own: the bring-up script routes only
// the configured address (§7.2 step 3), not a target set in /etc/imsd.env
// as PCSCF_DISCOVERED= nor a route lost with the PDN — the same idempotent
// replace as a media peer's
RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_});
if (recovery_.Next(Boot()).at > Boot()) Log(std::format("the network's hold stands; {}", NextStep()));
if (!WaitForPolicy()) throw Stopping{};
FreshRegister();
@ -1373,13 +1408,12 @@ private:
// the replace covers a target that changed under a running phone.
auto peer = imsd::ipsec::SaPeer(policy.out);
if (!peer) return ResumeOutcome::None;
bool routePeer = false;
if (*peer != pcscf_) {
bool listed = *peer == pcscfTarget_ || std::ranges::find(pcscfCandidates_, *peer) != pcscfCandidates_.end();
Log(pcscfTarget_.empty()
? std::format("pcscf: resuming on {}, the registration's SA peer (no P-CSCF configured)", *peer)
: std::format("pcscf: resuming on {}, the registration's SA peer; the configured P-CSCF is {} — the next fresh registration moves there", *peer, pcscfTarget_));
? std::format("pcscf: resuming on {}, the registration's SA peer (no P-CSCF configured or discovered)", *peer)
: std::format("pcscf: resuming on {}, the registration's SA peer; the next fresh registration goes to {}{}", *peer, pcscfTarget_, listed ? "" : " (the peer is not in today's list)"));
pcscf_ = *peer;
routePeer = true; // routed below, once the SAs are confirmed: a peer named by a pair that is not ours gets no route into the PDN
}
auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_);
// The registration may sit on another address of the link: before
@ -1403,7 +1437,10 @@ private:
}
}
if (!sa) { Log(std::format("the SA pair to {} has no SA on an address of {}: nothing to resume", pcscf_, dev_)); return ResumeOutcome::Dead; }
if (routePeer) RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_});
// the peer's host route is imsd's own once the SAs are confirmed (a peer
// named by a pair that is not ours gets no route into the PDN), whether
// or not the bring-up script routed the same address — idempotent
RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_});
auto ps = LoadState();
if (!ps || !ps->callid) { Log(std::format("no reg state file; {}", ResumeDeadNext())); return ResumeOutcome::Dead; }
// A file another attempt left behind — one that got through the SA
@ -2046,6 +2083,8 @@ private:
Log(std::format("the fresh registration moves from {} to the PDN's own address {}", local_, pdnLocal_));
MoveTo(pdnLocal_);
}
// the fresh target's host route, imsd's own (BringUp's fresh path does the same)
RunCmd({"ip", Is6(pcscf_) ? "-6" : "-4", "route", "replace", pcscf_, "dev", dev_});
bool ok = false;
try {
RefreshPani();
@ -2680,7 +2719,8 @@ int main(int argc, char** argv) {
return 1;
}
local = imsd::util::CanonicalAddr(local);
Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, imsd::util::CanonicalAddr(EnvOr("PCSCF", "(unset)")), EnvOr("PCSCF_PORT", "5060")));
std::string freshPcscf = FreshPcscfFromEnv();
Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, freshPcscf.empty() ? "(none)" : freshPcscf, EnvOr("PCSCF_PORT", "5060")));
if (!session) LogAudioSession();
imsd::bus::AuthorizeCalls = !session;