ipsec, imsd: the SA setup deletes by exact key instead of flushing the host's xfrm

A fresh registration's SA setup began with `ip xfrm state flush` and `ip
xfrm policy flush`: every SA userspace had installed and every SPD policy
went, a policy-based VPN's (strongSwan's, libreswan's) SAs and policies
included. The setup now takes out what two rules select and leaves the
rest; and the SA reader, which took the P-CSCF ports for a resume from any
reqid-1/2 `dir out` policy — correct only while the flush left imsd's pair
alone in the kernel — takes them from the policies between our two
addresses only. Whether imsd runs beside a live strongSwan or libreswan
connection is unmeasured: the dev phone shows a tunnel policy and a tunnel
state left in place (Tested below), no more.

OwnXfrmDeleteCommands (Imsd:Ipsec) builds the deletes from the kernel text
the shell reads: every transport-mode policy with reqid 1 or 2 whose
selector carries imsd's protected port on the UE side, by the selector ip
xfrm policy prints (src, dst, proto, sport, dport, dev) and its direction,
and every transport-mode reqid-1/2 ESP state running from such a policy's
source to its destination or anchored on the UE's ims-PDN address, by src,
dst, proto esp and SPI — policy or not: a setup whose policy add failed
leaves such states (the setup logs a failed add and goes on), and an ESP
state another transport-mode user put on that address with reqid 1 or 2 goes
with them (an AH or IPComp state is not selected: the rule asks for ESP).
Every reader of the policy text — the deletes and the SA
reader's port pass here — goes through one predicate (detail::OwnPolicy),
written for whatever reads that text later.
BuildSetupCommands loses its two flush lines and the shell runs the deletes
right before it (RunOwnXfrmDeletes); BuildTeardownCommands, the same two
flushes with no caller, goes. A policy-based VPN's tunnel entries and
another transport-mode user's pair on other ports and addresses stay.
Residual: a transport-mode policy with reqid 1 and the UE-side port 45061,
or reqid 2 and 45062 — alone or paired, between any addresses — carries
imsd's fingerprint and goes, with the reqid-1/2 transport-mode ESP states
in its direction, and a reqid-1/2 transport-mode ESP state on the UE's
ims-PDN address goes whoever made it (tests/Ipsec pins both, and that an AH state
stays).

Tested: crafter-build test, 7 passed (the eight deletes byte-exact on the
fixture in the registered FP6's shape (synthetic addresses and keys); a VPN's tunnel SA and policies beside the pair
untouched; another transport-mode user's pair on other ports and addresses
untouched, its reqid-1 ESP state on the UE's address deleted whoever made
it, its AH state left; a policy-less state on the UE's address deleted, one
between other addresses left; the reader's ports from our pair beside
a foreign transport policy with ports); on the dev phone (KPN), in builds of
this series, a fresh registration with a VPN's tunnel policy and state in
the kernel: both still there afterwards, imsd's previous pair gone.
This commit is contained in:
Jorijn van der Graaf 2026-10-13 22:28:02 +02:00
commit 21cb0f870d
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
4 changed files with 198 additions and 25 deletions

View file

@ -39,7 +39,7 @@ PipeWire. The verified device/carrier combinations are in the README.
| `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. | | `interfaces/Imsd-Sip.cppm` | `imsd::sip` — SIP as a text layer plus TCP framing: header extraction, status codes, the granted lifetime, `ResponseOf` transaction matching. Not a full RFC 3261 parser, deliberately. |
| `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. | | `interfaces/Imsd-Sdp.cppm` | `imsd::sdp` — SDP offer/answer: the AMR-WB + AMR + telephone-event offer (optional IR.92 preconditions), the parser the media plane needs, `BuildAnswer` for the terminating side, the `CODECS` override. |
| `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. | | `interfaces/Imsd-Aka.cppm` | `imsd::aka` — USIM AKA glue: the AKAv1-MD5 digest (RFC 3310), IMPI/IMPU/realm from IMSI + MCC/MNC, parsers for `qmicli --uim-get-card-status` and `mmcli` output, the AUTHENTICATE APDU and its response. |
| `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume. | | `interfaces/Imsd-Ipsec.cppm` | `imsd::ipsec` — the two ESP SA pairs (TS 33.203) as `ip xfrm` argv lists, the Security-Server mechanism selection, and the reader that reconstructs SPIs/ports from `ip xfrm state`/`policy` text for a warm resume, and (1.0.0) `OwnXfrmDeleteCommands`, the deletes by exact key of what two rules select — the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP states in their direction or on the UE's ims-PDN address, whoever installed them (§12). |
| `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. | | `interfaces/Imsd-Messages.cppm` | `imsd::msg` — every SIP request and response imsd sends, built from an explicit `Context` and per-request variables so the builders are pure functions; byte-pinned by `tests/Messages`. Also `IsEmergency`. |
| `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). | | `interfaces/Imsd-Engine.cppm` | `imsd::engine` — the decisions as pure reducers: `CallMachine` (one call's dialog, returning `Action`s for the shell) and `RegRecovery` (the registration recovery policy, §7.4). |
| `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. | | `interfaces/ImsdBus.cppm` | `imsd::bus` — the polkit gate in front of call control, shared by imsd and imsd-ofonod. GLib, so outside `imsd-core`. |
@ -95,7 +95,11 @@ refusal's Retry-After fed as a hold, the old registration written off after
any failed resume), the `OnLink` read that writes the old registration off any failed resume), the `OnLink` read that writes the old registration off
when its address has left the netdev (`FreshRecover` after a failed move, when its address has left the netdev (`FreshRecover` after a failed move,
`AddressGone`), and `PacketServiceUp`'s read of `AddressGone`), and `PacketServiceUp`'s read of
ModemManager before a silent cycle is counted; the shell's reaction to a NOTIFY ModemManager before a silent cycle is counted;
and the xfrm deletes the SA setup runs — `OwnXfrmDeleteCommands` builds them
from the kernel text, with a test; the shell reads that text and runs them
(§12);
the shell's reaction to a NOTIFY
terminating our binding (the flow closed at once, or kept under a call and terminating our binding (the flow closed at once, or kept under a call and
closed when the call ends, a reconnect cycle's or the bring-up resume's wait closed when the call ends, a reconnect cycle's or the bring-up resume's wait
ended by it — the reducer's verdict is pinned, the reaction is not); and the ended by it — the reducer's verdict is pinned, the reaction is not); and the
@ -223,7 +227,13 @@ What the suites pin:
- `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli` - `Aka` — the AKAv1-MD5 digest, identity derivation, the `qmicli`/`mmcli`
text parsers, the AUTHENTICATE APDU and its response parser. text parsers, the AUTHENTICATE APDU and its response parser.
- `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the - `Ipsec` — the exact `ip xfrm` argv lists for the SA pairs, the
Security-Server mechanism selection, the SA reader. Security-Server mechanism selection, the SA reader, and
`OwnXfrmDeleteCommands` (our pair's eight deletes byte-exact on
the fixture in the registered FP6's shape (synthetic addresses and keys), a VPN's tunnel SA and policies beside it
untouched, another transport-mode user's pair on other ports and addresses
untouched — its reqid-1 ESP state on the UE's address deleted, its AH state
left —, a policy-less state on the UE's address deleted, one between
other addresses left).
- `Messages` — every outgoing SIP message byte-for-byte, cross-generated from - `Messages` — every outgoing SIP message byte-for-byte, cross-generated from
the Python reference stack imsd replaced. the Python reference stack imsd replaced.
- `Sip` — the text layer's edge cases (empty header values, line anchoring, - `Sip` — the text layer's edge cases (empty header values, line anchoring,
@ -328,7 +338,14 @@ Rules:
socket (a silent call) — a pin naming an account with one ran, 2026-10-04. socket (a silent call) — a pin naming an account with one ran, 2026-10-04.
`DetectLocal`'s three-tier address ranking for a run without `LOCAL` `DetectLocal`'s three-tier address ranking for a run without `LOCAL`
(§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the (§10.1) ran through a fake `ip` (2026-10-05: the phone's listing of the
netdev and two cut-down ones). netdev and two cut-down ones). The 1.0.0 arms no bench has reached: the SA
setup's deletes with another transport-mode user's pair in the kernel
(`tests/Ipsec` only — with a VPN's tunnel policy and tunnel state there,
left in place through a fresh registration, they ran on the dev phone,
2026-10-12); `RunOwnXfrmDeletes`' line for a delete that failed (`… -> rc
N`: an entry gone between the read and the delete); the holds file's `pdn`
compare in canonical form (no capture stages a holds file whose `pdn` is
spelled unlike `LOCAL`).
- `imsd-media --selftest` round-trips the RFC 4867 payload packing (both - `imsd-media --selftest` round-trips the RFC 4867 payload packing (both
formats, both AMR codecs) and the G.711 tables. `MIC_SRC=<file>` (raw s16 formats, both AMR codecs) and the G.711 tables. `MIC_SRC=<file>` (raw s16
PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams PCM at the codec rate, paced in real time) and `PCM_DUMP=1` are test seams
@ -1084,6 +1101,22 @@ only the imsd side of its bus choice.
frozen ABI; the writes are polkit-gated (§7.1, §8.1). Do not add an frozen ABI; the writes are polkit-gated (§7.1, §8.1). Do not add an
ungated write member, and do not add a root translation daemon that ungated write member, and do not add a root translation daemon that
forwards calls without gating its own callers through `imsd-bus`. forwards calls without gating its own callers through `imsd-bus`.
- imsd deletes by exact key what two rules select, whoever installed it. A
fresh registration's SA setup deletes — `ip xfrm state delete src dst proto
esp spi`, `ip xfrm policy delete <selector> dir` — what two rules select
(`OwnXfrmDeleteCommands`): a transport-mode policy with reqid 1 or 2 whose
selector carries the protected port 45061/45062 on the UE side (imsd's
fingerprint), and a transport-mode reqid-1/2 ESP state running from such a
policy's source to its destination or anchored on the UE's ims-PDN address
(a state whose policy add failed goes too; an AH or IPComp state is not
selected). Never `ip xfrm state|policy flush`: a policy-based VPN's
(strongSwan's, libreswan's) SAs and policies, or another transport-mode
user's pair on other ports and addresses, stay. Before 1.0.0 the setup
flushed the host's whole xfrm state and policy. Residual: a transport-mode
policy with reqid 1 and the UE-side port 45061, or reqid 2 and 45062 — alone
or paired, between any addresses — goes, with the reqid-1/2 transport-mode
ESP states in its direction, and a reqid-1/2 transport-mode ESP state on the
UE's ims-PDN address goes whoever made it (`tests/Ipsec` pins both).
- The bus policies deliberately let any local account *reach* both names; - The bus policies deliberately let any local account *reach* both names;
authorization is polkit's, not the policy file's. Keep it that way: a authorization is polkit's, not the policy file's. Keep it that way: a
policy that names an account breaks the moment the account is called policy that names an account breaks the moment the account is called

View file

@ -114,6 +114,18 @@ std::string RedactKeys(const std::vector<std::string>& argv) {
return s; return s;
} }
// imsd's own xfrm entries out of the kernel, each by its key (the
// OwnXfrmDeleteCommands list — never `ip xfrm … flush`: another IPsec user's
// entries, a policy-based VPN's, stay, §12); a delete that fails (an entry
// gone between the read and the delete) is logged and the rest go on. At
// file scope, as LoadHolds and PersistHolds are.
void RunOwnXfrmDeletes(const std::vector<std::vector<std::string>>& cmds) {
for (auto& cmd : cmds) {
int rc = RunCmd(cmd);
if (rc != 0) Log(std::format("{} -> rc {}", RedactKeys(cmd), rc));
}
}
// ---- small text extractors (401 header params, qmicli "completed:") ------- // ---- small text extractors (401 header params, qmicli "completed:") -------
std::optional<std::string> QuotedAfter(std::string_view text, std::string_view key) { std::optional<std::string> QuotedAfter(std::string_view text, std::string_view key) {
@ -1471,6 +1483,13 @@ private:
sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc; sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc;
sp.alg = alg; sp.alg = alg;
sp.ealg = ealg; sp.ealg = ealg;
// imsd's previous pair out first, each entry by its key — not `ip xfrm …
// flush`: another IPsec user's entries (a policy-based VPN's) stay (§12)
{
auto state = RunCapture({"ip", "xfrm", "state"});
auto policy = RunCapture({"ip", "xfrm", "policy"});
RunOwnXfrmDeletes(imsd::ipsec::OwnXfrmDeleteCommands(state.out, policy.out, local_));
}
saReplaced_ = true; // from here on the old registration's SAs are gone saReplaced_ = true; // from here on the old registration's SAs are gone
for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) { for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) {
int rc = RunCmd(cmd); int rc = RunCmd(cmd);
@ -1501,7 +1520,7 @@ private:
if (autoTcp) { if (autoTcp) {
// Two unanswered TCP connects: treat the P-CSCF as UDP-only // Two unanswered TCP connects: treat the P-CSCF as UDP-only
// and register again over UDP — a new challenge and a new SA // and register again over UDP — a new challenge and a new SA
// pair (the SA setup flushes this one), same ports. // pair (the SA setup takes this one out), same ports.
Log("protected TCP connect unanswered; registering fresh over UDP"); Log("protected TCP connect unanswered; registering fresh over UDP");
SetTransport(Transport::Udp); SetTransport(Transport::Udp);
FreshRegisterAttempt(); FreshRegisterAttempt();
@ -1873,7 +1892,7 @@ private:
} }
// The rest of what a restart does, in place: a new Call-ID, a new // The rest of what a restart does, in place: a new Call-ID, a new
// challenge, a new SA pair (FreshRegister flushes the old ones), then // challenge, a new SA pair (FreshRegister takes the old ones out), then
// the post-registration steps. A refused or throttled attempt waits on // the post-registration steps. A refused or throttled attempt waits on
// the throttle schedule (a wait no Dial cuts short — on KPN a refused // the throttle schedule (a wait no Dial cuts short — on KPN a refused
// attempt costs the old registration) and a silent one on the // attempt costs the old registration) and a silent one on the

View file

@ -19,7 +19,11 @@ SPIs in use, which a reregistration's new SPIs must differ from (and the
state file falls back to) — and a one-mechanism Security-Server value that state file falls back to) — and a one-mechanism Security-Server value that
stands in for the Security-Verify when the state file holds no list for the stands in for the Security-Verify when the state file holds no list for the
SA pair in the kernel (`ChooseResumeSecurityVerify` decides; since 0.3.12 SA pair in the kernel (`ChooseResumeSecurityVerify` decides; since 0.3.12
the 401's own list is stored there). the 401's own list is stored there). Since 1.0.0 also
`OwnXfrmDeleteCommands`: the deletes by exact key of what two rules select —
the policies carrying imsd's fingerprint and the reqid-1/2 transport-mode ESP
states in their direction or on the UE's ims-PDN address, whoever installed
them (§12).
Pure std C++ (the shell runs the commands and captures the text). Command Pure std C++ (the shell runs the commands and captures the text). Command
shapes and the parser are pinned by tests/Ipsec. shapes and the parser are pinned by tests/Ipsec.
@ -114,9 +118,11 @@ export namespace imsd::ipsec {
} }
} }
// The full `ip xfrm` argv sequence to install a fresh SA pair: flush // The full `ip xfrm` argv sequence to install a fresh SA pair: the four
// state+policy, add the four ESP states, add the four transport policies. // ESP states, then the four transport policies — no flush: the caller
// Order matches imscall.setup_sa exactly. Callers validate p.alg with // takes imsd's previous entries out first (OwnXfrmDeleteCommands), so
// another IPsec user's stay. Order otherwise matches imscall.setup_sa.
// Callers validate p.alg with
// KernelAuth() first; an unknown alg goes to the kernel by its own name // KernelAuth() first; an unknown alg goes to the kernel by its own name
// and is rejected there, never mapped to a default. // and is rejected there, never mapped to a default.
std::vector<std::vector<std::string>> BuildSetupCommands(const SaParams& p) { std::vector<std::vector<std::string>> BuildSetupCommands(const SaParams& p) {
@ -127,8 +133,6 @@ export namespace imsd::ipsec {
std::vector<std::string> enc = detail::EncArgs(p); std::vector<std::string> enc = detail::EncArgs(p);
int plen = imsd::util::Is6(local) ? 128 : 32; int plen = imsd::util::Is6(local) ? 128 : 32;
std::vector<std::vector<std::string>> cmds; std::vector<std::vector<std::string>> cmds;
cmds.push_back({"ip", "xfrm", "state", "flush"});
cmds.push_back({"ip", "xfrm", "policy", "flush"});
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, auth, ikx, enc)); cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, auth, ikx, enc));
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, auth, ikx, enc)); cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, auth, ikx, enc));
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, auth, ikx, enc)); cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, auth, ikx, enc));
@ -140,10 +144,6 @@ export namespace imsd::ipsec {
return cmds; return cmds;
} }
std::vector<std::vector<std::string>> BuildTeardownCommands() {
return {{"ip", "xfrm", "state", "flush"}, {"ip", "xfrm", "policy", "flush"}};
}
// ---- Security-Server (RFC 3329 §2.2) ----------------------------------- // ---- Security-Server (RFC 3329 §2.2) -----------------------------------
// //
// The P-CSCF answers the challenge with the mechanisms it supports, each // The P-CSCF answers the challenge with the mechanisms it supports, each
@ -367,6 +367,21 @@ export namespace imsd::ipsec {
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt; if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
return v; return v;
} }
// A policy block of imsd's own: transport mode, reqid 1 or 2, and the
// fixed protected port on the UE side — sport PortUc/PortUs on `dir
// out`, dport on `dir in` (BuildSetupCommands' PolicyAdd shape). Reqid
// and mode alone are anyone's (L2TP/IPsec, a host-to-host SA); the
// ports are the fingerprint. The one predicate every reader of the
// kernel's policy text uses.
inline bool OwnPolicy(std::string_view b) {
auto reqid = ParseDec(TokenAfter(b, "reqid "));
if (!reqid || (*reqid != 1 && *reqid != 2) || b.find("mode transport") == std::string_view::npos) return false;
const int port = *reqid == 1 ? imsd::util::PortUc : imsd::util::PortUs;
if (b.find("dir out") != std::string_view::npos) return ParseDec(TokenAfter(b, "sport ")) == port;
if (b.find("dir in") != std::string_view::npos) return ParseDec(TokenAfter(b, "dport ")) == port;
return false;
}
} }
// Reconstruct SA facts from `ip xfrm state` + `ip xfrm policy` text. // Reconstruct SA facts from `ip xfrm state` + `ip xfrm policy` text.
@ -398,7 +413,14 @@ export namespace imsd::ipsec {
if (src == p && dst == l && *reqid == 2) sa.spiUs = *spi; if (src == p && dst == l && *reqid == 2) sa.spiUs = *spi;
} }
for (std::string_view b : detail::SrcBlocks(policyText)) { for (std::string_view b : detail::SrcBlocks(policyText)) {
if (b.find("dir out") == std::string_view::npos) continue; if (b.find("dir out") == std::string_view::npos || !detail::OwnPolicy(b)) continue;
// the ports come from our own pair — a policy of imsd's own
// (detail::OwnPolicy) between our two addresses — never from a
// foreign policy that happens to carry ports
std::string_view srcTok = detail::TokenAfter(b, "src ");
std::string_view dstTok = detail::TokenAfter(b, "dst ");
if (imsd::util::CanonicalAddr(srcTok.substr(0, srcTok.find('/'))) != l) continue;
if (imsd::util::CanonicalAddr(dstTok.substr(0, dstTok.find('/'))) != p) continue;
auto dport = detail::ParseDec(detail::TokenAfter(b, "dport ")); auto dport = detail::ParseDec(detail::TokenAfter(b, "dport "));
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid ")); auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
if (!dport || !reqid) continue; if (!dport || !reqid) continue;
@ -452,4 +474,57 @@ export namespace imsd::ipsec {
} }
return locals; return locals;
} }
// The `ip xfrm … delete` argv lists that take the entries two rules select
// out of the kernel as the two texts show it: every policy
// of imsd's own (detail::OwnPolicy — transport mode, reqid 1 or 2, the
// fixed protected port on the UE side: the ports BuildSetupCommands
// installs), by the selector `ip xfrm policy` prints (src, dst, proto,
// sport, dport, dev) and its direction; and every transport-mode ESP state
// with reqid 1 or 2 running from such a policy's source to its destination,
// or anchored on `local`, the UE's ims-PDN address, by src, dst and SPI —
// policy or not: a setup whose policy add failed leaves such states, and
// an ESP one another transport-mode user put on that address with reqid 1
// or 2 goes with them (§12; an AH or IPComp state is not selected).
// Everything else — a policy-based
// VPN's tunnel entries, another transport-mode user's on other ports and
// addresses — stays: the fresh registration's SA setup runs these before
// BuildSetupCommands' adds, and flushes nothing (§12). Residual: a
// transport-mode policy with reqid 1 and the UE-side port 45061, or reqid
// 2 and 45062, alone or paired, carries the fingerprint and goes with the
// reqid-1/2 transport-mode ESP states in its direction, and a reqid-1/2
// transport-mode ESP state on `local` goes whoever made it (tests/Ipsec).
std::vector<std::vector<std::string>> OwnXfrmDeleteCommands(std::string_view stateText, std::string_view policyText, std::string_view local) {
auto addr = [](std::string_view tok) { return imsd::util::CanonicalAddr(tok.substr(0, tok.find('/'))); };
const std::string l = imsd::util::CanonicalAddr(local);
std::vector<std::pair<std::string, std::string>> pairs; // (src, dst) of imsd's policies
std::vector<std::vector<std::string>> policyDels;
for (std::string_view b : detail::SrcBlocks(policyText)) {
if (!detail::OwnPolicy(b)) continue;
const std::string_view src = detail::TokenAfter(b, "src ");
const std::string_view dst = detail::TokenAfter(b, "dst ");
pairs.emplace_back(addr(src), addr(dst));
// the selector line as `ip xfrm policy` prints it: src dst [proto] [sport] [dport] [dev] — every token
// present goes into the delete (the tmpl line's `proto esp` is not the selector's), none is emitted empty
const std::string_view sel = b.substr(0, b.find('\n'));
std::vector<std::string> del{"ip", "xfrm", "policy", "delete", "src", std::string(src), "dst", std::string(dst)};
for (const char* key : {"proto ", "sport ", "dport ", "dev "})
if (std::string_view v = detail::TokenAfter(sel, key); !v.empty()) { del.emplace_back(key, std::strlen(key) - 1); del.emplace_back(v); }
del.emplace_back("dir"); del.emplace_back(detail::TokenAfter(b, "dir "));
policyDels.push_back(std::move(del));
}
std::vector<std::vector<std::string>> cmds;
for (std::string_view b : detail::SrcBlocks(stateText)) {
if (b.find("proto esp ") == std::string_view::npos) continue; // the delete names proto esp: an AH or IPComp state is not this rule's
if (b.find("reqid 1 mode transport") == std::string_view::npos && b.find("reqid 2 mode transport") == std::string_view::npos) continue;
const std::string_view src = detail::TokenAfter(b, "src ");
const std::string_view dst = detail::TokenAfter(b, "dst ");
bool mine = !l.empty() && (addr(src) == l || addr(dst) == l);
for (const auto& p : pairs) if (p.first == addr(src) && p.second == addr(dst)) { mine = true; break; }
if (!mine) continue;
cmds.push_back({"ip", "xfrm", "state", "delete", "src", std::string(src), "dst", std::string(dst), "proto", "esp", "spi", std::string(detail::TokenAfter(b, "spi "))});
}
cmds.insert(cmds.end(), policyDels.begin(), policyDels.end());
return cmds;
}
} }

View file

@ -101,6 +101,54 @@ int main() {
Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none"); Check(SaLocals(State, "2001:db8::999").empty(), "SaLocals: no SA to that P-CSCF -> none");
} }
// ---- OwnXfrmDeleteCommands: imsd's own entries out of the kernel by exact key; everything else stays
{
const std::string ue = "2001:db8:1111:2222:3333:4444:5555:db43";
// a policy-based VPN's: a tunnel policy (v6, reqid 1), an IPv4 one to 0.0.0.0/0, a tunnel SA
const std::string vpn = "src 2001:db8::1/128 dst 2001:db8::2/128 \n\tdir out priority 0 \n\ttmpl src 2001:db8::1 dst 2001:db8::2\n\t\tproto esp reqid 1 mode tunnel\n";
const std::string vpn4 = "src 192.0.2.10/32 dst 0.0.0.0/0 \n\tdir out priority 383615 ptype main \n\ttmpl src 192.0.2.10 dst 192.0.2.1\n\t\tproto esp reqid 1 mode tunnel\n";
const std::string tunState = "src 2001:db8::1 dst 2001:db8::2\n\tproto esp spi 0x00000777 reqid 7 mode tunnel\n\treplay-window 32 \n";
// another transport-mode user's reqid 1 + 2 pair on other ports, with its SA between other addresses
const std::string otherOut2 = "src " + ue + "/128 dst 2001:db8::106/128 sport 1 dport 2 \n\tdir out priority 0 \n\ttmpl src " + ue + " dst 2001:db8::106\n\t\tproto esp reqid 2 mode transport\n";
const std::string otherOut1 = "src " + ue + "/128 dst 2001:db8::106/128 sport 3 dport 4 \n\tdir out priority 0 \n\ttmpl src " + ue + " dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
const std::string otherState = "src 2001:db8::7 dst 2001:db8::106\n\tproto esp spi 0x00000106 reqid 2 mode transport\n\treplay-window 0 \n";
auto dels = OwnXfrmDeleteCommands(State, Policy, ue);
Check(dels.size() == 8, "OwnXfrmDeleteCommands: our four states and four policies -> 8 deletes");
Check(dels.size() == 8 && Join(dels[0]) == "ip xfrm state delete src 2001:db8::105 dst " + ue + " proto esp spi 0x0a84ab2f", "OwnXfrmDeleteCommands: a state by src, dst and SPI, byte-exact");
Check(dels.size() == 8 && Join(dels[4]) == "ip xfrm policy delete src 2001:db8::105/128 dst " + ue + "/128 sport 33421 dport 45062 dir in", "OwnXfrmDeleteCommands: a policy by its selector and direction, byte-exact");
Check(dels.size() == 8 && Join(dels[1]) == "ip xfrm state delete src " + ue + " dst 2001:db8::105 proto esp spi 0x0526f2c1" && Join(dels[2]) == "ip xfrm state delete src 2001:db8::105 dst " + ue + " proto esp spi 0x0071acd4" && Join(dels[3]) == "ip xfrm state delete src " + ue + " dst 2001:db8::105 proto esp spi 0x0526f2c0",
"OwnXfrmDeleteCommands: the other three states byte-exact, in the kernel's order");
Check(dels.size() == 8 && Join(dels[5]) == "ip xfrm policy delete src " + ue + "/128 dst 2001:db8::105/128 sport 45062 dport 33421 dir out" && Join(dels[6]) == "ip xfrm policy delete src 2001:db8::105/128 dst " + ue + "/128 sport 6000 dport 45061 dir in" && Join(dels[7]) == "ip xfrm policy delete src " + ue + "/128 dst 2001:db8::105/128 sport 45061 dport 6000 dir out",
"OwnXfrmDeleteCommands: the other three policies byte-exact, in the kernel's order");
auto besideVpn = OwnXfrmDeleteCommands(std::string(State) + tunState, std::string(Policy) + vpn + vpn4, ue);
Check(besideVpn.size() == 8 && std::ranges::none_of(besideVpn, [](const auto& c) { auto j = Join(c); return j.contains("2001:db8::2") || j.contains("192.0.2.10") || j.contains("0x00000777"); }),
"OwnXfrmDeleteCommands: a VPN's tunnel SA and two tunnel policies beside ours -> ours only, theirs untouched");
Check(OwnXfrmDeleteCommands(otherState, otherOut2 + otherOut1, ue).empty(), "OwnXfrmDeleteCommands: another transport-mode reqid 1 + 2 pair on other ports, with its SA between other addresses -> nothing deleted");
Check(OwnXfrmDeleteCommands(std::string(State) + otherState, std::string(Policy) + otherOut2 + otherOut1, ue).size() == 8, "OwnXfrmDeleteCommands: the same beside our pair -> ours only");
Check(OwnXfrmDeleteCommands("", "", ue).empty(), "OwnXfrmDeleteCommands: nothing -> nothing");
Check(OwnXfrmDeleteCommands(State, "", ue).size() == 4, "OwnXfrmDeleteCommands: policy-less states of imsd's on the UE's address are deleted (a setup whose policy add failed leaves them)");
Check(OwnXfrmDeleteCommands(otherState, "", ue).empty(), "OwnXfrmDeleteCommands: a policy-less state between other addresses is left");
// the fingerprint selects, not a pair: a lone transport-mode reqid-1 policy carrying the protected port between other addresses goes, with the reqid-1 state between them (the §12 residual)
const std::string loneOther = "src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
const std::string loneOtherState = "src 2001:db8::7 dst 2001:db8::106\n\tproto esp spi 0x00000107 reqid 1 mode transport\n\treplay-window 0 \n";
Check(OwnXfrmDeleteCommands(loneOtherState, loneOther, ue).size() == 2, "OwnXfrmDeleteCommands: a lone policy carrying the fingerprint between other addresses, and the reqid-1 state in its direction, are deleted (the fingerprint selects, not a pair)");
// a reqid-1/2 transport-mode state on the UE's address goes whoever made it: the other user's SA between the UE address and its peer, its policies on other ports staying (the §12 residual)
const std::string otherOnUe = "src " + ue + " dst 2001:db8::106\n\tproto esp spi 0x00000108 reqid 1 mode transport\n\treplay-window 0 \n";
auto onUe = OwnXfrmDeleteCommands(otherOnUe, otherOut2 + otherOut1, ue);
Check(onUe.size() == 1 && Join(onUe[0]) == "ip xfrm state delete src " + ue + " dst 2001:db8::106 proto esp spi 0x00000108", "OwnXfrmDeleteCommands: another transport-mode user's reqid-1 state on the UE's address is deleted whoever made it; its policies on other ports stay (the §12 residual)");
Check(OwnXfrmDeleteCommands("src " + ue + " dst 2001:db8::106\n\tproto ah spi 0x00000109 reqid 1 mode transport\n\treplay-window 0 \n", otherOut2 + otherOut1, ue).empty(), "OwnXfrmDeleteCommands: an AH reqid-1 transport state on the UE's address is not selected (the delete names proto esp)");
// the selector as `ip xfrm policy` prints it: a fingerprinted policy with a protocol (and a dev) keeps them in its delete, a missing port is no empty token
const std::string protoOther = "src 2001:db8::7/128 dst 2001:db8::106/128 proto udp sport 45061 dport 4 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
auto protoDels = OwnXfrmDeleteCommands("", protoOther, ue);
Check(protoDels.size() == 1 && Join(protoDels[0]) == "ip xfrm policy delete src 2001:db8::7/128 dst 2001:db8::106/128 proto udp sport 45061 dport 4 dir out", "OwnXfrmDeleteCommands: a fingerprinted policy with a protocol in its selector is deleted by that selector (proto kept; the tmpl's 'proto esp' is not the selector's)");
const std::string portlessOther = "src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dev qmapmux0.0 \n\tdir out priority 0 \n\ttmpl src 2001:db8::7 dst 2001:db8::106\n\t\tproto esp reqid 1 mode transport\n";
auto portlessDels = OwnXfrmDeleteCommands("", portlessOther, ue);
Check(portlessDels.size() == 1 && Join(portlessDels[0]) == "ip xfrm policy delete src 2001:db8::7/128 dst 2001:db8::106/128 sport 45061 dev qmapmux0.0 dir out", "OwnXfrmDeleteCommands: a selector without a dport and with a dev -> no empty token, the dev kept");
// the SA reader's ports come from our pair, never from a foreign transport policy that happens to carry ports
auto saBeside = ParseExistingSa(State, std::string(Policy) + otherOut2, "2001:db8::105", ue);
Check(saBeside && saBeside->portPc == 33421 && saBeside->portPs == 6000, "ParseExistingSa: a foreign transport policy's ports beside ours are not taken");
}
// ---- fresh setup command sequence // ---- fresh setup command sequence
{ {
SaParams p; SaParams p;
@ -111,23 +159,21 @@ int main() {
p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400; p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400;
p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421; p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421;
auto cmds = BuildSetupCommands(p); auto cmds = BuildSetupCommands(p);
Check(cmds.size() == 10, "flush x2 + 4 states + 4 policies"); Check(cmds.size() == 8, "4 states + 4 policies, no flush (OwnXfrmDeleteCommands takes imsd's previous entries out)");
Check(Join(cmds[0]) == "ip xfrm state flush", "state flush first");
Check(Join(cmds[1]) == "ip xfrm policy flush", "policy flush second");
// reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc // reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc
Check(Join(cmds[2]) == Check(Join(cmds[0]) ==
"ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 " "ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 "
"proto esp spi 400 mode transport reqid 1 " "proto esp spi 400 mode transport reqid 1 "
"auth-trunc hmac(sha1) 0xabababababababababababababababab 96 " "auth-trunc hmac(sha1) 0xabababababababababababababababab 96 "
"enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", "enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
"state add reqid1 outbound byte-exact"); "state add reqid1 outbound byte-exact");
// first out policy: sport PORT_UC dport PORT_PS reqid 1 // first out policy: sport PORT_UC dport PORT_PS reqid 1
Check(Join(cmds[6]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact"); Check(Join(cmds[4]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact");
// v4 uses /32 // v4 uses /32
SaParams p4 = p; SaParams p4 = p;
p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1"; p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1";
auto c4 = BuildSetupCommands(p4); auto c4 = BuildSetupCommands(p4);
Check(c4[6][5] == "10.0.0.2/32", "v4 prefix length /32"); Check(c4[4][5] == "10.0.0.2/32", "v4 prefix length /32");
} }
// null cipher -> cipher_null + empty key arg // null cipher -> cipher_null + empty key arg
{ {
@ -137,7 +183,7 @@ int main() {
p.ck = {}; p.ck = {};
p.ealg = "null"; p.ealg = "null";
auto cmds = BuildSetupCommands(p); auto cmds = BuildSetupCommands(p);
auto& st = cmds[2]; auto& st = cmds[0];
Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key"); Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key");
} }
@ -149,7 +195,7 @@ int main() {
p.ck = {}; p.ck = {};
p.alg = "hmac-md5-96"; p.ealg = "null"; p.alg = "hmac-md5-96"; p.ealg = "null";
auto cmds = BuildSetupCommands(p); auto cmds = BuildSetupCommands(p);
Check(cmds[2][17] == "hmac(md5)" && cmds[2][18] == "0x01010101010101010101010101010101" && cmds[2][19] == "96", "md5: auth-trunc hmac(md5) IK 96"); Check(cmds[0][17] == "hmac(md5)" && cmds[0][18] == "0x01010101010101010101010101010101" && cmds[0][19] == "96", "md5: auth-trunc hmac(md5) IK 96");
Check(KernelAuth("hmac-sha-1-96") == "hmac(sha1)" && KernelAuth("hmac-md5-96") == "hmac(md5)" && !KernelAuth("hmac-sha-256-128"), "KernelAuth names"); Check(KernelAuth("hmac-sha-1-96") == "hmac(sha1)" && KernelAuth("hmac-md5-96") == "hmac(md5)" && !KernelAuth("hmac-sha-256-128"), "KernelAuth names");
Check(KnownEalg("null") && KnownEalg("aes-cbc") && KnownEalg("des-ede3-cbc") && !KnownEalg("aes-gcm") && !KnownEalg("") && !KnownEalg("3des"), "KnownEalg set (canonical names only)"); Check(KnownEalg("null") && KnownEalg("aes-cbc") && KnownEalg("des-ede3-cbc") && !KnownEalg("aes-gcm") && !KnownEalg("") && !KnownEalg("3des"), "KnownEalg set (canonical names only)");
} }