imsd 0.2.7 — initial public snapshot

Userspace VoLTE/IMS daemon for mainline Linux phones (developed on the
Fairphone 6): a GLib-free core (SIP, SDP, USIM AKA, IPsec SA setup, RTP
media, call engine) behind a GDBus control daemon, a standalone AMR-WB
media leg, and a Plasma Dialer backend. C++26 modules built with Crafter
Build; the tree is clean under the project's house-style linter
(crafter-build lint) across all three build products.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
This commit is contained in:
Jorijn van der Graaf 2026-07-22 22:53:28 +02:00
commit 6e77823933
31 changed files with 8761 additions and 0 deletions

11
.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
bin/
build/
imsd-*.tar.gz
# captures / runtime state must never enter the repo (they carry the
# subscriber's IMSI/MSISDN and network addresses)
*.raw
*.rtp
*.stats
imsreg.state

674
LICENSE Normal file
View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

215
README.md Normal file
View file

@ -0,0 +1,215 @@
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<!-- SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -->
# imsd
Userspace IMS/VoLTE daemon for mainline Linux phones.
> [!WARNING]
> imsd currently cannot make emergency calls
Modem firmware on typical mainline-Linux phones will bring up the IMS PDN but
can't be used for actual call audio. imsd implements the whole IMS client in
userspace instead: SIP registration with USIM AKA authentication over kernel
IPsec (ESP), call signalling, and the AMR-WB RTP media plane wired into
PipeWire — so a stock Plasma Mobile dialer can place and receive real VoLTE
calls on a commercial network.
## Status
Call-capable, both directions. Outgoing VoLTE calls place, answer, carry
full-duplex AMR-WB audio, and tear down; incoming calls ring, answer via
`Accept`, and carry media the same way — driven end-to-end from a stock
dialer over the frozen D-Bus ABI. Current state:
| Piece | State |
|---|---|
| SIP message layer + TCP framing (`Imsd:Sip`) | done, tested |
| SDP offer/answer for AMR-WB (`Imsd:Sdp`) | done, tested |
| USIM AKA + AKAv1-MD5 digest, SIM parsers (`Imsd:Aka`) | done, tested |
| IPsec SA commands + warm-SA reader (`Imsd:Ipsec`) | done, tested |
| SIP request/response builders (`Imsd:Messages`) | done, byte-pinned |
| Outgoing + incoming call state machine (`Imsd:Engine`) | done, tested |
| Registration (fresh + warm resume + keepalive refresh, MMTEL feature tags) | done |
| RTP + AMR-WB media plane into PipeWire (`imsd-media`) | done |
| D-Bus service `net.catcrafts.IMS1` (register, Dial, Accept, HangUp, live media) | done |
| Plasma Dialer session backend (`imsd-dialerd`, org.kde.telephony.*) | done |
| DTMF (`SendDtmf`, RFC 4733) | not yet implemented |
The engine core (`imsd-core`) is a GLib-free, pure-`std` static library whose
every decision — SIP framing, SDP selection, the AKAv1-MD5 digest, the
`ip xfrm` command sequence, and the whole call FSM — is unit-tested on a dev
box with no bus, modem, or phone; several suites pin output byte-for-byte
against recorded network traffic. GLib/GDBus, sockets, and subprocess control
live only in the `imsd` daemon shell.
## Architecture
C++26 modules, built with Crafter Build. Four build products:
- **imsd-core** (static library) — the engine, pure `import std` C++ with no
GLib and no I/O side effects. Everything here is unit-testable on any dev
box without a modem, a bus, or a phone; several suites pin output
byte-for-byte against recorded network traffic.
- **imsd** (executable) — the daemon shell: GLib main loop, GDBus service,
sockets, subprocess control (`qmicli`/`mmcli`/`ip`), and the registration
sequence + run loop that pumps the core. All GLib and I/O live here.
- **imsd-media** (executable) — the RTP/AMR-WB data plane, spawned once per
call. Pure `import std` + POSIX, no GLib; links `-ldl` and dlopen's the
codecs. A separate process for crash isolation and the far-end-hangup
exit-code contract.
- **imsd-dialerd** (executable) — the Plasma Mobile integration: a session
daemon owning `org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils}`,
bridging them to imsd on the system bus. GDBus translation only, no core.
It is a separate process because those are per-user *session*-bus names
that the root system daemon cannot own; built with `-- --product=dialerd`,
autostarted from an `.desktop` file in place of plasma-dialer's
`modem-daemon` (whose autostart must be disabled).
### Terminating-network dependency (incoming)
A UE only receives a VoLTE call as a SIP INVITE if the network's Terminating
Access Domain Selection (T-ADS) routes it to the PS/IMS domain rather than
paging the modem over CS. That requires the IMS registration to advertise
voice capability — the MMTEL ICSI (`+g.3gpp.icsi-ref=...mmtel`) and
`+sip.instance` media-feature tags in the REGISTER Contact, which
`Imsd:Messages` emits. T-ADS may still select CS for reasons outside the
client's control (operator policy, the modem's own radio-capability
signalling, a competing registration). When it does, no INVITE reaches imsd;
that is a network/modem-integration matter, not a daemon defect.
## Supported hardware & carriers
imsd talks to the modem through ModemManager plus `qmicli` over QRTR (USIM
AKA via a UIM logical channel), so in practice it currently needs a Qualcomm
modem on a mainline kernel with working WWAN/MM integration, kernel ESP
(`ip xfrm`), and PipeWire for audio.
Everything below has been verified on exactly **one device / one carrier**
(n=1) — reports from other combinations are very welcome:
| Device | OS | Modem | Carrier | Status |
|---|---|---|---|---|
| Fairphone 6 (SM7635 "milos") | postmarketOS, mainline kernel | Qualcomm (QRTR/QMI) | KPN NL (eSIM) | MO + MT calls, full-duplex AMR-WB |
Carrier-side assumptions that held on this network but may differ elsewhere:
IPsec sec-agree with AKAv1-MD5 over IPv6, AMR-WB audio, TCP for the
protected flows. The SIP/SDP/AKA layers are carrier-agnostic and unit-tested;
the bring-up sequencing is where new networks will differ.
## Build
[Crafter Build](https://forgejo.catcrafts.net/Catcrafts/Crafter.Build) +
gio-2.0 headers (`glib2` on Arch, `glib-dev` on Alpine).
```sh
crafter-build # bin/imsd-<target>-<march>/imsd (the daemon)
crafter-build -- --product=media # bin/imsd-media-.../imsd-media (the media leg)
crafter-build -- --product=dialerd # bin/imsd-dialerd-.../imsd-dialerd (dialer backend)
crafter-build test # unit tests (Util, Aka, Ipsec, Messages, Engine, Sip, Sdp)
```
### Cross-compiling for a phone (aarch64 Alpine/postmarketOS)
```sh
packaging/make-sysroot.sh # one-time: Alpine aarch64 sysroot from the CDN
crafter-build -- --target=aarch64-alpine-linux-musl \
--sysroot=$HOME/.cache/imsd/sysroot-aarch64-alpine \
--march=armv8-a --mtune=generic
# same flags after `crafter-build test --target=aarch64-alpine-linux-musl`
# run the suite under qemu-aarch64 against the sysroot
```
## Run (development)
```sh
./bin/imsd-*/imsd --session # own net.catcrafts.IMS1 on the session bus
busctl --user call net.catcrafts.IMS1 /net/catcrafts/IMS1 net.catcrafts.IMS1 GetStatus
```
On a phone the daemon runs as root on the system bus (policy in
`packaging/net.catcrafts.IMS1.conf` — it grants the postmarketOS default
account `user` access; adjust it alongside `AUDIO_USER` if your session
account is named differently), started by `packaging/imsd.service`
after `packaging/ims-pdn-up.sh` has brought up the ims PDN through
ModemManager. It spawns `/usr/libexec/imsd-media` per call. Registration
resumes an in-kernel IPsec SA when one is present (avoiding the network's
fresh-SA throttle) and keeps itself alive with a re-REGISTER refresh at half
the granted lifetime. `packaging/APKBUILD.binary` + `make-bin-tarball.sh`
wrap a cross-compiled build into an apk.
## Configuration
Everything is environment variables. The packaged unit reads
`/run/imsd.env` (written by `ims-pdn-up.sh`: the connected ims netdev) and
then `/etc/imsd.env` (your configuration, wins on conflict). Minimum viable
`/etc/imsd.env`:
```sh
# REQUIRED until P-CSCF discovery from the PDN's PCO is implemented: your
# carrier's P-CSCF address. Find it in a stock-firmware capture or your
# carrier's IMS documentation.
PCSCF=2001:db8::105
```
### imsd
| Variable | Default | Meaning |
|---|---|---|
| `PCSCF` | *(none — required)* | P-CSCF address; without it registration fails with a clear error |
| `PCSCF_PORT` | `5060` | unprotected SIP port for the initial REGISTER |
| `DEV` | `qmapmux0.0` | ims-PDN netdev; the packaged service passes the real one via `/run/imsd.env` |
| `LOCAL` | *(auto)* | UE address; default = the global IPv6 on `DEV` |
| `USER_AGENT` | `imsd/<version>` | REGISTER User-Agent. Some networks fingerprint UAs; setting your device's stock build string reproduces the stock modem's registration exactly. Empty omits the header |
| `STATE_FILE` | `/var/lib/imsd/imsreg.state` (root) / `$XDG_STATE_HOME/imsd/imsreg.state` | persisted registration context for warm resume |
| `RESUME` | `auto` | `0` forces a fresh registration (ignores a warm SA) |
| `REFRESH_INTERVAL` | *(auto)* | keepalive re-REGISTER period in s; default = half the granted expiry, clamped to [120, 1800] |
| `EALG` | `aes-cbc` | offered ESP cipher (`aes-cbc`, `des-ede3-cbc`, `null`) |
| `RTP_PORT` | `50004` | local RTP port advertised in SDP |
| `PRECOND` | `0` | `1` offers SDP QoS preconditions |
| `DUMP_SIP` | `0` | `1` writes raw REGISTER-200/SUBSCRIBE-200/INVITE dumps (mode 0600) for debugging — they contain your IMSI/MSISDN and addresses |
| `DUMP_DIR`, `OUT_DIR` | state dir | where dumps / per-call media stats land |
| `IMSD_MEDIA` | *(auto)* | path to `imsd-media` (default: next to `imsd`, else `/usr/libexec/imsd-media`) |
The protected client/server ports are fixed at 45061/45062
(`imsd::util::kPortUc`/`kPortUs`) — changing them in source also requires
updating the firewall rules `ims-pdn-up.sh` installs.
### imsd-media (per-call, set through imsd's environment)
| Variable | Default | Meaning |
|---|---|---|
| `MIC`, `PLAY` | `1`, `1` | uplink mic capture / downlink playout via PipeWire |
| `AUDIO_USER` | `user` | desktop user whose PipeWire session carries call audio (postmarketOS default account) |
| `GAIN`, `PLAY_GAIN` | `10`, `1.0` | uplink / downlink gain |
| `AMR_MODE` | `2` | AMR-WB encoder mode (08) |
| `DTX` | `0` | encoder discontinuous transmission |
| `MEDIA_TIMEOUT` | `6.0` | seconds of downlink silence before exit 3 (far-end-hangup signal) |
| `RTP_DUMP` | `0` | `1` captures raw downlink RTP next to the stats file |
## D-Bus ABI
`net.catcrafts.IMS1` at `/net/catcrafts/IMS1`. The ABI is frozen — dialer
integrations depend on it:
| Member | Signature |
|---|---|
| `Dial(number)` | `s → s` (callUni) |
| `HangUp(callUni)` | `s` |
| `Accept(callUni)` | `s` |
| `SendDtmf(callUni, tones)` | `ss` |
| `GetCalls()` | `→ aa{sv}` |
| `GetStatus()` | `→ a{sv}` |
| `CallAdded` | `s a{sv}` |
| `CallStateChanged` | `s s s` (callUni, state, reason) |
| `CallDeleted` | `s` |
| `RegistrationChanged` | `b` |
## License
GPL-3.0-only — see [LICENSE](LICENSE).
## Copyright
Copyright (C) 2026 Catcrafts®
catcrafts.net

590
implementations/dialerd.cpp Normal file
View file

@ -0,0 +1,590 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types no-char-pointer
/*
imsd-dialerd Plasma Dialer backend for imsd.
Drop-in replacement for plasma-dialer's `modem-daemon` (the only component of
the Plasma Mobile telephony stack that talks to ModemManager). Owns the same
session-bus names/objects the stock daemon owns:
org.kde.telephony.CallUtils /org/kde/telephony/CallUtils/tel/mm
org.kde.telephony.DeviceUtils /org/kde/telephony/DeviceUtils/tel/mm
org.kde.telephony.UssdUtils /org/kde/telephony/UssdUtils/tel/mm
(the `/tel/mm` path suffix is hardcoded in plasma-dialer's QML plugin and in
kde-telephony-daemon keep it) and bridges them to imsd (net.catcrafts.IMS1
on the system bus), which drives the real SIP/IPsec/RTP stack. plasma-dialer,
kde-telephony-daemon (ringing/notifications/call history/callaudiod/MPRIS)
and spacebar run UNMODIFIED; the stock modem-daemon autostart must be
disabled (Hidden=true override) so we own the names.
ABI notes (plasma-dialer 6.7.3, verified against kde-telephony-meta source):
- CallData is marshalled as a STRUCT WRAPPING a{sv} actual signature
"(a{sv})" even though the interface XML declares (sssss(i)(i)(i)ixi).
Keys: id protocol provider account communicationWith direction state
stateReason callAttemptDuration startedAt(int64 epoch secs) duration.
- enums are struct-wrapped int32s "(i)"; values = MMCallState numbering.
- sendDtmf/accept/hangUp can arrive with EMPTY callUni (ActiveCallModel) ->
map an empty callUni to the active call.
- on callAdded/callDeleted the UI re-fetches: fetchCalls() must emit
callsChanged with the full current vector (live-computed durations
the UI's 1 s timer only interpolates client-side).
- kde-telephony-daemon logs history on callStateChanged(state==Terminated)
and drives callaudiod on Active/Terminated.
Runs as the session user (autostart .desktop), no root needed. Everything
happens on the GLib main loop: imsd's system-bus signals and the dialer's
session-bus method calls both arrive there, so there is no locking.
*/
#include <gio/gio.h>
#include <glib-unix.h>
import std;
namespace {
constexpr const char* ImsdBus = "net.catcrafts.IMS1";
constexpr const char* ImsdPath = "/net/catcrafts/IMS1";
constexpr const char* ImsdIface = "net.catcrafts.IMS1";
constexpr const char* CallsIface = "org.kde.telephony.CallUtils";
constexpr const char* DeviceIface = "org.kde.telephony.DeviceUtils";
constexpr const char* UssdIface = "org.kde.telephony.UssdUtils";
constexpr const char* CallsPath = "/org/kde/telephony/CallUtils/tel/mm";
constexpr const char* DevicePath = "/org/kde/telephony/DeviceUtils/tel/mm";
constexpr const char* UssdPath = "/org/kde/telephony/UssdUtils/tel/mm";
constexpr const char* DeviceUni = "ims";
constexpr const char* Provider = "20408"; // operator id, as modem-daemon reports it
// DialerTypes::CallState (== MMCallState numbering)
constexpr int StDialing = 1, StRingingOut = 2, StRingingIn = 3,
StActive = 4, StTerminated = 7;
// DialerTypes::CallStateReason
constexpr int RsUnknown = 0, RsOutgoing = 1, RsIncoming = 2, RsAccepted = 3,
RsTerminated = 4, RsBusy = 5, RsError = 6;
// DialerTypes::CallDirection
constexpr int DirIncoming = 1, DirOutgoing = 2;
void Log(std::string_view m) { std::println("imsd-dialerd: {}", m); std::fflush(stdout); }
int StateOf(std::string_view s, int fallback) {
if (s == "dialing") return StDialing;
if (s == "ringing") return StRingingOut;
if (s == "incoming") return StRingingIn;
if (s == "active") return StActive;
if (s == "terminated") return StTerminated;
return fallback;
}
int ReasonOf(std::string_view s, int fallback) {
if (s == "outgoing") return RsOutgoing;
if (s == "incoming") return RsIncoming;
if (s == "accepted") return RsAccepted;
if (s == "local-hangup" || s == "remote-hangup") return RsTerminated;
if (s == "refused-or-busy") return RsBusy;
if (s == "error") return RsError;
return fallback;
}
std::int64_t NowEpoch() {
return std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
}
struct CallRec {
std::string uni, number;
int direction = DirOutgoing;
int state = StDialing;
int reason = RsOutgoing;
std::int64_t startedAt = 0;
std::int64_t answeredAt = 0; // 0 = not answered
std::int64_t Duration() const {
return answeredAt ? NowEpoch() - answeredAt : 0;
}
};
// ---- state (main thread only) ----------------------------------------------
GDBusConnection* Session = nullptr;
GDBusConnection* System = nullptr;
std::map<std::string, CallRec> Calls; // uni -> record
std::string Account; // MSISDN from imsd (history 'account')
bool Registered = false;
bool ImsdUp = false;
GMainLoop* Loop = nullptr;
// ---- GVariant helpers -------------------------------------------------------
std::string DictStr(GVariant* dict, const char* key) {
GVariant* v = g_variant_lookup_value(dict, key, G_VARIANT_TYPE_STRING);
if (!v) return "";
std::string s = g_variant_get_string(v, nullptr);
g_variant_unref(v);
return s;
}
std::int64_t DictInt64(GVariant* dict, const char* key) {
GVariant* v = g_variant_lookup_value(dict, key, G_VARIANT_TYPE_INT64);
if (!v) return 0;
std::int64_t x = g_variant_get_int64(v);
g_variant_unref(v);
return x;
}
bool DictBool(GVariant* dict, const char* key) {
GVariant* v = g_variant_lookup_value(dict, key, G_VARIANT_TYPE_BOOLEAN);
if (!v) return false;
bool b = g_variant_get_boolean(v);
g_variant_unref(v);
return b;
}
GVariant* Enum(int v) { return g_variant_new("(i)", v); }
// The (a{sv}) CallData struct (see the ABI notes up top).
GVariant* CallData(const CallRec& r) {
GVariantBuilder b;
g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}"));
g_variant_builder_add(&b, "{sv}", "id", g_variant_new_string(r.uni.c_str()));
g_variant_builder_add(&b, "{sv}", "protocol", g_variant_new_string("tel"));
g_variant_builder_add(&b, "{sv}", "provider", g_variant_new_string(Provider));
g_variant_builder_add(&b, "{sv}", "account", g_variant_new_string(Account.c_str()));
g_variant_builder_add(&b, "{sv}", "communicationWith", g_variant_new_string(r.number.c_str()));
g_variant_builder_add(&b, "{sv}", "direction", g_variant_new_int32(r.direction));
g_variant_builder_add(&b, "{sv}", "state", g_variant_new_int32(r.state));
g_variant_builder_add(&b, "{sv}", "stateReason", g_variant_new_int32(r.reason));
g_variant_builder_add(&b, "{sv}", "callAttemptDuration", g_variant_new_int32(0));
g_variant_builder_add(&b, "{sv}", "startedAt", g_variant_new_int64(r.startedAt));
g_variant_builder_add(&b, "{sv}", "duration", g_variant_new_int32(static_cast<int>(r.Duration())));
return g_variant_new("(@a{sv})", g_variant_builder_end(&b));
}
GVariant* CallVector() {
GVariantBuilder b;
g_variant_builder_init(&b, G_VARIANT_TYPE("a(a{sv})"));
for (auto& [uni, rec] : Calls)
g_variant_builder_add_value(&b, CallData(rec));
return g_variant_builder_end(&b);
}
GVariant* DeviceList() {
GVariantBuilder b;
g_variant_builder_init(&b, G_VARIANT_TYPE("as"));
if (ImsdUp && Registered) g_variant_builder_add(&b, "s", DeviceUni);
return g_variant_builder_end(&b);
}
std::string ActiveUni() {
for (auto& [uni, rec] : Calls)
if (rec.state != StTerminated) return uni;
return "";
}
void EmitCalls(const char* name, GVariant* params) {
g_dbus_connection_emit_signal(Session, nullptr, CallsPath, CallsIface, name, params, nullptr);
}
void EmitDevice(const char* name, GVariant* params) {
g_dbus_connection_emit_signal(Session, nullptr, DevicePath, DeviceIface, name, params, nullptr);
}
void EmitUssd(const char* name, GVariant* params) {
g_dbus_connection_emit_signal(Session, nullptr, UssdPath, UssdIface, name, params, nullptr);
}
// ---- imsd (system bus) side -------------------------------------------------
// Fire-and-forget method call into imsd; failures are logged, not fatal
// (matching the Python bridge — a dead imsd shows up via the name watch).
void CallImsd(const char* method, GVariant* params) {
g_dbus_connection_call(
System, ImsdBus, ImsdPath, ImsdIface, method, params, nullptr,
G_DBUS_CALL_FLAGS_NONE, 5000, nullptr,
[](GObject* src, GAsyncResult* res, gpointer m) {
GError* err = nullptr;
GVariant* r = g_dbus_connection_call_finish(G_DBUS_CONNECTION(src), res, &err);
if (err) {
Log(std::format("{} failed: {}", static_cast<const char*>(m), err->message));
g_error_free(err);
}
if (r) g_variant_unref(r);
},
const_cast<char*>(method));
}
void RefreshStatus() {
GError* err = nullptr;
GVariant* st = g_dbus_connection_call_sync(System, ImsdBus, ImsdPath, ImsdIface, "GetStatus", nullptr, G_VARIANT_TYPE("(a{sv})"), G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, &err);
if (err) {
Log(std::format("imsd status query failed: {}", err->message));
g_error_free(err);
Registered = false;
return;
}
GVariant* dict = g_variant_get_child_value(st, 0);
Registered = DictBool(dict, "registered");
std::string ppi = DictStr(dict, "ppi");
if (ppi.starts_with("tel:")) Account = ppi.substr(4);
g_variant_unref(dict);
g_variant_unref(st);
GVariant* calls = g_dbus_connection_call_sync(System, ImsdBus, ImsdPath, ImsdIface, "GetCalls", nullptr, G_VARIANT_TYPE("(aa{sv})"), G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, &err);
if (err) { g_error_free(err); return; }
GVariant* arr = g_variant_get_child_value(calls, 0);
GVariantIter it;
g_variant_iter_init(&it, arr);
while (GVariant* info = g_variant_iter_next_value(&it)) {
std::string uni = DictStr(info, "uni");
if (!uni.empty() && !Calls.contains(uni)) {
CallRec rec;
rec.uni = uni;
rec.number = DictStr(info, "number");
rec.direction = DictStr(info, "direction") == "incoming" ? DirIncoming
: DirOutgoing;
rec.state = StateOf(DictStr(info, "state"), StDialing);
rec.reason = ReasonOf(DictStr(info, "reason"), RsUnknown);
rec.startedAt = DictInt64(info, "startedAt");
rec.answeredAt = DictInt64(info, "answeredAt");
Calls[uni] = rec;
}
g_variant_unref(info);
}
g_variant_unref(arr);
g_variant_unref(calls);
}
// imsd signals -> CallUtils signals
void OnImsdSignal(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* signal, GVariant* params, gpointer) {
std::string_view sig = signal;
if (sig == "CallAdded") {
const gchar* cuni = nullptr;
GVariant* info = nullptr;
g_variant_get(params, "(&s@a{sv})", &cuni, &info);
CallRec rec;
rec.uni = cuni ? cuni : "";
rec.number = DictStr(info, "number");
rec.direction = DictStr(info, "direction") == "incoming" ? DirIncoming : DirOutgoing;
rec.state = StateOf(DictStr(info, "state"), rec.direction == DirIncoming ? StRingingIn : StDialing);
rec.reason = ReasonOf(DictStr(info, "reason"), rec.direction == DirIncoming ? RsIncoming : RsOutgoing);
rec.startedAt = DictInt64(info, "startedAt");
if (!rec.startedAt) rec.startedAt = NowEpoch();
g_variant_unref(info);
Calls[rec.uni] = rec;
Log(std::format("call added {} {} {}", rec.uni, rec.direction == DirIncoming ? "<-" : "->", rec.number));
EmitCalls("callAdded", g_variant_new("(ss@(i)@(i)@(i)s)", DeviceUni, rec.uni.c_str(), Enum(rec.direction), Enum(rec.state), Enum(rec.reason), rec.number.c_str()));
} else if (sig == "CallStateChanged") {
const gchar* cuni = nullptr; const gchar* cstate = nullptr;
const gchar* creason = nullptr;
g_variant_get(params, "(&s&s&s)", &cuni, &cstate, &creason);
auto it = Calls.find(cuni ? cuni : "");
if (it == Calls.end()) return;
CallRec& rec = it->second;
rec.state = StateOf(cstate ? cstate : "", rec.state);
rec.reason = ReasonOf(creason ? creason : "", rec.reason);
if (rec.state == StActive && !rec.answeredAt) rec.answeredAt = NowEpoch();
Log(std::format("{} -> {} ({})", rec.uni, cstate, creason));
EmitCalls("callStateChanged", g_variant_new("(@(a{sv}))", CallData(rec)));
} else if (sig == "CallDeleted") {
const gchar* cuni = nullptr;
g_variant_get(params, "(&s)", &cuni);
if (Calls.erase(cuni ? cuni : "")) {
Log(std::format("call deleted {}", cuni));
EmitCalls("callDeleted", g_variant_new("(ss)", DeviceUni, cuni));
}
} else if (sig == "RegistrationChanged") {
gboolean reg = FALSE;
g_variant_get(params, "(b)", &reg);
Registered = reg;
Log(std::format("registration -> {}", Registered));
EmitDevice("deviceUniListChanged", g_variant_new("(@as)", DeviceList()));
}
}
void OnImsdAppeared(GDBusConnection*, const gchar*, const gchar*, gpointer) {
if (ImsdUp) return;
ImsdUp = true;
Log("imsd up");
RefreshStatus();
EmitDevice("deviceUniListChanged", g_variant_new("(@as)", DeviceList()));
}
void OnImsdVanished(GDBusConnection*, const gchar*, gpointer) {
if (!ImsdUp) return;
ImsdUp = false;
Registered = false;
Log("imsd down");
// drop stale calls; tell the UI
for (auto it = Calls.begin(); it != Calls.end();) {
it->second.state = StTerminated;
it->second.reason = RsError;
EmitCalls("callStateChanged", g_variant_new("(@(a{sv}))", CallData(it->second)));
EmitCalls("callDeleted", g_variant_new("(ss)", DeviceUni, it->first.c_str()));
it = Calls.erase(it);
}
EmitDevice("deviceUniListChanged", g_variant_new("(@as)", DeviceList()));
}
// ---- session bus objects ----------------------------------------------------
constexpr const char* CallsXml = R"xml(
<node>
<interface name="org.kde.telephony.CallUtils">
<method name="formatNumber">
<arg type="s" name="number" direction="in"/>
<arg type="s" name="formatted" direction="out"/>
</method>
<method name="dial">
<arg type="s" name="deviceUni" direction="in"/>
<arg type="s" name="number" direction="in"/>
</method>
<method name="accept">
<arg type="s" name="deviceUni" direction="in"/>
<arg type="s" name="callUni" direction="in"/>
</method>
<method name="hangUp">
<arg type="s" name="deviceUni" direction="in"/>
<arg type="s" name="callUni" direction="in"/>
</method>
<method name="sendDtmf">
<arg type="s" name="deviceUni" direction="in"/>
<arg type="s" name="callUni" direction="in"/>
<arg type="s" name="tones" direction="in"/>
</method>
<method name="fetchCalls"/>
<method name="setCalls"><arg type="a(a{sv})" name="calls" direction="in"/></method>
<method name="addCall">
<arg type="s" name="deviceUni" direction="in"/>
<arg type="s" name="callUni" direction="in"/>
<arg type="(i)" name="direction" direction="in"/>
<arg type="(i)" name="state" direction="in"/>
<arg type="(i)" name="reason" direction="in"/>
<arg type="s" name="communicationWith" direction="in"/>
</method>
<method name="deleteCall">
<arg type="s" name="deviceUni" direction="in"/>
<arg type="s" name="callUni" direction="in"/>
</method>
<method name="setCallState"><arg type="(a{sv})" name="callData" direction="in"/></method>
<signal name="dialed"><arg type="s"/><arg type="s"/></signal>
<signal name="accepted"><arg type="s"/><arg type="s"/></signal>
<signal name="hungUp"><arg type="s"/><arg type="s"/></signal>
<signal name="sentDtmf"><arg type="s"/><arg type="s"/><arg type="s"/></signal>
<signal name="callsRequested"/>
<signal name="callsChanged"><arg type="a(a{sv})"/></signal>
<signal name="callAdded">
<arg type="s"/><arg type="s"/><arg type="(i)"/><arg type="(i)"/>
<arg type="(i)"/><arg type="s"/>
</signal>
<signal name="callDeleted"><arg type="s"/><arg type="s"/></signal>
<signal name="callStateChanged"><arg type="(a{sv})"/></signal>
</interface>
</node>)xml";
constexpr const char* DeviceXml = R"xml(
<node>
<interface name="org.kde.telephony.DeviceUtils">
<method name="deviceUniList"><arg type="as" name="list" direction="out"/></method>
<method name="equipmentIdentifiers"><arg type="as" name="ids" direction="out"/></method>
<method name="setDeviceUniList"><arg type="as" name="list" direction="in"/></method>
<method name="setCountryCode"><arg type="s" name="countryCode" direction="in"/></method>
<signal name="deviceUniListChanged"><arg type="as"/></signal>
<signal name="countryCodeChanged"><arg type="s"/></signal>
</interface>
</node>)xml";
constexpr const char* UssdXml = R"xml(
<node>
<interface name="org.kde.telephony.UssdUtils">
<method name="initiate">
<arg type="s" name="deviceUni" direction="in"/>
<arg type="s" name="command" direction="in"/>
</method>
<method name="respond">
<arg type="s" name="deviceUni" direction="in"/>
<arg type="s" name="reply" direction="in"/>
</method>
<method name="cancel"><arg type="s" name="deviceUni" direction="in"/></method>
<signal name="initiated"><arg type="s"/><arg type="s"/></signal>
<signal name="responded"><arg type="s"/><arg type="s"/></signal>
<signal name="cancelled"><arg type="s"/></signal>
<signal name="notificationReceived"><arg type="s"/><arg type="s"/></signal>
<signal name="stateChanged"><arg type="s"/><arg type="s"/></signal>
<signal name="errorReceived"><arg type="s"/><arg type="s"/></signal>
</interface>
</node>)xml";
void HandleCallsMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) {
std::string_view m = method;
if (m == "formatNumber") {
const gchar* number = nullptr;
g_variant_get(params, "(&s)", &number);
g_dbus_method_invocation_return_value(inv, g_variant_new("(s)", number));
return;
}
if (m == "dial") {
const gchar* dev = nullptr; const gchar* number = nullptr;
g_variant_get(params, "(&s&s)", &dev, &number);
Log(std::format("dial({}, {})", dev, number));
EmitCalls("dialed", g_variant_new("(ss)", dev, number));
CallImsd("Dial", g_variant_new("(s)", number));
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "accept") {
const gchar* dev = nullptr; const gchar* uni = nullptr;
g_variant_get(params, "(&s&s)", &dev, &uni);
std::string target = (uni && *uni) ? uni : ActiveUni();
Log(std::format("accept({})", target));
EmitCalls("accepted", g_variant_new("(ss)", dev, uni));
CallImsd("Accept", g_variant_new("(s)", target.c_str()));
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "hangUp") {
const gchar* dev = nullptr; const gchar* uni = nullptr;
g_variant_get(params, "(&s&s)", &dev, &uni);
std::string target = (uni && *uni) ? uni : ActiveUni();
Log(std::format("hangUp({})", target));
EmitCalls("hungUp", g_variant_new("(ss)", dev, target.c_str()));
CallImsd("HangUp", g_variant_new("(s)", target.c_str()));
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "sendDtmf") {
const gchar* dev = nullptr; const gchar* uni = nullptr;
const gchar* tones = nullptr;
g_variant_get(params, "(&s&s&s)", &dev, &uni, &tones);
std::string target = (uni && *uni) ? uni : ActiveUni();
EmitCalls("sentDtmf", g_variant_new("(sss)", dev, target.c_str(), tones));
CallImsd("SendDtmf", g_variant_new("(ss)", target.c_str(), tones));
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "fetchCalls") {
EmitCalls("callsRequested", nullptr);
EmitCalls("callsChanged", g_variant_new("(@a(a{sv}))", CallVector()));
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
// state-injection methods (interface parity with modem-daemon; the stock
// daemon's CallManager feeds these — nothing calls them in our setup)
if (m == "setCalls") {
EmitCalls("callsChanged", params);
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "addCall") {
EmitCalls("callAdded", params);
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "deleteCall") {
EmitCalls("callDeleted", params);
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "setCallState") {
EmitCalls("callStateChanged", params);
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
}
void HandleDeviceMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) {
std::string_view m = method;
if (m == "deviceUniList") {
g_dbus_method_invocation_return_value(inv, g_variant_new("(@as)", DeviceList()));
return;
}
if (m == "equipmentIdentifiers") {
GVariantBuilder b;
g_variant_builder_init(&b, G_VARIANT_TYPE("as"));
g_variant_builder_add(&b, "s", "FP6-userspace-IMS");
g_dbus_method_invocation_return_value(inv, g_variant_new("(as)", &b));
return;
}
if (m == "setDeviceUniList") {
EmitDevice("deviceUniListChanged", params);
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "setCountryCode") {
EmitDevice("countryCodeChanged", params);
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
}
void HandleUssdMethod(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) {
std::string_view m = method;
if (m == "initiate") {
const gchar* dev = nullptr; const gchar* cmd = nullptr;
g_variant_get(params, "(&s&s)", &dev, &cmd);
Log(std::format("USSD not supported ({})", cmd));
EmitUssd("errorReceived", g_variant_new("(ss)", dev, "USSD is not supported on the IMS stack"));
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
if (m == "respond" || m == "cancel") {
g_dbus_method_invocation_return_value(inv, nullptr);
return;
}
g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method");
}
const GDBusInterfaceVTable CallsVtable = { HandleCallsMethod, nullptr, nullptr, {} };
const GDBusInterfaceVTable DeviceVtable = { HandleDeviceMethod, nullptr, nullptr, {} };
const GDBusInterfaceVTable UssdVtable = { HandleUssdMethod, nullptr, nullptr, {} };
void RegisterObject(GDBusConnection* conn, const char* xml, const char* path, const GDBusInterfaceVTable* vtable) {
GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(xml, nullptr);
g_dbus_connection_register_object(conn, path, node->interfaces[0], vtable, nullptr, nullptr, nullptr);
g_dbus_node_info_unref(node);
}
gboolean OnTerm(gpointer loop) {
g_main_loop_quit(static_cast<GMainLoop*>(loop));
return G_SOURCE_REMOVE;
}
} // namespace
int main() {
GError* err = nullptr;
Session = g_bus_get_sync(G_BUS_TYPE_SESSION, nullptr, &err);
if (!Session) {
std::println(std::cerr, "imsd-dialerd: no session bus: {}", err ? err->message : "?");
return 1;
}
System = g_bus_get_sync(G_BUS_TYPE_SYSTEM, nullptr, &err);
if (!System) {
std::println(std::cerr, "imsd-dialerd: no system bus: {}", err ? err->message : "?");
return 1;
}
RegisterObject(Session, CallsXml, CallsPath, &CallsVtable);
RegisterObject(Session, DeviceXml, DevicePath, &DeviceVtable);
RegisterObject(Session, UssdXml, UssdPath, &UssdVtable);
Loop = g_main_loop_new(nullptr, FALSE);
// claim the three names the stock modem-daemon owns; DO_NOT_QUEUE so a
// still-running modem-daemon makes us fail loudly instead of queueing
for (const char* name : {CallsIface, DeviceIface, UssdIface}) {
guint id = g_bus_own_name_on_connection(
Session, name, G_BUS_NAME_OWNER_FLAGS_DO_NOT_QUEUE, nullptr,
[](GDBusConnection*, const gchar* n, gpointer lp) {
Log(std::format("lost {} — exiting", n));
g_main_loop_quit(static_cast<GMainLoop*>(lp));
},
Loop, nullptr);
(void)id;
}
g_dbus_connection_signal_subscribe(System, ImsdBus, ImsdIface, nullptr, ImsdPath, nullptr, G_DBUS_SIGNAL_FLAGS_NONE, OnImsdSignal, nullptr, nullptr);
g_bus_watch_name_on_connection(System, ImsdBus, G_BUS_NAME_WATCHER_FLAGS_NONE, OnImsdAppeared, OnImsdVanished, nullptr, nullptr);
g_unix_signal_add(SIGTERM, OnTerm, Loop);
g_unix_signal_add(SIGINT, OnTerm, Loop);
Log("up (org.kde.telephony.{CallUtils,DeviceUtils,UssdUtils})");
g_main_loop_run(Loop);
g_main_loop_unref(Loop);
return 0;
}

1451
implementations/main.cpp Normal file

File diff suppressed because it is too large Load diff

665
implementations/media.cpp Normal file
View file

@ -0,0 +1,665 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types no-char-pointer
/*
imsd-media the RTP/AMR-WB media leg for a userspace VoLTE call, spawned as
its own process by the daemon (imsd) exactly as the Python prototype spawned
rtpcap.py: one media leg per call, argv-configured, torn down on SIGTERM or
when the downlink dries up. Keeping it a separate process preserves the
far-end-hangup contract (exit code 3 = downlink RTP stopped, which on carriers
whose network BYE never reaches our SAs is the reliable teardown trigger) and
isolates a media crash from the control-plane daemon.
Binds the advertised local RTP port, sends uplink octet-aligned AMR-WB frames
toward the media gateway, captures the downlink, and with PLAY=1
reconstructs the media clock from RTP timestamps so pw-play stays real-time-
paced through far-end DTX silence (every missing 20 ms slot is decoded as an
FT-15 NO_DATA frame for CNG/PLC). MIC=1 feeds live pw-record audio through
libvo-amrwbenc; downlink decode is libopencore-amrwb. Both codecs are dlopen'd
so the binary has no link-time dependency on them.
Usage: imsd-media <local-ip> <rtp-port> <remote-ip> <remote-port> <pt> <secs> <out-base>
imsd-media --selftest (payload pack/depay roundtrip, both formats)
Env: MIC PLAY GAIN PLAY_GAIN AMR_MODE DTX MEDIA_TIMEOUT RTP_DUMP OCTET_ALIGN
AUDIO_USER
(OCTET_ALIGN=0 selects RFC 4867 bandwidth-efficient payloads both ways; the
daemon sets it from the negotiated SDP 1 is the default/legacy behavior.)
*/
#include <arpa/inet.h>
#include <dlfcn.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <pwd.h>
#include <signal.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>
import std;
namespace {
using Clock = std::chrono::steady_clock;
double Now() {
return std::chrono::duration<double>(Clock::now().time_since_epoch()).count();
}
// octet-aligned AMR-WB speech-frame byte sizes by frame type (mode).
constexpr int AmrwbBytes(int ft) {
switch (ft) {
case 0: return 17; case 1: return 23; case 2: return 32; case 3: return 36;
case 4: return 40; case 5: return 46; case 6: return 50; case 7: return 58;
case 8: return 60; case 9: return 5; default: return 0;
}
}
// AMR-WB speech bits per frame type (RFC 4867 table 2 / TS 26.201) — the
// exact payload bit counts of the bandwidth-efficient format. FT 14/15
// (SPEECH_LOST/NO_DATA) carry 0 bits; unknown FTs return -1 so a corrupt
// ToC aborts the packet instead of shifting every later bit.
constexpr int AmrwbBits(int ft) {
switch (ft) {
case 0: return 132; case 1: return 177; case 2: return 253; case 3: return 285;
case 4: return 317; case 5: return 365; case 6: return 397; case 7: return 461;
case 8: return 477; case 9: return 40; case 14: return 0; case 15: return 0;
default: return -1;
}
}
std::string EnvOr(const char* k, const char* d) {
const char* v = std::getenv(k);
return v ? std::string(v) : std::string(d);
}
bool EnvBool(const char* k, bool d) {
const char* v = std::getenv(k);
return v ? std::string_view(v) == "1" : d;
}
bool Is6(std::string_view a) { return a.contains(':'); }
// Fill a sockaddr_storage from an IP literal + port; returns its length.
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);
}
// ---- AMR-WB codecs via dlopen (vo-amrwbenc E_IF_*, opencore-amrwb D_IF_*) --
class Encoder {
public:
bool Open() {
lib_ = dlopen("libvo-amrwbenc.so.0", RTLD_NOW);
if (!lib_) return false;
init_ = reinterpret_cast<InitFn>(dlsym(lib_, "E_IF_init"));
enc_ = reinterpret_cast<EncFn>(dlsym(lib_, "E_IF_encode"));
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "E_IF_exit"));
if (!init_ || !enc_ || !exit_) return false;
st_ = init_();
return st_ != nullptr;
}
// 320 int16 samples -> one RFC 3267 storage frame (header byte + speech).
std::vector<std::uint8_t> Encode(std::int16_t* samples, int mode, int dtx) {
std::uint8_t out[128];
int n = enc_(st_, static_cast<std::int16_t>(mode), samples, out, static_cast<std::int16_t>(dtx));
if (n <= 0) return {};
return std::vector<std::uint8_t>(out, out + n);
}
~Encoder() { if (st_ && exit_) exit_(st_); if (lib_) dlclose(lib_); }
private:
using InitFn = void* (*)();
using EncFn = int (*)(void*, std::int16_t, std::int16_t*, std::uint8_t*, std::int16_t);
using ExitFn = void (*)(void*);
void* lib_ = nullptr; void* st_ = nullptr;
InitFn init_ = nullptr; EncFn enc_ = nullptr; ExitFn exit_ = nullptr;
};
class Decoder {
public:
bool Open() {
lib_ = dlopen("libopencore-amrwb.so.0", RTLD_NOW);
if (!lib_) return false;
init_ = reinterpret_cast<InitFn>(dlsym(lib_, "D_IF_init"));
dec_ = reinterpret_cast<DecFn>(dlsym(lib_, "D_IF_decode"));
exit_ = reinterpret_cast<ExitFn>(dlsym(lib_, "D_IF_exit"));
if (!init_ || !dec_ || !exit_) return false;
st_ = init_();
return st_ != nullptr;
}
// one storage frame ([header][speech]) -> 640 bytes of 16 kHz s16 PCM.
std::array<std::int16_t, 320> Decode(const std::uint8_t* frame, int len) {
std::array<std::int16_t, 320> out{};
std::vector<std::uint8_t> in(frame, frame + len);
dec_(st_, in.data(), out.data(), 0);
return out;
}
~Decoder() { if (st_ && exit_) exit_(st_); if (lib_) dlclose(lib_); }
private:
using InitFn = void* (*)();
using DecFn = void (*)(void*, std::uint8_t*, std::int16_t*, int);
using ExitFn = void (*)(void*);
void* lib_ = nullptr; void* st_ = nullptr;
InitFn init_ = nullptr; DecFn dec_ = nullptr; ExitFn exit_ = nullptr;
};
// MSB-first bit cursor over an RTP payload (bandwidth-efficient AMR-WB is
// bit-packed: 4-bit CMR, 6-bit ToC entries, then the speech bits back to
// back with only the final octet padded — RFC 4867 §4.3).
struct BitReader {
std::span<const std::uint8_t> d;
std::size_t pos = 0;
bool Ok(std::size_t n) const { return pos + n <= d.size() * 8; }
std::uint32_t Take(int n) {
std::uint32_t v = 0;
for (int i = 0; i < n; i++, pos++)
v = (v << 1) | ((d[pos >> 3] >> (7 - (pos & 7))) & 1);
return v;
}
};
// bandwidth-efficient AMR-WB de-payload. Same contract as DepayOctet:
// [(storage-header-byte, speech-bytes)...], speech re-aligned to octets.
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>>
DepayBe(std::span<const std::uint8_t> pl) {
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> out;
BitReader br{pl};
if (!br.Ok(4)) return out;
br.Take(4); // CMR
struct Toc { int ft; int q; };
std::vector<Toc> tocs;
for (;;) {
if (!br.Ok(6)) return out;
int f = static_cast<int>(br.Take(1));
int ft = static_cast<int>(br.Take(4));
int q = static_cast<int>(br.Take(1));
tocs.push_back({ft, q});
if (!f) break;
}
for (auto [ft, q] : tocs) {
int bits = AmrwbBits(ft);
if (bits < 0 || !br.Ok(static_cast<std::size_t>(bits))) break;
std::vector<std::uint8_t> speech(AmrwbBytes(ft), 0);
for (int i = 0; i < bits; i++)
if (br.Take(1)) speech[i >> 3] |= 0x80 >> (i & 7);
out.emplace_back(static_cast<std::uint8_t>((ft << 3) | (q ? 0x04 : 0)), std::move(speech));
}
return out;
}
// storage-format frame (header byte + octet-aligned speech) -> RTP payload.
// Octet-aligned: CMR byte + the frame verbatim (the storage header doubles
// as a ToC byte with F=0). Bandwidth-efficient: 10 header bits + exactly
// AmrwbBits(ft) speech bits, final octet zero-padded.
std::vector<std::uint8_t> PayloadFromFrame(std::span<const std::uint8_t> frame, bool octetAlign) {
if (octetAlign) {
std::vector<std::uint8_t> pl = {0xF0};
pl.insert(pl.end(), frame.begin(), frame.end());
return pl;
}
int ft = (frame[0] >> 3) & 0x0F;
int q = (frame[0] >> 2) & 1;
int bits = AmrwbBits(ft);
if (bits < 0) bits = 0;
std::vector<std::uint8_t> pl((10 + bits + 7) / 8, 0);
auto put = [&](int pos, int n, std::uint32_t v) {
for (int i = 0; i < n; i++)
if ((v >> (n - 1 - i)) & 1) pl[(pos + i) >> 3] |= 0x80 >> ((pos + i) & 7);
};
put(0, 4, 15); // CMR: no mode request
put(4, 1, 0); // F: single frame
put(5, 4, static_cast<std::uint32_t>(ft));
put(9, 1, static_cast<std::uint32_t>(q));
for (int i = 0; i < bits; i++)
if (frame[1 + (i >> 3)] & (0x80 >> (i & 7))) pl[(10 + i) >> 3] |= 0x80 >> ((10 + i) & 7);
return pl;
}
// octet-aligned AMR-WB de-payload: skip CMR, read ToC bytes until F=0, then
// the speech runs. Returns [(storage-header-byte, speech-bytes)...].
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>>
DepayOctet(std::span<const std::uint8_t> pl) {
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>> out;
if (pl.empty()) return out;
std::size_t i = 1; // skip CMR
std::vector<std::uint8_t> tocs;
while (i < pl.size()) {
std::uint8_t toc = pl[i++];
tocs.push_back(toc);
if (!(toc & 0x80)) break;
}
for (std::uint8_t toc : tocs) {
int ft = (toc >> 3) & 0x0F;
int n = AmrwbBytes(ft);
std::vector<std::uint8_t> speech;
if (n > 0 && i + n <= pl.size()) speech.assign(pl.begin() + i, pl.begin() + i + n);
out.emplace_back(static_cast<std::uint8_t>(toc & 0x7C), std::move(speech));
i += n;
}
return out;
}
std::size_t RtpPayloadOffset(std::span<const std::uint8_t> pkt) {
int cc = pkt[0] & 0x0F;
int ext = (pkt[0] >> 4) & 1;
std::size_t off = 12 + cc * 4;
if (ext && off + 4 <= pkt.size()) {
std::uint16_t extlen = (pkt[off + 2] << 8) | pkt[off + 3];
off += 4 + extlen * 4;
}
return off;
}
// Spawn pw-record/pw-play in the desktop user's PipeWire session (sudo -u
// when run as root). AUDIO_USER names the session owner; the default "user"
// is postmarketOS's standard account. `toChild` true = we write the child's
// stdin (pw-play); false = we read its stdout (pw-record). Returns {pid, fd}.
struct Child { pid_t pid = -1; int fd = -1; };
Child SpawnPw(bool play, bool toChild) {
int pipefd[2];
if (pipe(pipefd) != 0) return {};
std::vector<std::string> argv;
if (geteuid() == 0) {
std::string user = EnvOr("AUDIO_USER", "user");
uid_t uid = 0;
if (passwd* pw = getpwnam(user.c_str())) uid = pw->pw_uid;
argv = {"sudo", "-u", user, "env",
std::format("XDG_RUNTIME_DIR=/run/user/{}", uid)};
}
const char* tool = play ? "pw-play" : "pw-record";
const char* lat = play ? "40ms" : "20ms";
for (const char* a : {tool, "--raw", "--rate", "16000", "--channels", "1", "--format", "s16", "--latency", lat, "-"})
argv.emplace_back(a);
pid_t pid = fork();
if (pid == 0) {
if (toChild) { dup2(pipefd[0], STDIN_FILENO); }
else { dup2(pipefd[1], STDOUT_FILENO); }
close(pipefd[0]); close(pipefd[1]);
int devnull = open("/dev/null", O_WRONLY);
if (devnull >= 0) { dup2(devnull, STDERR_FILENO); close(devnull); }
std::vector<char*> cargv;
for (auto& s : argv) cargv.push_back(const_cast<char*>(s.c_str()));
cargv.push_back(nullptr);
execvp(cargv[0], cargv.data());
_exit(127);
}
if (pid < 0) { close(pipefd[0]); close(pipefd[1]); return {}; }
if (toChild) { close(pipefd[0]); return {pid, pipefd[1]}; }
close(pipefd[1]);
return {pid, pipefd[0]};
}
bool ReadExact(int fd, std::uint8_t* buf, std::size_t n) {
std::size_t got = 0;
while (got < n) {
ssize_t r = read(fd, buf + got, n - got);
if (r <= 0) return false;
got += static_cast<std::size_t>(r);
}
return true;
}
// ---- shared RTP tx state (main + mic threads both send) -------------------
struct TxState {
std::mutex lock;
int pt = 0;
std::uint32_t ssrc = 0x5EED1234;
std::uint32_t seq = 1000;
std::uint32_t ts = 160000;
sockaddr_storage dst{}; socklen_t dstLen = 0;
sockaddr_storage latched{}; socklen_t latchedLen = 0;
std::uint64_t tx = 0;
std::uint64_t txMic = 0;
std::atomic<bool> micOn{false};
};
void RtpSend(int sock, TxState& st, std::span<const std::uint8_t> payload) {
std::uint8_t hdr[12];
sockaddr_storage a1, a2; socklen_t l1, l2;
{
std::scoped_lock g(st.lock);
hdr[0] = 0x80;
hdr[1] = static_cast<std::uint8_t>(st.pt & 0x7F);
hdr[2] = (st.seq >> 8) & 0xFF; hdr[3] = st.seq & 0xFF;
hdr[4] = (st.ts >> 24) & 0xFF; hdr[5] = (st.ts >> 16) & 0xFF;
hdr[6] = (st.ts >> 8) & 0xFF; hdr[7] = st.ts & 0xFF;
hdr[8] = (st.ssrc >> 24) & 0xFF; hdr[9] = (st.ssrc >> 16) & 0xFF;
hdr[10] = (st.ssrc >> 8) & 0xFF; hdr[11] = st.ssrc & 0xFF;
st.seq = (st.seq + 1) & 0xFFFF;
st.ts = (st.ts + 320) & 0xFFFFFFFF;
a1 = st.dst; l1 = st.dstLen; a2 = st.latched; l2 = st.latchedLen;
}
std::vector<std::uint8_t> pkt(hdr, hdr + 12);
pkt.insert(pkt.end(), payload.begin(), payload.end());
auto sendTo = [&](sockaddr_storage& a, socklen_t l) {
if (l && sendto(sock, pkt.data(), pkt.size(), 0, reinterpret_cast<sockaddr*>(&a), l) >= 0) {
std::scoped_lock g(st.lock);
st.tx++;
}
};
sendTo(a1, l1);
// avoid double-send when latched == dst
if (l2 && (l2 != l1 || std::memcmp(&a1, &a2, l1) != 0)) sendTo(a2, l2);
}
std::vector<std::pair<std::uint8_t, std::vector<std::uint8_t>>>
Depay(std::span<const std::uint8_t> pl, bool octetAlign) {
return octetAlign ? DepayOctet(pl) : DepayBe(pl);
}
std::vector<std::uint8_t> SilenceFrame(int ft, bool octetAlign) {
// storage frame: ToC(F=0,FT,Q=1) + zeroed speech, payloaded per mode.
std::vector<std::uint8_t> f = {static_cast<std::uint8_t>((ft << 3) | 0x04)};
f.resize(1 + AmrwbBytes(ft), 0);
return PayloadFromFrame(f, octetAlign);
}
std::atomic<bool> Quit{false};
void OnSig(int) { Quit.store(true); }
// playout queue item
struct PktItem { std::uint32_t ts; std::vector<std::uint8_t> payload; };
} // namespace
int main(int argc, char** argv) {
if (argc == 2 && std::string_view(argv[1]) == "--selftest") {
// pack->depay roundtrip of every frame type, both payload formats.
// BE carries exactly AmrwbBits(ft) bits, so the pattern's padding
// bits in the last speech byte must be zero for equality to hold.
for (int ft : {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}) {
std::vector<std::uint8_t> frame = {
static_cast<std::uint8_t>((ft << 3) | 0x04)};
int bits = AmrwbBits(ft);
for (int i = 0; i < AmrwbBytes(ft); i++)
frame.push_back(static_cast<std::uint8_t>(0xA5 + i * 31));
if (bits % 8) frame.back() &= static_cast<std::uint8_t>(0xFF << (8 - bits % 8));
for (bool oa : {true, false}) {
auto got = Depay(PayloadFromFrame(frame, oa), oa);
if (got.size() != 1 || got[0].first != frame[0] || !std::equal(got[0].second.begin(), got[0].second.end(), frame.begin() + 1, frame.end())) {
std::println(std::cerr, "selftest FAIL ft={} oa={}", ft, oa);
return 1;
}
}
}
std::println("selftest OK");
return 0;
}
if (argc < 8) {
std::println(std::cerr, "usage: imsd-media local rtp_port remote_ip remote_port pt secs out");
return 2;
}
std::string local = argv[1];
int rtpPort = std::atoi(argv[2]);
std::string rIp = argv[3];
int rPort = std::atoi(argv[4]);
int pt = std::atoi(argv[5]);
double secs = std::atof(argv[6]);
std::string out = argv[7];
bool mic = EnvBool("MIC", false);
bool play = EnvBool("PLAY", false);
int amrMode = std::atoi(EnvOr("AMR_MODE", "2").c_str());
double gain = std::atof(EnvOr("GAIN", "1.0").c_str());
double playGain = std::atof(EnvOr("PLAY_GAIN", "1.0").c_str());
int dtx = EnvBool("DTX", false) ? 1 : 0;
bool octetAlign = EnvBool("OCTET_ALIGN", true);
double mediaTimeout = std::atof(EnvOr("MEDIA_TIMEOUT", "6.0").c_str());
bool rtpDump = EnvBool("RTP_DUMP", false);
constexpr int ExitMediaTimeout = 3;
constexpr int PrimeFrames = 8;
constexpr int MaxFill = 25;
constexpr std::size_t PlayqMax = 256;
int sock = socket(Is6(local) ? AF_INET6 : AF_INET, SOCK_DGRAM, 0);
if (sock < 0) { std::println(std::cerr, "imsd-media: socket failed"); return 1; }
int one = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
sockaddr_storage bindA; socklen_t bindL = MakeAddr(local, rtpPort, bindA);
if (bind(sock, reinterpret_cast<sockaddr*>(&bindA), bindL) != 0) {
std::println(std::cerr, "imsd-media: bind [{}]:{} failed", local, rtpPort);
return 1;
}
timeval tv{0, 20000}; // 20 ms recv timeout
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv);
TxState st;
st.pt = pt;
st.dstLen = MakeAddr(rIp, rPort, st.dst);
st.latched = st.dst; st.latchedLen = st.dstLen;
signal(SIGTERM, OnSig);
signal(SIGINT, OnSig);
signal(SIGPIPE, SIG_IGN);
// ---- mic uplink thread
std::atomic<bool> stop{false};
std::jthread micThread;
if (mic) {
micThread = std::jthread([&] {
Encoder enc;
if (!enc.Open()) {
std::println(std::cerr, "imsd-media: mic: encoder unavailable; silence fallback");
return;
}
Child rec = SpawnPw(false, /*toChild=*/false);
if (rec.pid < 0) return;
std::uint8_t raw[640];
while (!stop.load()) {
if (!ReadExact(rec.fd, raw, 640)) {
std::println(std::cerr, "imsd-media: mic: pw-record EOF; silence fallback");
break;
}
std::int16_t samples[320];
std::memcpy(samples, raw, 640);
if (gain != 1.0)
for (int i = 0; i < 320; i++) {
int v = static_cast<int>(samples[i] * gain);
samples[i] = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
}
std::vector<std::uint8_t> frame = enc.Encode(samples, amrMode, dtx);
if (frame.empty()) continue;
st.micOn.store(true);
{ std::scoped_lock g(st.lock); st.txMic++; }
RtpSend(sock, st, PayloadFromFrame(frame, octetAlign));
}
st.micOn.store(false);
close(rec.fd);
kill(rec.pid, SIGKILL);
waitpid(rec.pid, nullptr, 0);
});
}
// ---- downlink playout (decode + RTP-timestamp clock reconstruction)
Decoder dec;
Child playCh;
bool playOn = false;
if (play) {
if (dec.Open()) {
playCh = SpawnPw(true, /*toChild=*/true);
playOn = playCh.pid >= 0;
} else {
std::println(std::cerr, "imsd-media: play: decoder unavailable; capture only");
}
}
std::mutex qlock;
std::condition_variable qcv;
std::deque<PktItem> playq;
std::uint64_t rxPlayed = 0;
std::uint64_t cng = 0;
std::uint64_t late = 0;
std::uint64_t qdrop = 0;
std::jthread playThread;
if (playOn) {
playThread = std::jthread([&] {
auto writePcm = [&](std::span<const std::int16_t> pcm) {
std::size_t bytes = pcm.size() * 2;
const char* p = reinterpret_cast<const char*>(pcm.data());
std::size_t off = 0;
while (off < bytes) {
ssize_t w = write(playCh.fd, p + off, bytes - off);
if (w <= 0) return false;
off += static_cast<std::size_t>(w);
}
return true;
};
auto applyGain = [&](std::array<std::int16_t, 320>& pcm) {
if (playGain == 1.0) return;
for (auto& s : pcm) {
int v = static_cast<int>(s * playGain);
s = static_cast<std::int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
}
};
std::array<std::int16_t, 320> zero{};
std::optional<std::uint32_t> expect;
for (;;) {
PktItem item;
{
std::unique_lock g(qlock);
qcv.wait(g, [&] { return !playq.empty() || stop.load(); });
if (playq.empty()) return;
item = std::move(playq.front());
playq.pop_front();
}
auto frames = Depay(item.payload, octetAlign);
if (frames.empty()) continue;
int fill = 0;
if (!expect) {
for (int i = 0; i < PrimeFrames; i++)
if (!writePcm(zero)) return;
} else {
std::uint32_t diff = (item.ts - *expect) & 0xFFFFFFFF;
if (diff >= 0x80000000u) { late++; continue; }
fill = static_cast<int>(diff / 320);
if (fill > MaxFill) fill = 0;
}
for (int i = 0; i < fill; i++) {
cng++;
std::uint8_t nodata = 0x7C;
auto pcm = dec.Decode(&nodata, 1);
applyGain(pcm);
if (!writePcm(pcm)) return;
}
for (auto& [hdr, speech] : frames) {
rxPlayed++;
std::vector<std::uint8_t> f = {hdr};
f.insert(f.end(), speech.begin(), speech.end());
auto pcm = dec.Decode(f.data(), static_cast<int>(f.size()));
applyGain(pcm);
if (!writePcm(pcm)) return;
}
expect = (item.ts + 320 * static_cast<std::uint32_t>(frames.size())) & 0xFFFFFFFF;
}
});
}
// ---- main recv loop
std::vector<std::uint8_t> silence = SilenceFrame(0, octetAlign);
for (int i = 0; i < 5; i++) RtpSend(sock, st, silence); // latch burst
std::FILE* dump = rtpDump ? std::fopen((std::format("{}.rtp", out)).c_str(), "wb") : nullptr;
double t0 = Now(), lastTx = 0, lastRx = Now();
std::uint64_t rx = 0;
std::uint64_t rxBytes = 0;
bool gotMedia = false;
bool mediaEnded = false;
std::string firstSrc;
while (Now() - t0 < secs && !Quit.load()) {
double now = Now();
if (mediaTimeout > 0 && gotMedia && now - lastRx > mediaTimeout) {
mediaEnded = true;
break;
}
if (now - lastTx >= 0.02 && !st.micOn.load()) {
RtpSend(sock, st, silence);
lastTx = now;
}
std::uint8_t buf[65535];
sockaddr_storage src; socklen_t srcLen = sizeof src;
ssize_t n = recvfrom(sock, buf, sizeof buf, 0, reinterpret_cast<sockaddr*>(&src), &srcLen);
if (n <= 0) continue;
lastRx = Now();
gotMedia = true;
if (firstSrc.empty()) {
char host[INET6_ADDRSTRLEN] = {};
int port = 0;
if (src.ss_family == AF_INET6) {
auto* a = reinterpret_cast<sockaddr_in6*>(&src);
inet_ntop(AF_INET6, &a->sin6_addr, host, sizeof host);
port = ntohs(a->sin6_port);
} else {
auto* a = reinterpret_cast<sockaddr_in*>(&src);
inet_ntop(AF_INET, &a->sin_addr, host, sizeof host);
port = ntohs(a->sin_port);
}
firstSrc = std::format("{}:{}", host, port);
std::scoped_lock g(st.lock);
std::memcpy(&st.latched, &src, srcLen);
st.latchedLen = srcLen; // relatch to the actual media source
}
rx++;
rxBytes += static_cast<std::uint64_t>(n);
if (dump) {
std::uint32_t len = static_cast<std::uint32_t>(n);
std::uint8_t lb[4] = {static_cast<std::uint8_t>((len >> 24) & 0xFF),
static_cast<std::uint8_t>((len >> 16) & 0xFF),
static_cast<std::uint8_t>((len >> 8) & 0xFF),
static_cast<std::uint8_t>(len & 0xFF)};
std::fwrite(lb, 1, 4, dump);
std::fwrite(buf, 1, static_cast<std::size_t>(n), dump);
}
if (playThread.joinable() && n >= 12 && (buf[1] & 0x7F) == pt) {
std::size_t off = RtpPayloadOffset(std::span(buf, static_cast<std::size_t>(n)));
std::uint32_t pktTs = (static_cast<std::uint32_t>(buf[4]) << 24) |
(buf[5] << 16) | (buf[6] << 8) | buf[7];
std::scoped_lock g(qlock);
if (playq.size() >= PlayqMax) { playq.pop_front(); qdrop++; }
playq.push_back({pktTs, std::vector<std::uint8_t>(buf + off, buf + n)});
qcv.notify_one();
}
}
stop.store(true);
qcv.notify_all();
if (micThread.joinable()) micThread.join();
if (playThread.joinable()) playThread.join();
if (dump) std::fclose(dump);
if (playOn) {
close(playCh.fd);
int status;
for (int i = 0; i < 20; i++) {
if (waitpid(playCh.pid, &status, WNOHANG) != 0) break;
usleep(100000);
}
kill(playCh.pid, SIGKILL);
waitpid(playCh.pid, nullptr, 0);
}
close(sock);
// .stats sidecar (tiny; always written)
if (std::FILE* sf = std::fopen((std::format("{}.stats", out)).c_str(), "w")) {
std::print(sf,
"{{\"tx\": {}, \"tx_mic\": {}, \"rx\": {}, \"rx_bytes\": {}, "
"\"rx_played\": {}, \"cng\": {}, \"late\": {}, \"qdrop\": {}, "
"\"first_src\": \"{}\", \"dst\": \"{}:{}\", \"pt\": {}, \"mic\": {}, "
"\"play\": {}, \"amr_mode\": {}, \"media_ended\": {}}}",
st.tx, st.txMic, rx, rxBytes, rxPlayed, cng, late, qdrop, firstSrc,
rIp, rPort, pt, mic, play, amrMode, mediaEnded);
std::fclose(sf);
}
std::println("imsd-media: tx={} tx_mic={} rx={} rx_played={} cng={} late={} " "qdrop={} rx_bytes={} first_src={} media_ended={}", st.tx, st.txMic, rx, rxPlayed, cng, late, qdrop, rxBytes, firstSrc, mediaEnded);
return mediaEnded ? ExitMediaTimeout : 0;
}

297
interfaces/Imsd-Aka.cppm Normal file
View file

@ -0,0 +1,297 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Aka — USIM AKA (3GPP TS 33.102) glue for IMS registration:
* the AKAv1-MD5 HTTP-digest response (RFC 3310) the protected REGISTER
carries in its Authorization header — MD5 over the binary RES;
* the home-network identity (IMPI/IMPU/registration URI/realm) derived
from IMSI + MCC/MNC;
* text parsers for the SIM facts the daemon shell scrapes from qmicli
(`--uim-get-card-status`: which slot holds a ready USIM and its AID) and
ModemManager (`mmcli`: the primary SIM path, then IMSI + operator id);
* the ISO 7816 APDU for the AUTHENTICATE(AKA) command and the parser for
its 0xDB success response (RES / CK / IK TLV).
All pure std C++: the actual card I/O (open channel, transmit APDU) is the
shell's job — this module only shapes the bytes and reads the answers back.
Digest and parser behavior is pinned by tests/Aka.
*/
export module Imsd:Aka;
import std;
import :Util;
export namespace imsd::aka {
// The home-network identity a USIM registers under.
struct Identity {
std::string imsi;
std::string mcc;
std::string mnc; // already zero-padded to 3 digits
std::string domain; // ims.mncXXX.mccYYY.3gppnetwork.org
std::string impi; // <imsi>@<domain>
std::string impu; // sip:<imsi>@<domain>
std::string regUri; // sip:<domain>
};
// Build the identity from IMSI + MCC + a (2- or 3-digit) MNC. The MNC is
// zero-padded to 3 digits, matching the Python detect_sim()/mnc.zfill(3)
// — KPN's operator id 20408 becomes mnc008 in the domain.
Identity MakeIdentity(std::string_view imsi, std::string_view mcc, std::string_view mnc) {
std::string mnc3(mnc);
while (mnc3.size() < 3)
mnc3.insert(mnc3.begin(), '0');
Identity id;
id.imsi = imsi;
id.mcc = mcc;
id.mnc = mnc3;
id.domain = std::format("ims.mnc{}.mcc{}.3gppnetwork.org", mnc3, mcc);
id.impi = std::format("{}@{}", imsi, id.domain);
id.impu = std::format("sip:{}@{}", imsi, id.domain);
id.regUri = std::format("sip:{}", id.domain);
return id;
}
// AKAv1-MD5 digest-response (RFC 3310 §3.2): HA1 = MD5(IMPI:realm:RES)
// where RES is the *binary* AKA result, HA2 = MD5(method:uri), and the
// response = MD5(HA1:nonce:nc:cnonce:qop:HA2). realm is the home domain.
std::string DigestAkav1(std::string_view nonce,
std::span<const std::uint8_t> res,
std::string_view cnonce, std::string_view uri,
std::string_view impi, std::string_view realm,
std::string_view nc = "00000001",
std::string_view qop = "auth",
std::string_view method = "REGISTER") {
imsd::util::Md5 h1;
h1.Update(impi);
h1.Update(":");
h1.Update(realm);
h1.Update(":");
h1.Update(res);
std::string ha1 = imsd::util::ToHex(h1.Digest());
std::string ha2 = imsd::util::Md5::HexDigest(std::format("{}:{}", method, uri));
return imsd::util::Md5::HexDigest(std::format("{}:{}:{}:{}:{}:{}", ha1, nonce, nc, cnonce, qop, ha2));
}
// Which USIM the modem should authenticate against.
struct SimSelection {
int slot = 0;
std::string aid; // uppercase, colons stripped
};
// Parse qmicli `--uim-get-card-status`: the first ready USIM application,
// its slot number, and its Application ID (the hex line under
// "Application ID:"). nullopt if no ready USIM is present. Mirrors the
// Python detect_sim() scan exactly.
std::optional<SimSelection> ParseCardStatus(std::string_view text) {
int curSlot = 0;
bool inUsim = false;
bool ready = false;
std::optional<SimSelection> out;
std::size_t pos = 0;
auto isAidLine = [](std::string_view s) {
// trimmed line is HH(:HH)+ — two-or-more colon-separated hex pairs
std::size_t i = 0;
int pairs = 0;
auto hex = [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') ||
(c >= 'A' && c <= 'F');
};
while (i < s.size()) {
if (i + 2 > s.size() || !hex(s[i]) || !hex(s[i + 1])) return false;
i += 2;
pairs++;
if (i == s.size()) break;
if (s[i] != ':') return false;
i++;
}
return pairs >= 2;
};
while (pos <= text.size()) {
std::size_t nl = text.find('\n', pos);
std::string_view line = text.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
std::string_view t = line;
while (!t.empty() && (t.front() == ' ' || t.front() == '\t'))
t.remove_prefix(1);
while (!t.empty() && (t.back() == ' ' || t.back() == '\t' || t.back() == '\r'))
t.remove_suffix(1);
if (t.starts_with("Slot [")) {
std::size_t lb = t.find('[');
std::size_t rb = t.find(']', lb);
if (lb != std::string_view::npos && rb != std::string_view::npos) {
int v = 0;
std::from_chars(t.data() + lb + 1, t.data() + rb, v);
curSlot = v;
}
inUsim = ready = false;
} else if (t.contains("Application type") && t.contains("usim")) {
inUsim = true;
} else if (inUsim && t.contains("Application state") && t.contains("ready")) {
ready = true;
} else if (inUsim && isAidLine(t)) {
if (ready && !out) {
std::string aid;
for (char c : t)
if (c != ':') aid.push_back(static_cast<char>(std::toupper(static_cast<unsigned char>(c))));
out = SimSelection{curSlot, aid};
}
inUsim = false;
}
if (nl == std::string_view::npos) break;
pos = nl + 1;
}
return out;
}
// "primary sim path: <path>" from `mmcli -m any`.
std::optional<std::string> ParsePrimarySimPath(std::string_view text) {
std::size_t at = text.find("primary sim path:");
if (at == std::string_view::npos) return std::nullopt;
std::string_view rest = text.substr(at + std::string_view("primary sim path:").size());
std::size_t nl = rest.find('\n');
std::string_view line = rest.substr(0, nl == std::string_view::npos ? rest.size() : nl);
while (!line.empty() && (line.front() == ' ' || line.front() == '\t'))
line.remove_prefix(1);
while (!line.empty() && (line.back() == ' ' || line.back() == '\t' || line.back() == '\r'))
line.remove_suffix(1);
if (line.empty()) return std::nullopt;
return std::string(line);
}
struct SimInfo {
std::string imsi;
std::string mcc;
std::string mnc; // as printed (operator id minus mcc), not padded
};
// Grab a labelled numeric value ("imsi:" / "operator id:") from mmcli -i
// output. Values are digit runs.
namespace detail {
inline std::optional<std::string> Digits(std::string_view text, std::string_view label) {
std::size_t at = text.find(label);
if (at == std::string_view::npos) return std::nullopt;
std::size_t i = at + label.size();
while (i < text.size() && (text[i] == ' ' || text[i] == '\t'))
i++;
std::size_t start = i;
while (i < text.size() && text[i] >= '0' && text[i] <= '9')
i++;
if (i == start) return std::nullopt;
return std::string(text.substr(start, i - start));
}
}
// "equipment id: <imei>" (15 digits) from `mmcli -m any`.
std::optional<std::string> ParseEquipmentId(std::string_view text) {
return detail::Digits(text, "equipment id:");
}
// RFC 7254 IMEI URN from a 15-digit IMEI (TAC-SNR-check digit) — the
// +sip.instance value a UE registers with; nullopt for anything else.
std::optional<std::string> ImeiUrn(std::string_view imei) {
if (imei.size() != 15) return std::nullopt;
for (char c : imei)
if (c < '0' || c > '9') return std::nullopt;
return std::format("urn:gsma:imei:{}-{}-{}", imei.substr(0, 8), imei.substr(8, 6), imei.substr(14));
}
// IMSI + operator id (MCC = first 3 digits, MNC = the rest) from `mmcli -i`.
std::optional<SimInfo> ParseSimInfo(std::string_view text) {
auto imsi = detail::Digits(text, "imsi:");
auto op = detail::Digits(text, "operator id:");
if (!imsi || !op || op->size() < 4) return std::nullopt;
SimInfo s;
s.imsi = *imsi;
s.mcc = op->substr(0, 3);
s.mnc = op->substr(3);
return s;
}
// P-Access-Network-Info value from `mmcli -m any --location-get`:
// "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=<mcc><mnc><tac:4hex><eci:7hex>"
// (TS 24.229 7.2A.4: E-UTRAN cell id = MCC + MNC as broadcast + 16-bit
// TAC + 28-bit ECI; mmcli prints both hex fields zero-padded wider, so
// take the low digits). The stock modem sends exactly this shape in
// every REGISTER/INVITE — the TAS reads it to see which access the UE
// is reachable on. FDD is a constant: every KPN LTE band in use is FDD
// and MM does not expose the duplex mode. nullopt when the fields are
// missing or zero (no serving cell) — callers then omit the header
// rather than fabricate one.
std::optional<std::string> ParsePani(std::string_view text) {
auto mcc = detail::Digits(text, "operator mcc:");
auto mnc = detail::Digits(text, "operator mnc:");
auto hexAfter = [&](std::string_view label) -> std::optional<std::string> {
std::size_t at = text.find(label);
if (at == std::string_view::npos) return std::nullopt;
std::size_t i = at + label.size();
while (i < text.size() && (text[i] == ' ' || text[i] == '\t'))
i++;
std::size_t start = i;
while (i < text.size() && std::isxdigit(static_cast<unsigned char>(text[i])))
i++;
if (i == start) return std::nullopt;
return std::string(text.substr(start, i - start));
};
auto tac = hexAfter("tracking area code:");
auto ci = hexAfter("cell id:");
if (!mcc || !mnc || !tac || !ci) return std::nullopt;
if (tac->size() > 4) *tac = tac->substr(tac->size() - 4);
while (tac->size() < 4) tac->insert(tac->begin(), '0');
if (ci->size() > 7) *ci = ci->substr(ci->size() - 7);
while (ci->size() < 7) ci->insert(ci->begin(), '0');
if (tac->find_first_not_of('0') == std::string::npos && ci->find_first_not_of('0') == std::string::npos) return std::nullopt;
return std::format("3GPP-E-UTRAN-FDD;utran-cell-id-3gpp={}{}{}{}", *mcc, *mnc, *tac, *ci);
}
// ---- AUTHENTICATE(AKA) APDU (ISO 7816-4 over the qmicli UIM channel) ----
// The command APDU (hex, no separators) for a 3G AKA authentication:
// CLA=<channel byte> INS=88 P1=00 P2=81 Lc [10 RAND(16) 10 AUTN(16)]
// `channel` is the logical-channel number qmicli returned; the CLA low
// nibble carries it (Python: cla = "%02x" % ch).
std::string BuildAkaApdu(int channel, std::span<const std::uint8_t> rand16, std::span<const std::uint8_t> autn16) {
std::string data = std::format("10{}10{}", imsd::util::ToHex(rand16), imsd::util::ToHex(autn16));
std::size_t lc = data.size() / 2;
// CLA INS=88 P1=00 P2=81 Lc <data>
return std::format("{:02x}880081{:02x}{}", channel, lc, data);
}
// GET RESPONSE APDU for when the card answers 61xx (xx bytes available).
std::string BuildGetResponseApdu(int channel, int le) {
return std::format("{:02x}c00000{:02x}", channel, le & 0xFF);
}
struct AkaResult {
std::vector<std::uint8_t> res;
std::vector<std::uint8_t> ck;
std::vector<std::uint8_t> ik;
};
// Parse the card's AUTHENTICATE response body. `resp` is the raw APDU
// response INCLUDING the trailing SW1 SW2 (two status bytes), which are
// stripped here. A successful 3G AKA body is:
// DB L_RES RES.. L_CK CK.. L_IK IK..
// (tag 0xDB = "AKA success"; 0xDC would be sync-failure). nullopt on a
// short buffer, a non-DB tag, or a length that runs past the end.
std::optional<AkaResult> ParseAkaResponse(std::span<const std::uint8_t> resp) {
if (resp.size() < 3) return std::nullopt;
std::span<const std::uint8_t> body = resp.subspan(0, resp.size() - 2);
if (body.empty() || body[0] != 0xDB) return std::nullopt;
std::size_t i = 1;
auto take = [&](std::vector<std::uint8_t>& out) -> bool {
if (i >= body.size()) return false;
std::size_t n = body[i++];
if (i + n > body.size()) return false;
out.assign(body.begin() + i, body.begin() + i + n);
i += n;
return true;
};
AkaResult r;
if (!take(r.res) || !take(r.ck) || !take(r.ik)) return std::nullopt;
return r;
}
}

507
interfaces/Imsd-Engine.cppm Normal file
View file

@ -0,0 +1,507 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Engine — the call state machine as a pure reducer.
A CallMachine owns one call's SIP dialog, outgoing or incoming. It never
touches a socket, a clock, or a subprocess: each event (the INVITE's
responses, an in-dialog request from the network, an accept/hang-up command,
the media leg exiting, a setup timeout) is a method that mutates the dialog
state and returns a list of Actions for the daemon shell to carry out — send
this on the client flow, answer the request on the channel it came in on,
start/stop the media leg, emit this D-Bus state change, arm this deadline.
That keeps the whole call FSM — PRACK on reliable 18x, ACK of 2xx vs non-2xx
finals, CANCEL/answer races, remote BYE, media-plane far-end-hangup, and the
terminating (UAS) side: 100/180 on an inbound INVITE, 200-with-answer on
Accept, 486 reject, the remote-CANCEL 200+487 pair — unit-testable against
recorded network dialogs with no I/O (tests/Engine).
State/reason strings are the frozen D-Bus vocabulary the dialer maps on
(dialing/ringing/incoming/active/terminated; outgoing/incoming/accepted/
local-hangup/remote-hangup/refused-or-busy/error). Pure std C++.
*/
export module Imsd:Engine;
import std;
import :Util;
import :Sip;
import :Sdp;
import :Messages;
export namespace imsd::engine {
enum class CallState { Dialing, Ringing, Incoming, Active, Terminated };
enum class Direction { Outgoing, Incoming };
inline std::string_view StateStr(CallState s) {
switch (s) {
case CallState::Dialing: return "dialing";
case CallState::Ringing: return "ringing";
case CallState::Incoming: return "incoming";
case CallState::Active: return "active";
case CallState::Terminated: return "terminated";
}
return "terminated";
}
inline std::string_view DirectionStr(Direction d) {
return d == Direction::Incoming ? "incoming" : "outgoing";
}
// Parsed SDP answer needed to bring up the media leg.
struct MediaLeg {
std::string remoteIp;
int remotePort = 0;
int payloadType = -1;
bool octetAlign = false;
std::string codec = "AMR-WB";
bool Valid() const { return !remoteIp.empty() && remotePort > 0 && payloadType >= 0; }
};
struct Action {
enum class Type {
SendClient, // send `text` on the client TCP flow
SendResponse, // answer the just-handled inbound request on its origin channel
StartMedia, // bring up the media leg described by `media`
StopMedia, // tear the media leg down
State, // emit CallStateChanged(uni, `state`, `reason`)
Deleted, // emit CallDeleted(uni); drop this machine
SetDeadline, // arm a setup timer for `seconds`; fire calls OnDeadline()
Log,
};
Type type;
std::string text;
std::string state;
std::string reason;
MediaLeg media;
double seconds = 0;
};
// ring/answer budget, and how long to wait for a 487 after CANCEL — the
// imsd.py INVITE_TIMEOUT / CANCEL_487_TIMEOUT constants.
inline constexpr double InviteTimeout = 90.0;
inline constexpr double Cancel487Timeout = 10.0;
// How long an unanswered incoming call may ring before we 480 it — a
// safety net behind the network's own CANCEL, so generous.
inline constexpr double IncomingRingTimeout = 120.0;
// Constructor tag: this machine is the UAS of the given inbound INVITE.
struct IncomingInvite {
std::string_view invite;
};
class CallMachine {
public:
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, std::string number, int rtpPort, bool precond)
: ctx_(ctx), rng_(rng), uni_(std::move(uni)), number_(std::move(number)),
rtpPort_(rtpPort), precond_(precond) {
d_.ruri = imsd::msg::RuriFor(number_, ctx_.id.domain);
d_.callid = std::format("{}@{}", rng_.Token(20), ctx_.local);
d_.itag = rng_.Token(10);
d_.invBranch = rng_.Token(20);
}
CallMachine(const imsd::msg::Context& ctx, imsd::util::Rng& rng, std::string uni, IncomingInvite in, int rtpPort)
: ctx_(ctx), rng_(rng), uni_(std::move(uni)), rtpPort_(rtpPort),
precond_(false), direction_(Direction::Incoming),
state_(CallState::Incoming), reason_("incoming"),
invite_(in.invite) {
number_ = imsd::sip::CallerId(invite_);
d_.callid = std::string(imsd::sip::Header(invite_, "Call-ID").value_or(""));
d_.itag = rng_.Token(10); // the To tag we mint = our dialog tag
d_.invBranch = rng_.Token(20); // unused as UAS; kept initialized
if (auto from = imsd::sip::Header(invite_, "From")) d_.dialogTo = std::string(*from);
if (auto ct = imsd::sip::Header(invite_, "Contact")) d_.remoteTarget = AngleUri(*ct);
d_.ruri = d_.remoteTarget.value_or("");
// The UAS route set is the Record-Route in RECEIVED order
// (RFC 3261 12.1.1); DialogRoute() reverses for the UAC case, so
// store it reversed here to cancel that out.
auto rr = imsd::sip::Headers(invite_, "Record-Route");
for (std::size_t i = rr.size(); i-- > 0;)
d_.recordRoute.emplace_back(rr[i]);
answerSdp_ = std::string(Body(invite_)); // the caller's offer
nextCseq_ = 0; // our first in-dialog request gets CSeq 1
}
const std::string& Uni() const { return uni_; }
const std::string& Number() const { return number_; }
const std::string& CallId() const { return d_.callid; }
CallState State() const { return state_; }
std::string_view StateStr() const { return engine::StateStr(state_); }
Direction Dir() const { return direction_; }
std::string_view DirectionStr() const { return engine::DirectionStr(direction_); }
const std::string& Reason() const { return reason_; }
bool Terminated() const { return state_ == CallState::Terminated; }
// Build + queue the INVITE and arm the ring timeout.
std::vector<Action> Start() {
imsd::sdp::Offer offer;
offer.local = ctx_.local;
offer.rtpPort = rtpPort_;
offer.sessionId = rng_.UInt(1000000, 9999999);
offer.precond = precond_;
std::string sdp = imsd::sdp::BuildOffer(offer);
std::vector<Action> a;
a.push_back(Send(imsd::msg::BuildInvite(ctx_, d_, sdp, precond_)));
a.push_back(Deadline(InviteTimeout));
return a;
}
// UAS: answer the inbound INVITE with 100 Trying + 180 Ringing (made
// reliable only when the caller Requires 100rel) and arm the ring
// timeout. An offer we cannot serve — no audio line, or no AMR-WB
// (the media plane is AMR-WB-only) — is refused with 488 up front,
// before the UI ever rings.
std::vector<Action> OnInvite() {
std::vector<Action> a;
if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a;
MediaLeg leg = ParseAnswer();
if (!leg.Valid() || leg.codec != "AMR-WB") {
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 488, "Not Acceptable Here", d_.itag)));
Append(a, Finish("error", "incoming offer unusable (need AMR-WB audio); 488 sent"));
return a;
}
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 100, "Trying", d_.itag)));
std::string require(imsd::sip::Header(invite_, "Require").value_or(""));
if (require.contains("precondition")) a.push_back(LogAct("caller requires preconditions; answering without them"));
std::vector<std::string> extra;
if (require.contains("100rel")) {
extra.push_back("Require: 100rel");
extra.push_back("RSeq: 1");
}
last180_ = imsd::msg::BuildInviteResponse(ctx_, invite_, 180, "Ringing", d_.itag, std::nullopt, extra);
a.push_back(Respond(last180_));
a.push_back(Deadline(IncomingRingTimeout));
return a;
}
// UAS: the user answered. 200 OK with the SDP answer (the offer's own
// payload types, RFC 3264), media up, active. The caller's ACK needs
// no action when it arrives (OnRequest ignores ACK).
std::vector<Action> OnAccept() {
std::vector<Action> a;
if (direction_ != Direction::Incoming || state_ != CallState::Incoming) return a;
MediaLeg leg = ParseAnswer();
imsd::sdp::AnswerSpec spec;
spec.local = ctx_.local;
spec.rtpPort = rtpPort_;
spec.sessionId = rng_.UInt(1000000, 9999999);
spec.payloadType = leg.payloadType;
spec.octetAlign = leg.octetAlign;
spec.codec = leg.codec;
spec.dtmfPt = imsd::sdp::TelephoneEventPt(Body(invite_), 16000);
localSdp_ = imsd::sdp::BuildAnswer(spec);
// session timers: echo the caller's Session-Expires; with no
// refresher stated, make the caller (uac) the refresher — its
// re-INVITE/UPDATE refreshes get 200d by OnRequest.
std::vector<std::string> extra;
if (auto se = imsd::sip::Header(invite_, "Session-Expires")) {
std::string v(*se);
if (!v.contains("refresher=")) v += ";refresher=uac";
extra.push_back(std::format("Session-Expires: {}", v));
extra.push_back("Require: timer");
}
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 200, "OK", d_.itag, localSdp_, extra)));
a.push_back(StartMediaAct(leg));
state_ = CallState::Active;
a.push_back(StateAct("accepted"));
return a;
}
// A response to our INVITE (the shell already matched Call-ID + CSeq).
std::vector<Action> OnInviteResponse(std::string_view msg) {
std::vector<Action> a;
if (direction_ == Direction::Incoming)
return a; // a UAS has no INVITE transaction of its own
AbsorbDialog(msg);
auto st = imsd::sip::Status(msg);
if (!st) return a;
int code = *st;
if (code > 100 && code < 200) {
if (state_ == CallState::Dialing) {
state_ = CallState::Ringing;
a.push_back(StateAct("outgoing"));
}
auto rseq = imsd::sip::Header(msg, "RSeq");
std::string require = std::string(imsd::sip::Header(msg, "Require").value_or(""));
if (rseq && require.contains("100rel") && !pracked_.contains(std::string(*rseq))) {
pracked_.insert(std::string(*rseq));
std::array<std::string, 1> extra = {
std::format("RAck: {} 1 INVITE", *rseq)};
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "PRACK", nextCseq_, rng_.Token(20), extra)));
nextCseq_++;
}
return a;
}
if (code < 200)
return a; // 1xx other than 100<code<200 (only 100): nothing
if (code == 200) {
a.push_back(Send(imsd::msg::BuildAck2xx(ctx_, d_, rng_.Token(20), std::string(imsd::sip::Header(msg, "To").value_or("")))));
if (cancelled_) {
// answered in the CANCEL race — we no longer want it
nextCseq_++;
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
Append(a, Finish("local-hangup", "answered during our CANCEL; BYE sent"));
return a;
}
MediaLeg leg = ParseAnswer();
a.push_back(StartMediaAct(leg));
state_ = CallState::Active;
a.push_back(StateAct("accepted"));
return a;
}
// non-2xx final
a.push_back(Send(imsd::msg::BuildAckNon2xx(ctx_, d_, std::string(imsd::sip::Header(msg, "To").value_or("")))));
std::string fin = WithReason(std::format("INVITE final {}", code), msg);
if (cancelled_ || code == 487) Append(a, Finish("local-hangup", fin));
else if (code == 486 || code == 480 || code == 603) Append(a, Finish("refused-or-busy", fin));
else
Append(a, Finish("error", fin));
return a;
}
// An in-dialog request from the network (the shell matched Call-ID).
std::vector<Action> OnRequest(std::string_view msg) {
std::vector<Action> a;
std::string_view method = Method(msg);
if (method == "ACK")
return a; // ACK is never responded to (RFC 3261 17.1.1.2)
std::string_view body = Body(msg);
if (body.contains("m=audio")) answerSdp_ = std::string(body);
if (direction_ == Direction::Incoming && state_ == CallState::Incoming) {
if (method == "CANCEL") {
// the caller gave up: 200 the CANCEL transaction, 487 the
// INVITE transaction (RFC 3261 9.2), done.
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 487, "Request Terminated", d_.itag)));
Append(a, Finish("remote-hangup", WithReason("CANCEL before answer", msg)));
} else if (method == "INVITE") {
// retransmitted INVITE (our provisional got lost): repeat it
a.push_back(Respond(last180_));
} else {
// PRACK of our reliable 180, or anything else: 200 echo
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
}
return a;
}
if (method == "UPDATE" || method == "INVITE") {
std::optional<std::string> ans;
if (direction_ == Direction::Incoming) {
// session-refresh: re-state the answer we gave if the
// refresh carries an offer; a bodyless refresh gets a
// bodyless 200 (RFC 3311/4028)
if (body.contains("m=audio")) ans = localSdp_;
} else {
imsd::sdp::Offer offer;
offer.local = ctx_.local;
offer.rtpPort = rtpPort_;
offer.sessionId = rng_.UInt(1000000, 9999999);
offer.precond = precond_;
offer.currRemote = "sendrecv";
ans = imsd::sdp::BuildOffer(offer);
}
// RFC 4028 §9: a 2xx to a refresh MUST echo Session-Expires,
// or the refresher treats the session as unrefreshed. KPN's
// B2BUA audits ~55 s into every call with such an UPDATE and
// releases ("PT: Session timer expired") on a bare 200.
std::vector<std::string> extra;
if (auto se = imsd::sip::Header(msg, "Session-Expires")) {
std::string v(*se);
if (!v.contains("refresher=")) v += ";refresher=uac";
extra.push_back(std::format("Session-Expires: {}", v));
extra.push_back("Require: timer");
}
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg, ans ? std::optional<std::string_view>(*ans) : std::nullopt, extra)));
} else if (method == "BYE") {
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
Append(a, Finish("remote-hangup", WithReason("remote BYE", msg)));
} else if (method == "CANCEL") {
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
Append(a, Finish("remote-hangup", WithReason("remote CANCEL", msg)));
} else {
a.push_back(Respond(imsd::msg::BuildResponse200(ctx_, msg)));
}
return a;
}
std::vector<Action> OnHangup() {
std::vector<Action> a;
if (direction_ == Direction::Incoming && state_ == CallState::Incoming) {
// reject the unanswered call
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 486, "Busy Here", d_.itag)));
Append(a, Finish("local-hangup", "rejected (486 sent)"));
return a;
}
if (state_ == CallState::Active) {
nextCseq_++;
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
Append(a, Finish("local-hangup", "HangUp; BYE sent"));
} else if ((state_ == CallState::Dialing || state_ == CallState::Ringing) && !cancelled_) {
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
cancelled_ = true;
a.push_back(Deadline(Cancel487Timeout));
}
return a;
}
// The media subprocess exited. code 3 = the downlink RTP dried up
// (far-end hangup); anything else = the leg died. Either way, if the
// call was up we release it network-side and tear down.
std::vector<Action> OnMediaExit(int code) {
std::vector<Action> a;
if (state_ != CallState::Active) return a;
nextCseq_++;
a.push_back(Send(imsd::msg::BuildInDialog(ctx_, d_, "BYE", nextCseq_, rng_.Token(20))));
Append(a, Finish("remote-hangup", code == 3 ? "far-end hangup (downlink RTP stopped); BYE sent" : std::format("media leg died (code {}); BYE sent", code)));
return a;
}
// A setup deadline fired (never armed once Active).
std::vector<Action> OnDeadline() {
std::vector<Action> a;
if (state_ == CallState::Active) return a;
if (direction_ == Direction::Incoming) {
if (state_ == CallState::Incoming) {
// rang unanswered past the safety net; the missed call
// reads as remote-hangup, like a network CANCEL would
a.push_back(Respond(imsd::msg::BuildInviteResponse(ctx_, invite_, 480, "Temporarily Unavailable", d_.itag)));
Append(a, Finish("remote-hangup", "rang unanswered past the deadline (480 sent)"));
}
return a;
}
if (!cancelled_ && (state_ == CallState::Dialing || state_ == CallState::Ringing)) {
a.push_back(Send(imsd::msg::BuildCancel(ctx_, d_)));
cancelled_ = true;
a.push_back(Deadline(Cancel487Timeout));
} else {
Append(a, Finish("error", "setup deadline expired"));
}
return a;
}
// The shell couldn't send (socket error) — collapse the call.
std::vector<Action> Fail(std::string_view reason) {
std::vector<Action> a;
Append(a, Finish(reason, "client-flow send failed"));
return a;
}
private:
const imsd::msg::Context& ctx_;
imsd::util::Rng& rng_;
std::string uni_, number_;
int rtpPort_;
bool precond_;
Direction direction_ = Direction::Outgoing;
CallState state_ = CallState::Dialing;
std::string reason_ = "outgoing";
std::string invite_; // incoming only: the inbound INVITE verbatim
std::string last180_; // incoming only: our provisional, for retransmits
std::string localSdp_; // incoming only: the SDP answer we sent
imsd::msg::Dialog d_;
int nextCseq_ = 2;
bool cancelled_ = false;
std::set<std::string> pracked_;
std::string answerSdp_;
static std::string_view Body(std::string_view msg) {
std::size_t at = msg.find("\r\n\r\n");
return at == std::string_view::npos ? std::string_view{}
: msg.substr(at + 4);
}
static std::string_view Method(std::string_view msg) {
std::size_t sp = msg.find(' ');
return sp == std::string_view::npos ? msg : msg.substr(0, sp);
}
static std::optional<std::string> AngleUri(std::string_view v) {
std::size_t lt = v.find('<');
if (lt == std::string_view::npos) return std::string(v);
std::size_t gt = v.find('>', lt);
if (gt == std::string_view::npos) return std::string(v);
return std::string(v.substr(lt + 1, gt - lt - 1));
}
// Fold a response's dialog-establishing headers into the dialog.
void AbsorbDialog(std::string_view msg) {
if (auto to = imsd::sip::Header(msg, "To"))
if (to->contains("tag=")) d_.dialogTo = std::string(*to);
auto rr = imsd::sip::Headers(msg, "Record-Route");
if (!rr.empty()) {
d_.recordRoute.clear();
for (auto v : rr)
d_.recordRoute.emplace_back(v);
}
if (auto ct = imsd::sip::Header(msg, "Contact")) d_.remoteTarget = AngleUri(*ct);
std::string_view body = Body(msg);
if (body.contains("m=audio")) answerSdp_ = std::string(body);
}
MediaLeg ParseAnswer() const {
imsd::sdp::Answer ans = imsd::sdp::ParseAnswer(answerSdp_);
MediaLeg leg;
leg.remoteIp = ans.ip;
leg.remotePort = ans.port < 0 ? 0 : ans.port;
leg.payloadType = ans.payloadType;
leg.octetAlign = ans.octetAlign;
leg.codec = ans.codec;
return leg;
}
// Terminate: state change, media teardown, deletion — in that order,
// so the UI leaves the call page before the (slow) media teardown.
// One journald line per teardown — who ended the call and why
// (the s59 60-second-drop hunt ran blind for lack of exactly
// this; the network's Reason header named the cause all along).
// `detail` carries the SIP-level cause when the caller knows it.
std::vector<Action> Finish(std::string_view reason, std::string_view detail = "") {
state_ = CallState::Terminated;
reason_ = std::string(reason);
std::vector<Action> a;
a.push_back(LogAct(detail.empty() ? std::format("call {} ended: {}", uni_, reason) : std::format("call {} ended: {} — {}", uni_, reason, detail)));
a.push_back(StateAct(reason));
a.push_back(Action{Action::Type::StopMedia});
a.push_back(Action{Action::Type::Deleted});
return a;
}
// "remote BYE, Reason: SIP;cause=503;text=..." — the peer's own
// stated cause, when it sent one.
static std::string WithReason(std::string_view what, std::string_view msg) {
auto r = imsd::sip::Header(msg, "Reason");
return r ? std::format("{}, Reason: {}", what, *r) : std::string(what);
}
Action Send(std::string text) {
return Action{Action::Type::SendClient, std::move(text)};
}
Action Respond(std::string text) {
return Action{Action::Type::SendResponse, std::move(text)};
}
Action StateAct(std::string_view reason) {
reason_ = std::string(reason);
Action a{Action::Type::State};
a.state = std::string(engine::StateStr(state_));
a.reason = reason_;
return a;
}
Action StartMediaAct(const MediaLeg& leg) {
Action a{Action::Type::StartMedia};
a.media = leg;
return a;
}
Action Deadline(double s) {
Action a{Action::Type::SetDeadline};
a.seconds = s;
return a;
}
Action LogAct(std::string text) {
return Action{Action::Type::Log, std::move(text)};
}
static void Append(std::vector<Action>& into, std::vector<Action> more) {
for (auto& m : more)
into.push_back(std::move(m));
}
};
}

208
interfaces/Imsd-Ipsec.cppm Normal file
View file

@ -0,0 +1,208 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Ipsec — IMS security-association plumbing expressed as `ip xfrm` command
lines, plus a reader for an SA already installed in the kernel.
A registered IMS UE keeps two ESP SA pairs with the P-CSCF (TS 33.203):
reqid 1 carries the UE-initiated client flow (protected client port ⇄ P-CSCF
server port), reqid 2 the terminating server flow. Integrity is HMAC-SHA1-96
keyed with IK; confidentiality is the negotiated ealg keyed with CK. This
module turns the negotiated SPIs/ports/keys into the exact argv lists the
daemon shell hands to `ip`, and — for a warm resume — reconstructs the SPIs,
ports, and Security-Server value back out of `ip xfrm state`/`policy` text so
a refresh re-REGISTER can advertise the SPIs actually in use.
Pure std C++ (the shell runs the commands and captures the text). Command
shapes and the parser are pinned by tests/Ipsec.
*/
export module Imsd:Ipsec;
import std;
import :Util;
export namespace imsd::ipsec {
// UE protected ports are fixed by the stack; P-CSCF ports come from the
// Security-Server the network offered (fresh) or the kernel (resume).
struct SaParams {
std::string local; // UE global IPv6/4 on the ims PDN
std::string pcscf; // P-CSCF address
std::vector<std::uint8_t> ik; // integrity key (auth)
std::vector<std::uint8_t> ck; // cipher key (enc)
std::uint32_t spiUc = 0; // UE client inbound SA (P->UE, reqid 1)
std::uint32_t spiUs = 0; // UE server inbound SA (P->UE, reqid 2)
std::uint32_t spiPc = 0; // P-CSCF client outbound SA (UE->P, reqid 2)
std::uint32_t spiPs = 0; // P-CSCF server outbound SA (UE->P, reqid 1)
int portUc = imsd::util::PortUc; // UE protected client port
int portUs = imsd::util::PortUs; // UE protected server port
int portPs = 0; // P-CSCF server port (UE connects its TCP here)
int portPc = 0; // P-CSCF client port
std::string ealg = "aes-cbc"; // aes-cbc | des-ede3-cbc/3des | null
};
namespace detail {
// ESP cipher argv triple for `ip xfrm state add ... <enc>`, matching
// imscall.setup_sa: aes-cbc keyed with CK; 3DES uses CK||CK[:8] to
// reach 24 bytes; anything else is the null cipher (empty key).
inline std::vector<std::string> EncArgs(const SaParams& p) {
if (p.ealg == "aes-cbc") return {"enc", "cbc(aes)", std::format("0x{}", imsd::util::ToHex(p.ck))};
if (p.ealg == "des-ede3-cbc" || p.ealg == "3des") {
std::vector<std::uint8_t> k = p.ck;
k.insert(k.end(), p.ck.begin(), p.ck.begin() + 8);
return {"enc", "cbc(des3_ede)", std::format("0x{}", imsd::util::ToHex(k))};
}
return {"enc", "cipher_null", ""};
}
inline std::vector<std::string> StateAdd(std::string_view src, std::string_view dst, std::uint32_t spi, int reqid, std::string_view ikx, const std::vector<std::string>& enc) {
std::vector<std::string> a = {
"ip", "xfrm", "state", "add",
"src", std::string(src), "dst", std::string(dst),
"proto", "esp", "spi", std::to_string(spi),
"mode", "transport", "reqid", std::to_string(reqid),
"auth-trunc", "hmac(sha1)", std::string(ikx), "96"};
a.insert(a.end(), enc.begin(), enc.end());
return a;
}
inline std::vector<std::string> PolicyAdd(std::string_view src, std::string_view dst, int plen, int sport, int dport, std::string_view dir, std::string_view tsrc, std::string_view tdst, int reqid) {
return {
"ip", "xfrm", "policy", "add",
"src", std::format("{}/{}", src, plen),
"dst", std::format("{}/{}", dst, plen),
"sport", std::to_string(sport), "dport", std::to_string(dport),
"dir", std::string(dir),
"tmpl", "src", std::string(tsrc), "dst", std::string(tdst),
"proto", "esp", "reqid", std::to_string(reqid),
"mode", "transport"};
}
}
// The full `ip xfrm` argv sequence to install a fresh SA pair: flush
// state+policy, add the four ESP states, add the four transport policies.
// Order matches imscall.setup_sa exactly.
std::vector<std::vector<std::string>> BuildSetupCommands(const SaParams& p) {
const std::string& local = p.local;
const std::string& pcscf = p.pcscf;
std::string ikx = std::format("0x{}", imsd::util::ToHex(p.ik));
std::vector<std::string> enc = detail::EncArgs(p);
int plen = imsd::util::Is6(local) ? 128 : 32;
std::vector<std::vector<std::string>> cmds;
cmds.push_back({"ip", "xfrm", "state", "flush"});
cmds.push_back({"ip", "xfrm", "policy", "flush"});
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPs, 1, ikx, enc));
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUc, 1, ikx, enc));
cmds.push_back(detail::StateAdd(local, pcscf, p.spiPc, 2, ikx, enc));
cmds.push_back(detail::StateAdd(pcscf, local, p.spiUs, 2, ikx, enc));
cmds.push_back(detail::PolicyAdd(local, pcscf, plen, p.portUc, p.portPs, "out", local, pcscf, 1));
cmds.push_back(detail::PolicyAdd(pcscf, local, plen, p.portPs, p.portUc, "in", pcscf, local, 1));
cmds.push_back(detail::PolicyAdd(local, pcscf, plen, p.portUs, p.portPc, "out", local, pcscf, 2));
cmds.push_back(detail::PolicyAdd(pcscf, local, plen, p.portPc, p.portUs, "in", pcscf, local, 2));
return cmds;
}
std::vector<std::vector<std::string>> BuildTeardownCommands() {
return {{"ip", "xfrm", "state", "flush"}, {"ip", "xfrm", "policy", "flush"}};
}
// What a warm SA in the kernel tells us — enough for a refresh re-REGISTER
// to re-bind without re-authenticating.
struct ExistingSa {
std::string securityServer; // reconstructed Security-Server header value
int portPs = 0;
int portPc = 0;
std::uint32_t spiUc = 0; // our inbound client SPI (advertise on refresh)
std::uint32_t spiUs = 0; // our inbound server SPI
std::uint32_t spiPs = 0;
std::uint32_t spiPc = 0;
};
namespace detail {
// The whitespace-delimited token immediately after `key` (which should
// include its trailing space, e.g. "spi "); empty if `key` is absent.
inline std::string_view TokenAfter(std::string_view text, std::string_view key) {
std::size_t at = text.find(key);
if (at == std::string_view::npos) return {};
std::size_t start = at + key.size();
std::size_t end = text.find_first_of(" \t\r\n", start);
return text.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start);
}
// Split into blocks, each starting at a line-anchored "src " and
// running to just before the next one.
inline std::vector<std::string_view> SrcBlocks(std::string_view text) {
std::vector<std::string_view> blocks;
std::size_t i = 0;
std::optional<std::size_t> cur;
std::size_t pos = 0;
while (pos <= text.size()) {
std::size_t nl = text.find('\n', pos);
std::string_view line = text.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
if (line.starts_with("src ")) {
if (cur) blocks.push_back(text.substr(*cur, pos - *cur));
cur = pos;
}
if (nl == std::string_view::npos) break;
pos = nl + 1;
}
if (cur) blocks.push_back(text.substr(*cur));
(void)i;
return blocks;
}
inline std::optional<std::uint32_t> ParseHex32(std::string_view tok) {
if (tok.starts_with("0x") || tok.starts_with("0X")) tok.remove_prefix(2);
if (tok.empty()) return std::nullopt;
std::uint32_t v = 0;
auto [p, ec] = std::from_chars(tok.data(), tok.data() + tok.size(), v, 16);
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
return v;
}
inline std::optional<int> ParseDec(std::string_view tok) {
int v = 0;
auto [p, ec] = std::from_chars(tok.data(), tok.data() + tok.size(), v);
if (ec != std::errc{} || p != tok.data() + tok.size()) return std::nullopt;
return v;
}
}
// Reconstruct SA facts from `ip xfrm state` + `ip xfrm policy` text.
// Mapping (matches imscall.parse_existing_sa):
// state: (src==LOCAL,dst==P): reqid1->spiPs reqid2->spiPc
// (src==P,dst==LOCAL): reqid1->spiUc reqid2->spiUs
// policy: dir out, reqid1->portPs=dport, reqid2->portPc=dport
// Returns nullopt unless spiPs, spiPc, portPs, portPc are all found.
std::optional<ExistingSa> ParseExistingSa(std::string_view stateText, std::string_view policyText, std::string_view pcscf, std::string_view local) {
ExistingSa sa;
bool havePs = false;
bool havePc = false;
bool havePortPs = false;
bool havePortPc = false;
for (std::string_view b : detail::SrcBlocks(stateText)) {
std::string_view src = detail::TokenAfter(b, "src ");
std::string_view dst = detail::TokenAfter(b, "dst ");
auto spi = detail::ParseHex32(detail::TokenAfter(b, "spi "));
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
if (!spi || !reqid) continue;
if (src == local && dst == pcscf && *reqid == 1) { sa.spiPs = *spi; havePs = true; }
if (src == local && dst == pcscf && *reqid == 2) { sa.spiPc = *spi; havePc = true; }
if (src == pcscf && dst == local && *reqid == 1) sa.spiUc = *spi;
if (src == pcscf && dst == local && *reqid == 2) sa.spiUs = *spi;
}
for (std::string_view b : detail::SrcBlocks(policyText)) {
if (b.find("dir out") == std::string_view::npos) continue;
auto dport = detail::ParseDec(detail::TokenAfter(b, "dport "));
auto reqid = detail::ParseDec(detail::TokenAfter(b, "reqid "));
if (!dport || !reqid) continue;
if (*reqid == 1) { sa.portPs = *dport; havePortPs = true; }
else if (*reqid == 2) { sa.portPc = *dport; havePortPc = true; }
}
if (!(havePs && havePc && havePortPs && havePortPc)) return std::nullopt;
sa.securityServer = std::format("ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c={}; spi-s={}; port-c={}; port-s={}", sa.spiPc, sa.spiPs, sa.portPc, sa.portPs);
return sa;
}
}

View file

@ -0,0 +1,457 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Messages — the SIP request/response text the engine puts on the wire.
Every VoLTE message the daemon sends is assembled here from an explicit
context and the per-request variables (tags, branches, CSeq) so the builders
are pure functions with no hidden state: the engine owns the dialog bookkeeping
and the transport owns the socket, this module only formats bytes. That makes
each message byte-pinnable — tests/Messages compares the output against strings
cross-generated from the Python reference stack, header for header.
Covers: the unprotected + protected REGISTER and the refresh re-REGISTER
(sec-agree, Security-Client/Verify, AKAv1-MD5 Authorization); the INVITE with
an AMR-WB SDP offer and IMS MMTEL feature tags; the in-dialog PRACK/BYE and
2xx/non-2xx ACK; CANCEL (reusing the INVITE branch); the 200 OK that echoes
an inbound request's dialog headers; and the UAS responses to an inbound
INVITE (100/180/200 and rejection finals). Pure std C++.
*/
export module Imsd:Messages;
import std;
import :Util;
import :Aka;
import :Sip;
export namespace imsd::msg {
// Everything the builders need that is stable for the life of a
// registration. Ports default to the fixed protected values; route/ppi/
// securityServer are filled in from the REGISTER 200 OK.
struct Context {
imsd::aka::Identity id;
std::string local;
int portUc = imsd::util::PortUc;
int portUs = imsd::util::PortUs;
int initPort = 5060;
std::string ealgOffer = "aes-cbc";
// RFC 7254 IMEI URN for +sip.instance (e.g. urn:gsma:imei:35999999-
// 000000-1); empty omits the parameter.
std::string instanceId;
// User-part of the registered-binding Contact URI. The stock modem
// registers a fresh random UUID here, NOT the IMSI (oracle capture
// 2026-07-21, journal/ims.md s53) — one of the two residual diffs vs
// our REGISTER while MT calls still CSFB. Empty falls back to the
// IMSI: the pre-oracle shape, which old state files resumed from and
// the byte-pinned tests encode.
std::string contactUser;
// User-Agent header value for REGISTER; the stock modem sends its
// build string (the other residual oracle diff). Empty omits.
std::string userAgent;
// From the successful REGISTER:
std::string route; // Route header value (Service-Route or fallback)
std::string ppi; // P-Preferred-Identity value
std::string aor; // default public identity (P-Associated-URI[0],
// sip: entry preferred) — the identity for
// self-targeted requests like reg-event
// SUBSCRIBE; the temporary (IMSI-derived)
// IMPU is barred for anything but REGISTER
std::string securityServer; // Security-Verify value (the network's ss)
// P-Access-Network-Info value (imsd::aka::ParsePani from live mmcli
// location: real MCC/MNC/TAC/ECI, FDD). Empty omits the header —
// never fabricate one: the network reads it to learn which access
// the UE is reachable on (stock sends it in every REGISTER/INVITE).
std::string panInfo;
};
// A Via line: "Via: SIP/2.0/<transport> [local]:<port>;branch=z9hG4bK<b>;rport".
//
// Port rule (TS 24.229 5.1.1.4 / 33.203): every PROTECTED request carries
// the protected SERVER port (portUs) in Via and Contact — that is the
// port the network delivers terminating requests to (MT INVITE, NOTIFY,
// in-dialog requests toward us). The protected CLIENT port (portUc) is
// only the source port of the TCP connection we open; it never appears
// in headers. Getting this wrong is invisible on the originating path
// (TCP responses ride our own connection regardless) but silently kills
// ALL terminating delivery: the P-CSCF has no deliverable flow toward a
// Contact naming portUc, so MT calls fall back to CS and reg-event
// NOTIFYs vanish (found live 2026-07-20; the stock modem puts its
// port_us in both Via and Contact).
inline std::string ViaLine(const Context& c, std::string_view transport, int port, std::string_view branch) {
return std::format("Via: SIP/2.0/{} {};branch=z9hG4bK{};rport", transport, imsd::util::HostPort(c.local, port), branch);
}
// Security-Client value carrying our in-use client SPIs + protected ports.
inline std::string SecurityClient(const Context& c, std::uint32_t spiUc, std::uint32_t spiUs) {
return std::format("Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg={}; " "spi-c={}; spi-s={}; port-c={}; port-s={}", c.ealgOffer, spiUc, spiUs, c.portUc, c.portUs);
}
// Authorization line with an empty (unchallenged) digest — the first
// REGISTER, before the 401 nonce is known.
inline std::string AuthEmpty(const Context& c) {
return std::format("Authorization: Digest username=\"{}\", realm=\"{}\", uri=\"{}\", " "nonce=\"\", response=\"\"", c.id.impi, c.id.domain, c.id.regUri);
}
// Authorization line with a computed AKAv1-MD5 response.
inline std::string AuthAka(const Context& c, std::string_view nonce, std::string_view cnonce, std::string_view response) {
return std::format(
"Authorization: Digest username=\"{}\", realm=\"{}\", uri=\"{}\", "
"nonce=\"{}\", qop=auth, nc=00000001, cnonce=\"{}\", response=\"{}\", "
"algorithm=AKAv1-MD5",
c.id.impi, c.id.domain, c.id.regUri, nonce, cnonce, response);
}
// REGISTER Contact media-feature tags. The MMTEL ICSI declares the
// binding VOICE-capable: terminating access-domain selection only routes
// incoming calls to contacts registered with it — without the tag the
// network diverts straight to voicemail (KPN, observed live 2026-07-20;
// the stock modem registers with exactly this ICSI). The URN colons are
// percent-encoded (TS 24.229 7.2A.8.2 feature-tag coding; RFC 3841
// matching compares the value as a string, so the encoding must match
// what the network selects on — the stock modem sends exactly
// urn%3Aurn-7%3A..., diag capture 2026-07-18). +sip.instance carries the
// IMEI URN when known. Deliberately NOT +g.3gpp.smsip: it routes
// terminating SMS over IMS to a stack with no SMSoIP handling yet,
// silently losing texts — and exact tag parity including smsip +
// nw-init-ussi was tested live (2026-07-20, stored binding echo-verified)
// and did NOT change terminating access-domain selection, so the tags
// buy nothing.
// The binding user-part every Contact we emit carries (see
// Context::contactUser).
inline std::string_view ContactUser(const Context& c) {
return c.contactUser.empty() ? std::string_view(c.id.imsi)
: std::string_view(c.contactUser);
}
inline std::string ContactFeatures(const Context& c) {
std::string s;
if (!c.instanceId.empty()) s += std::format(";+sip.instance=\"<{}>\"", c.instanceId);
s += ";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"";
return s;
}
// Map a dialled string to a Request-URI (imscall.ruri_for): E.164/national
// numbers become user=phone SIP URIs at the home domain; a bare short code
// gets phone-context.
inline std::string RuriFor(std::string_view target, std::string_view domain) {
std::string t;
for (char ch : target)
if (ch != ' ') t.push_back(ch);
if (t.starts_with("+")) return std::format("sip:{}@{};user=phone", t, domain);
if (t.starts_with("00")) return std::format("sip:+{}@{};user=phone", t.substr(2), domain);
if (t.starts_with("0") && t.size() >= 9) return std::format("sip:+31{}@{};user=phone", t.substr(1), domain);
return std::format("sip:{};phone-context={}@{};user=phone", t, domain, domain);
}
namespace detail {
inline std::string Join(std::span<const std::string> lines) {
std::string s;
for (std::size_t i = 0; i < lines.size(); i++) {
s += lines[i];
s += "\r\n";
}
return s;
}
}
// ---- REGISTER -------------------------------------------------------
// The first, unprotected REGISTER (UDP from the init port, CSeq 1, empty
// digest, Supported: path). Triggers the 401 that carries the AKA nonce.
std::string BuildRegisterInitial(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, std::uint32_t spiUc, std::uint32_t spiUs) {
std::vector<std::string> l = {
std::format("REGISTER {} SIP/2.0", c.id.regUri),
ViaLine(c, "UDP", c.initPort, branch),
"Max-Forwards: 70",
std::format("From: <{}>;tag={}", c.id.impu, ftag),
std::format("To: <{}>", c.id.impu),
std::format("Call-ID: {}", callid),
"CSeq: 1 REGISTER",
std::format("Contact: <sip:{}@{}>{};expires=600000", ContactUser(c), imsd::util::HostPort(c.local, c.initPort), ContactFeatures(c)),
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER",
AuthEmpty(c),
"Require: sec-agree",
"Proxy-Require: sec-agree",
SecurityClient(c, spiUc, spiUs),
"Supported: sec-agree, path",
"Expires: 600000",
};
if (!c.panInfo.empty()) l.push_back(std::format("P-Access-Network-Info: {}", c.panInfo));
if (!c.userAgent.empty()) l.push_back(std::format("User-Agent: {}", c.userAgent));
l.push_back("Content-Length: 0");
return std::format("{}\r\n", detail::Join(l));
}
// A protected REGISTER over the IPsec/TCP flow. Always advertises
// Supported: sec-agree, path — RFC 3327: 'path' here is the UA's
// declaration that it is reachable via the Path the P-CSCF inserted,
// i.e. the route every terminating request (NOTIFY, MT INVITE) takes.
// The stock oracle sends it in BOTH REGISTERs (2026-07-21 capture,
// reg2 build at 19:30:44); an earlier reading that reg2 omits it was
// wrong and cost us all terminating delivery. `authLine` is a full
// Authorization line (AuthEmpty for the refresh's first try, AuthAka
// once challenged).
std::string BuildRegisterProtected(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch, int cseq, std::uint32_t spiUc, std::uint32_t spiUs, std::string_view authLine) {
std::vector<std::string> l = {
std::format("REGISTER {} SIP/2.0", c.id.regUri),
ViaLine(c, "TCP", c.portUs, branch),
"Max-Forwards: 70",
std::format("From: <{}>;tag={}", c.id.impu, ftag),
std::format("To: <{}>", c.id.impu),
std::format("Call-ID: {}", callid),
std::format("CSeq: {} REGISTER", cseq),
std::format("Contact: <sip:{}@{}>{};expires=600000", ContactUser(c), imsd::util::HostPort(c.local, c.portUs), ContactFeatures(c)),
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER",
std::string(authLine),
"Require: sec-agree",
"Proxy-Require: sec-agree",
SecurityClient(c, spiUc, spiUs),
std::format("Security-Verify: {}", c.securityServer),
};
l.push_back("Supported: sec-agree, path");
l.push_back("Expires: 600000");
if (!c.panInfo.empty()) l.push_back(std::format("P-Access-Network-Info: {}", c.panInfo));
if (!c.userAgent.empty()) l.push_back(std::format("User-Agent: {}", c.userAgent));
l.push_back("Content-Length: 0");
return std::format("{}\r\n", detail::Join(l));
}
// SUBSCRIBE to the registration event package (RFC 3680) for our own
// AOR — TS 24.229 5.1.1.3 requires it after registration. The immediate
// NOTIFY carries application/reginfo+xml with every binding the S-CSCF
// holds for the implicit registration set, feature tags as stored — the
// network's own view of what terminating routing selects on. Same
// sec-agree/route shape as the other protected in-flow requests; a new
// dialog (fresh Call-ID, CSeq 1) per subscription.
std::string BuildSubscribeReg(const Context& c, std::string_view callid, std::string_view ftag, std::string_view branch) {
std::string_view aor = c.aor.empty() ? std::string_view(c.id.impu)
: std::string_view(c.aor);
std::vector<std::string> l = {
std::format("SUBSCRIBE {} SIP/2.0", aor),
ViaLine(c, "TCP", c.portUs, branch),
"Max-Forwards: 70",
std::format("Route: {}", c.route),
std::format("From: <{}>;tag={}", aor, ftag),
std::format("To: <{}>", aor),
std::format("Call-ID: {}", callid),
"CSeq: 1 SUBSCRIBE",
std::format("Contact: <sip:{}@{}>", ContactUser(c), imsd::util::HostPort(c.local, c.portUs)),
"Event: reg",
"Accept: application/reginfo+xml",
"Expires: 600000",
};
// P-Preferred-Identity is a hint (RFC 3325) — omitted when no public
// identity has been learned yet; the network asserts one regardless.
if (!c.ppi.empty()) l.push_back(std::format("P-Preferred-Identity: <{}>", c.ppi));
if (!c.panInfo.empty()) l.push_back(std::format("P-Access-Network-Info: {}", c.panInfo));
l.push_back(std::format("Security-Verify: {}", c.securityServer));
l.push_back("Proxy-Require: sec-agree");
l.push_back("Require: sec-agree");
l.push_back("Content-Length: 0");
return std::format("{}\r\n", detail::Join(l));
}
// ---- call dialog ----------------------------------------------------
// The dialog identity + routing the engine accumulates for one call.
// For an outgoing call itag is our From tag and dialogTo is learned from
// the responses; for an incoming call itag is the To tag we mint,
// dialogTo is the caller's From, and both feed BuildInDialog identically
// (our requests always put OUR tag in From and the remote's in To).
struct Dialog {
std::string ruri;
std::string callid;
std::string itag; // our (local) dialog tag
std::string invBranch; // INVITE Via branch (CANCEL reuses it)
std::optional<std::string> dialogTo; // remote To/From incl. tag, once known
std::vector<std::string> recordRoute; // route set, UAC order (see DialogRoute)
std::optional<std::string> remoteTarget; // remote Contact URI
};
// Dialog route set: the reversed Record-Route, or the registration Route
// when none was recorded (imsd._dialog_route).
inline std::string DialogRoute(const Context& c, const Dialog& d) {
if (d.recordRoute.empty()) return c.route;
std::string s;
for (std::size_t i = d.recordRoute.size(); i-- > 0;) {
s += d.recordRoute[i];
if (i != 0) s += ", ";
}
return s;
}
// INVITE with an AMR-WB SDP offer (passed in, built by Imsd:Sdp). MMTEL
// ICSI feature tags + sec-agree + session timers, exactly as imsd._send_invite.
std::string BuildInvite(const Context& c, const Dialog& d, std::string_view sdp, bool precond) {
std::string supported = precond ? "100rel, timer, precondition"
: "100rel, timer";
std::vector<std::string> l = {
std::format("INVITE {} SIP/2.0", d.ruri),
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
"Max-Forwards: 70",
std::format("Route: {}", c.route),
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
std::format("To: <{}>", d.ruri),
std::format("Call-ID: {}", d.callid),
"CSeq: 1 INVITE",
std::format("Contact: <sip:{}@{}>;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"", ContactUser(c), imsd::util::HostPort(c.local, c.portUs)),
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER",
std::format("Supported: {}", supported),
"Accept-Contact: *;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"",
};
// see BuildSubscribeReg: P-Preferred-Identity only when known
if (!c.ppi.empty()) l.push_back(std::format("P-Preferred-Identity: <{}>", c.ppi));
if (!c.panInfo.empty()) l.push_back(std::format("P-Access-Network-Info: {}", c.panInfo));
l.push_back(std::format("Security-Verify: {}", c.securityServer));
l.push_back("Proxy-Require: sec-agree");
l.push_back("Require: sec-agree");
l.push_back("Session-Expires: 1800;refresher=uac");
l.push_back("Min-SE: 90");
l.push_back("Content-Type: application/sdp");
l.push_back(std::format("Content-Length: {}", sdp.size()));
l.push_back("");
return detail::Join(l) + std::string(sdp);
}
// An in-dialog request (PRACK, BYE, UPDATE...) over the dialog route set.
// `extra` lines go after CSeq; an optional body sets Content-Type/Length.
std::string BuildInDialog(const Context& c, const Dialog& d, std::string_view method, int cseq, std::string_view viaBranch, std::span<const std::string> extra = {}, std::string_view body = {}, std::string_view ctype = {}) {
std::string target = d.remoteTarget.value_or(d.ruri);
std::string to = d.dialogTo.value_or(std::format("<{}>", d.ruri));
std::vector<std::string> l = {
std::format("{} {} SIP/2.0", method, target),
ViaLine(c, "TCP", c.portUs, viaBranch),
"Max-Forwards: 70",
std::format("Route: {}", DialogRoute(c, d)),
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
std::format("To: {}", to),
std::format("Call-ID: {}", d.callid),
std::format("CSeq: {} {}", cseq, method),
};
for (const std::string& e : extra)
l.push_back(e);
if (!body.empty()) {
l.push_back(std::format("Content-Type: {}", ctype));
l.push_back(std::format("Content-Length: {}", body.size()));
l.push_back("");
return detail::Join(l) + std::string(body);
}
l.push_back("Content-Length: 0");
return std::format("{}\r\n", detail::Join(l));
}
// ACK for a 2xx final: a new transaction over the dialog route to the
// remote target, echoing the dialog To (with the remote tag).
std::string BuildAck2xx(const Context& c, const Dialog& d, std::string_view viaBranch, std::string_view toHeader) {
std::string target = d.remoteTarget.value_or(d.ruri);
std::string to = d.dialogTo.value_or(std::string(toHeader));
std::vector<std::string> l = {
std::format("ACK {} SIP/2.0", target),
ViaLine(c, "TCP", c.portUs, viaBranch),
"Max-Forwards: 70",
std::format("Route: {}", DialogRoute(c, d)),
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
std::format("To: {}", to),
std::format("Call-ID: {}", d.callid),
"CSeq: 1 ACK",
"Content-Length: 0",
};
return std::format("{}\r\n", detail::Join(l));
}
// ACK for a non-2xx final: hop-by-hop over the INVITE's own branch and the
// registration Route, echoing the response's To.
std::string BuildAckNon2xx(const Context& c, const Dialog& d, std::string_view toHeader) {
std::vector<std::string> l = {
std::format("ACK {} SIP/2.0", d.ruri),
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
"Max-Forwards: 70",
std::format("Route: {}", c.route),
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
std::format("To: {}", toHeader),
std::format("Call-ID: {}", d.callid),
"CSeq: 1 ACK",
"Content-Length: 0",
};
return std::format("{}\r\n", detail::Join(l));
}
// CANCEL: RFC 3261 requires it copy the INVITE's branch + CSeq number.
std::string BuildCancel(const Context& c, const Dialog& d) {
std::vector<std::string> l = {
std::format("CANCEL {} SIP/2.0", d.ruri),
std::format("Via: SIP/2.0/TCP {};branch=z9hG4bK{};rport", imsd::util::HostPort(c.local, c.portUs), d.invBranch),
"Max-Forwards: 70",
std::format("Route: {}", c.route),
std::format("From: <{}>;tag={}", c.id.impu, d.itag),
std::format("To: <{}>", d.ruri),
std::format("Call-ID: {}", d.callid),
"CSeq: 1 CANCEL",
"Content-Length: 0",
};
return std::format("{}\r\n", detail::Join(l));
}
// A response to an inbound INVITE — the UAS side of call setup. Unlike
// BuildResponse200 (first Via only, fine for hop-by-hop CANCEL/BYE
// answers), an INVITE response travels the whole proxy chain back to the
// caller: it must echo EVERY Via in order, return the Record-Route set on
// dialog-establishing (1xx/2xx) responses, and mint the dialog's local
// half by tagging To (all but 100 Trying, which is hop-by-hop and
// tagless). 1xx/2xx carry our Contact (the dialog's remote target for the
// caller) and Allow; `extra` appends headers (RSeq, Session-Expires...);
// `sdp` turns a 200 into the answer.
std::string BuildInviteResponse(const Context& c, std::string_view invite, int code, std::string_view text, std::string_view toTag, std::optional<std::string_view> sdp = std::nullopt, std::span<const std::string> extra = {}) {
std::vector<std::string> l = {std::format("SIP/2.0 {} {}", code, text)};
for (auto v : imsd::sip::Headers(invite, "Via"))
l.push_back(std::format("Via: {}", v));
if (code < 300)
for (auto v : imsd::sip::Headers(invite, "Record-Route"))
l.push_back(std::format("Record-Route: {}", v));
if (auto f = imsd::sip::Header(invite, "From")) l.push_back(std::format("From: {}", *f));
if (auto t = imsd::sip::Header(invite, "To")) {
if (code == 100 || t->contains("tag=")) l.push_back(std::format("To: {}", *t));
else
l.push_back(std::format("To: {};tag={}", *t, toTag));
}
if (auto ci = imsd::sip::Header(invite, "Call-ID")) l.push_back(std::format("Call-ID: {}", *ci));
if (auto cs = imsd::sip::Header(invite, "CSeq")) l.push_back(std::format("CSeq: {}", *cs));
if (code != 100 && code < 300) {
l.push_back(std::format("Contact: <sip:{}@{}>", ContactUser(c), imsd::util::HostPort(c.local, c.portUs)));
l.push_back("Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER");
}
for (const std::string& e : extra)
l.push_back(e);
if (sdp) {
l.push_back("Content-Type: application/sdp");
l.push_back(std::format("Content-Length: {}", sdp->size()));
l.push_back("");
return detail::Join(l) + std::string(*sdp);
}
l.push_back("Content-Length: 0");
return std::format("{}\r\n", detail::Join(l));
}
// A 200 OK for an inbound request: echo Via/From/To/Call-ID/CSeq in order,
// then either an SDP answer (with our Contact) or an empty body.
// `extra` appends headers (Session-Expires echo for refreshes...).
std::string BuildResponse200(const Context& c, std::string_view request, std::optional<std::string_view> sdp = std::nullopt, std::span<const std::string> extra = {}) {
std::vector<std::string> l = {"SIP/2.0 200 OK"};
for (std::string_view h : {"Via", "From", "To", "Call-ID", "CSeq"})
if (auto v = imsd::sip::Header(request, h)) l.push_back(std::format("{}: {}", h, *v));
for (const auto& e : extra)
l.push_back(e);
if (sdp) {
l.push_back(std::format("Contact: <sip:{}@{}>", ContactUser(c), imsd::util::HostPort(c.local, c.portUs)));
l.push_back("Content-Type: application/sdp");
l.push_back(std::format("Content-Length: {}", sdp->size()));
l.push_back("");
return detail::Join(l) + std::string(*sdp);
}
l.push_back("Content-Length: 0");
return std::format("{}\r\n", detail::Join(l));
}
}

227
interfaces/Imsd-Sdp.cppm Normal file
View file

@ -0,0 +1,227 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Sdp — AMR-WB SDP offer/answer building + parsing.
The offer is a fixed AMR-WB (octet-aligned) + telephone-event shape with an
optional GSMA IR.92 QoS-precondition block; it is what commercial IMS cores
expect from a VoLTE UE, and its exact bytes are pinned by tests/Sdp. The
parser extracts only what the media plane needs: remote address/port, the
negotiated payload type, and octet-align mode — it reads answers to our
offers and inbound offers alike (same fields either way). BuildAnswer is the
terminating side: it answers an inbound offer with the offer's own payload
type numbers (RFC 3264) and mirrors its octet-align mode (RFC 4867 requires
both directions to use one mode). Pure std C++.
*/
export module Imsd:Sdp;
import std;
namespace imsd::sdp {
bool Is6(std::string_view addr) { return addr.contains(':'); }
// First token (up to whitespace/CR/LF) starting at `pos`.
std::string_view TokenAt(std::string_view body, std::size_t pos) {
std::size_t end = body.find_first_of(" \t\r\n", pos);
return body.substr(pos, end == std::string_view::npos ? std::string_view::npos : end - pos);
}
std::optional<int> ParseInt(std::string_view s) {
int v = 0;
auto [p, ec] = std::from_chars(s.data(), s.data() + s.size(), v);
if (ec != std::errc{} || p != s.data() + s.size()) return std::nullopt;
return v;
}
// First occurrence of `needle` anywhere in the body; nullopt if absent.
std::optional<std::size_t> Find(std::string_view body, std::string_view needle) {
std::size_t at = body.find(needle);
if (at == std::string_view::npos) return std::nullopt;
return at;
}
}
export namespace imsd::sdp {
struct Offer {
std::string local; // connection address (v4 or v6)
int rtpPort = 0;
std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes
bool precond = false; // IR.92 QoS preconditions block
std::string currRemote = "none"; // a=curr:qos remote <this>
};
// AMR-WB (octet-aligned) offer. Byte layout is pinned by tests/Sdp.
std::string BuildOffer(const Offer& o) {
std::string ipver = Is6(o.local) ? "IP6" : "IP4";
std::string s;
s += "v=0\r\n";
s += std::format("o=- {} {} IN {} {}\r\n", o.sessionId, o.sessionId, ipver, o.local);
s += "s=-\r\n";
s += std::format("c=IN {} {}\r\n", ipver, o.local);
s += "t=0 0\r\n";
s += std::format("m=audio {} RTP/AVP 97 98\r\n", o.rtpPort);
s += "b=AS:41\r\n";
s += "b=RS:512\r\n";
s += "b=RR:1536\r\n";
s += "a=rtpmap:97 AMR-WB/16000/1\r\n";
s += "a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n";
s += "a=rtpmap:98 telephone-event/16000\r\n";
s += "a=fmtp:98 0-15\r\n";
s += "a=ptime:20\r\n";
s += "a=maxptime:240\r\n";
if (o.precond) {
s += "a=curr:qos local sendrecv\r\n";
s += std::format("a=curr:qos remote {}\r\n", o.currRemote);
s += "a=des:qos mandatory local sendrecv\r\n";
s += "a=des:qos mandatory remote sendrecv\r\n";
}
s += "a=sendrecv\r\n";
return s;
}
struct Answer {
std::string ip; // empty = no c= line found
int port = -1; // -1 = no m=audio line found
int payloadType = -1;
bool octetAlign = false;
std::string codec = "AMR-WB";
};
// Payload-type preference: AMR-WB with octet-align=1 > AMR-WB > AMR with
// octet-align=1 > AMR. Octet-aligned wins within a codec because it is
// the media leg's native, call-proven format (bandwidth-efficient is
// supported but was the s56 static-audio culprit) — when the peer offers
// both variants, taking the octet-aligned pt keeps real calls on the
// battle-tested path. With no rtpmap match at all, the first pt is used
// and codec comes back EMPTY — the body named no AMR variant (e.g. a
// PCMA offer), and pretending otherwise would defeat codec checks
// upstream.
Answer ParseAnswer(std::string_view body) {
Answer a;
if (auto at = Find(body, "c=IN IP6 ")) a.ip = TokenAt(body, *at + 9);
else if (auto at4 = Find(body, "c=IN IP4 ")) a.ip = TokenAt(body, *at4 + 9);
auto m = Find(body, "m=audio ");
if (!m) return a;
std::string_view portTok = TokenAt(body, *m + 8);
auto port = ParseInt(portTok);
// both the port and the " RTP/AVP " literal are required
std::size_t afterPort = *m + 8 + portTok.size();
constexpr std::string_view Avp = " RTP/AVP ";
if (!port || !body.substr(afterPort).starts_with(Avp)) return a;
a.port = *port;
// payload-type list: digits + spaces up to CR/LF
std::size_t ptsBegin = afterPort + Avp.size();
std::size_t ptsEnd = body.find_first_not_of("0123456789 ", ptsBegin);
std::string_view ptsRun = body.substr(ptsBegin, ptsEnd == std::string_view::npos ? std::string_view::npos : ptsEnd - ptsBegin);
std::vector<int> pts;
for (auto part : std::views::split(ptsRun, ' '))
if (auto v = ParseInt(std::string_view(part))) pts.push_back(*v);
if (pts.empty()) return a;
auto hasOctetAlign = [&](int p) {
auto fm = Find(body, std::format("a=fmtp:{} ", p));
if (!fm) return false;
std::size_t lineEnd = body.find_first_of("\r\n", *fm);
std::string_view line = body.substr(*fm, lineEnd == std::string_view::npos ? std::string_view::npos : lineEnd - *fm);
return line.contains("octet-align=1");
};
int wbOa = -1;
int wb = -1;
int nbOa = -1;
int nb = -1;
for (int p : pts) {
std::string key = std::format("a=rtpmap:{} ", p);
auto at = Find(body, key);
if (!at) continue;
// codec name: up to '/', CR or LF
std::size_t nameBegin = *at + key.size();
std::size_t nameEnd = body.find_first_of("/\r\n", nameBegin);
std::string_view name = body.substr(nameBegin, nameEnd == std::string_view::npos ? std::string_view::npos : nameEnd - nameBegin);
if (name.contains("AMR-WB")) {
if (wb == -1) wb = p;
if (wbOa == -1 && hasOctetAlign(p)) wbOa = p;
} else if (name.contains("AMR")) {
if (nb == -1) nb = p;
if (nbOa == -1 && hasOctetAlign(p)) nbOa = p;
}
}
if (wbOa != -1 || wb != -1) {
a.payloadType = wbOa != -1 ? wbOa : wb;
a.codec = "AMR-WB";
} else if (nbOa != -1 || nb != -1) {
a.payloadType = nbOa != -1 ? nbOa : nb;
a.codec = "AMR";
} else {
a.payloadType = pts.front();
a.codec.clear();
}
a.octetAlign = hasOctetAlign(a.payloadType);
return a;
}
// Payload type of `telephone-event/<clockRate>` in a body's rtpmap lines
// (DTMF, RFC 4733); nullopt when the offer doesn't carry one.
std::optional<int> TelephoneEventPt(std::string_view body, int clockRate) {
std::string needle = std::format("telephone-event/{}", clockRate);
std::size_t pos = 0;
while ((pos = body.find("a=rtpmap:", pos)) != std::string_view::npos) {
std::size_t ptBegin = pos + 9;
std::size_t lineEnd = body.find_first_of("\r\n", ptBegin);
std::size_t sp = body.find(' ', ptBegin);
if (sp != std::string_view::npos && (lineEnd == std::string_view::npos || sp < lineEnd)) {
auto pt = ParseInt(body.substr(ptBegin, sp - ptBegin));
std::string_view name = body.substr(sp + 1, (lineEnd == std::string_view::npos ? body.size() : lineEnd) - sp - 1);
if (pt && name.starts_with(needle)) return pt;
}
pos = ptBegin;
}
return std::nullopt;
}
struct AnswerSpec {
std::string local; // connection address (v4 or v6)
int rtpPort = 0;
std::uint64_t sessionId = 0; // o= sess-id/version; caller randomizes
int payloadType = 97; // the offer's pt for the chosen codec
bool octetAlign = true; // mirror of the offer's mode
std::string codec = "AMR-WB"; // "AMR-WB" or "AMR"
std::optional<int> dtmfPt; // the offer's telephone-event pt, if any
};
// SDP answer to an inbound audio offer: single codec at the offer's own
// payload type, octet-align echoed only when the offer used it (absence
// = bandwidth-efficient, RFC 4867 §8.1), telephone-event kept when
// offered. Byte layout pinned by tests/Sdp.
std::string BuildAnswer(const AnswerSpec& s) {
std::string ipver = Is6(s.local) ? "IP6" : "IP4";
int rate = s.codec == "AMR-WB" ? 16000 : 8000;
std::string out;
out += "v=0\r\n";
out += std::format("o=- {} {} IN {} {}\r\n", s.sessionId, s.sessionId, ipver, s.local);
out += "s=-\r\n";
out += std::format("c=IN {} {}\r\n", ipver, s.local);
out += "t=0 0\r\n";
if (s.dtmfPt) out += std::format("m=audio {} RTP/AVP {} {}\r\n", s.rtpPort, s.payloadType, *s.dtmfPt);
else
out += std::format("m=audio {} RTP/AVP {}\r\n", s.rtpPort, s.payloadType);
out += std::format("b=AS:{}\r\n", s.codec == "AMR-WB" ? 41 : 30);
out += "b=RS:512\r\n";
out += "b=RR:1536\r\n";
out += std::format("a=rtpmap:{} {}/{}/1\r\n", s.payloadType, s.codec, rate);
if (s.octetAlign) out += std::format("a=fmtp:{} octet-align=1\r\n", s.payloadType);
if (s.dtmfPt) {
out += std::format("a=rtpmap:{} telephone-event/{}\r\n", *s.dtmfPt, rate);
out += std::format("a=fmtp:{} 0-15\r\n", *s.dtmfPt);
}
out += "a=ptime:20\r\n";
out += "a=maxptime:240\r\n";
out += "a=sendrecv\r\n";
return out;
}
}

236
interfaces/Imsd-Sip.cppm Normal file
View file

@ -0,0 +1,236 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Sip — SIP message text layer + TCP framing.
Deliberately a text layer, not a full RFC 3261 parser: the engine works on
whole messages as strings and pulls out exactly what it needs (headers,
status codes, the granted registration lifetime), which keeps every message
loggable and testable verbatim. Edge-case behavior (empty header values,
line anchoring, keepalive discards) is pinned by tests/Sip — a change that
flips one of those tests is a behavior change, not a cleanup.
Pure std C++ (no GLib).
*/
export module Imsd:Sip;
import std;
namespace imsd::sip {
constexpr char AsciiLower(char c) {
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
}
constexpr bool IEqualsPrefix(std::string_view text, std::string_view prefix) {
if (text.size() < prefix.size()) return false;
for (std::size_t i = 0; i < prefix.size(); i++)
if (AsciiLower(text[i]) != AsciiLower(prefix[i])) return false;
return true;
}
// spaces, tabs, CR, LF off both ends
constexpr std::string_view Trim(std::string_view s) {
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r' || s.front() == '\n'))
s.remove_prefix(1);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r' || s.back() == '\n'))
s.remove_suffix(1);
return s;
}
// Iterate \n-separated lines (any trailing CR stays on the line and is
// stripped by Trim when a value is extracted).
template <typename F>
void ForEachLine(std::string_view msg, F&& f) {
std::size_t pos = 0;
while (pos <= msg.size()) {
std::size_t nl = msg.find('\n', pos);
std::string_view line = msg.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos);
if (!f(line)) return;
if (nl == std::string_view::npos) return;
pos = nl + 1;
}
}
// One "Name: value" match: name anchored at line start (case-insensitive,
// per RFC 3261 header names), immediately followed by the colon — no
// whitespace before it — and at least one character (even just the CR)
// after it. A bare "Name:\r" line matches with value ""; a "Name:" that
// ends the buffer does not match. Matching never crosses the line
// boundary: an empty-valued header yields "", NOT the next line's
// content (a regex-based implementation whose whitespace class eats the
// CRLF gets that wrong — pinned by the "bare header" test).
std::optional<std::string_view> MatchHeaderLine(std::string_view line, std::string_view name) {
if (!IEqualsPrefix(line, name)) return std::nullopt;
if (line.size() <= name.size() || line[name.size()] != ':') return std::nullopt;
std::string_view rest = line.substr(name.size() + 1);
if (rest.empty()) return std::nullopt;
return Trim(rest);
}
// First run of ASCII digits starting at `s`; nullopt if none.
std::optional<long> LeadingDigits(std::string_view s) {
std::size_t n = 0;
while (n < s.size() && s[n] >= '0' && s[n] <= '9')
n++;
if (n == 0) return std::nullopt;
long v = 0;
auto [_, ec] = std::from_chars(s.data(), s.data() + n, v);
if (ec != std::errc{}) return std::nullopt;
return v;
}
}
export namespace imsd::sip {
// Value of the first `name:` header, trimmed; nullopt if absent.
std::optional<std::string_view> Header(std::string_view msg, std::string_view name) {
std::optional<std::string_view> out;
ForEachLine(msg, [&](std::string_view line) {
if (auto v = MatchHeaderLine(line, name)) {
out = *v;
return false;
}
return true;
});
return out;
}
// Every `name:` header value, in order.
std::vector<std::string_view> Headers(std::string_view msg, std::string_view name) {
std::vector<std::string_view> out;
ForEachLine(msg, [&](std::string_view line) {
if (auto v = MatchHeaderLine(line, name)) out.push_back(*v);
return true;
});
return out;
}
// The 3-digit code of a "SIP/2.0 NNN ..." response start-line; nullopt
// for requests / anything else. Only matches at the start of the message.
std::optional<int> Status(std::string_view msg) {
constexpr std::string_view Prefix = "SIP/2.0 ";
if (!msg.starts_with(Prefix) || msg.size() < Prefix.size() + 3) return std::nullopt;
std::string_view d = msg.substr(Prefix.size(), 3);
for (char c : d)
if (c < '0' || c > '9') return std::nullopt;
int v = 0;
std::from_chars(d.data(), d.data() + 3, v);
return v;
}
// Where a UDP response to this request must go (RFC 3261 18.2.2): the
// top Via's sent-by port — unless the Via carries rport, in which case
// the caller should reply to the request's source port instead
// (nullopt). Also nullopt when the Via has no explicit port (5060 is
// the default, but our P-CSCF flows always carry one). Handles
// "[v6]:port" and "host:port" sent-by forms.
std::optional<int> ViaResponsePort(std::string_view msg) {
auto via = Header(msg, "Via");
if (!via) return std::nullopt;
std::string_view v = *via;
if (std::size_t sc = v.find(';'); sc != std::string_view::npos) {
if (v.substr(sc).find("rport") != std::string_view::npos) return std::nullopt;
v = v.substr(0, sc);
}
std::size_t colon;
if (std::size_t br = v.rfind(']'); br != std::string_view::npos)
colon = v.find(':', br); // v6: port only after the ]
else
colon = v.rfind(':'); // v4/hostname (transport token
// has no colon of its own)
if (colon == std::string_view::npos) return std::nullopt;
if (auto p = LeadingDigits(Trim(v.substr(colon + 1))))
if (*p > 0 && *p < 65536) return static_cast<int>(*p);
return std::nullopt;
}
// Registration lifetime granted by a REGISTER 200 OK: the Contact
// header's expires= parameter (the registrar's answer for our binding),
// else the Expires header; nullopt if absent. "expires=" is searched
// anywhere in each Contact value, not parameter-parsed.
std::optional<long> GrantedExpires(std::string_view msg) {
for (std::string_view contact : Headers(msg, "Contact")) {
std::size_t at = contact.find("expires=");
if (at != std::string_view::npos)
if (auto v = LeadingDigits(contact.substr(at + 8))) return v;
}
if (auto e = Header(msg, "Expires")) {
std::string_view v = *e;
if (!v.empty() && v.find_first_not_of("0123456789") == std::string_view::npos) return LeadingDigits(v);
}
return std::nullopt;
}
// Caller identity of an inbound request: the number/user of the first
// P-Asserted-Identity (the network-asserted identity, RFC 3325), falling
// back to From. Understands "<tel:+31612345678>", "<sip:+31612345678@
// host;user=phone>" (user part) and bare values; returns "" if neither
// header yields one. Anonymous callers come out as "anonymous".
std::string CallerId(std::string_view msg) {
auto extract = [](std::string_view v) -> std::optional<std::string> {
std::string_view uri = v;
std::size_t lt = v.find('<');
if (lt != std::string_view::npos) {
std::size_t gt = v.find('>', lt);
if (gt != std::string_view::npos) uri = v.substr(lt + 1, gt - lt - 1);
} else {
std::size_t semi = v.find(';');
if (semi != std::string_view::npos) uri = v.substr(0, semi);
uri = Trim(uri);
}
if (uri.starts_with("tel:")) {
std::string_view n = uri.substr(4);
std::size_t semi = n.find(';');
if (semi != std::string_view::npos) n = n.substr(0, semi);
return std::string(n);
}
if (uri.starts_with("sip:") || uri.starts_with("sips:")) {
std::string_view n = uri.substr(uri.find(':') + 1);
std::size_t end = n.find_first_of("@;");
if (end == std::string_view::npos) return std::nullopt;
return std::string(n.substr(0, end));
}
return std::nullopt;
};
for (std::string_view h : Headers(msg, "P-Asserted-Identity"))
if (auto n = extract(h)) return *n;
if (auto f = Header(msg, "From"))
if (auto n = extract(*f)) return *n;
return "";
}
// SIP-over-TCP stream framing, transport-free: feed received bytes into
// Append(), pull complete messages (headers + Content-Length body) out
// of TryExtract(). Leading CRLFs (RFC 5626 keepalive pongs) are
// discarded.
class FrameBuffer {
public:
void Append(std::string_view data) { buf_.append(data); }
// One complete SIP message off the front of the buffer, or nullopt
// if more bytes are needed. Content-Length is located with the same
// line-anchored matching rules as Header().
std::optional<std::string> TryExtract() {
std::size_t keep = buf_.find_first_not_of("\r\n");
buf_.erase(0, keep == std::string::npos ? buf_.size() : keep);
std::size_t idx = buf_.find("\r\n\r\n");
if (idx == std::string::npos) return std::nullopt;
std::string_view head = std::string_view(buf_).substr(0, idx);
long clen = 0;
if (auto v = Header(head, "Content-Length")) clen = LeadingDigits(*v).value_or(0);
std::size_t total = idx + 4 + static_cast<std::size_t>(clen);
if (buf_.size() < total) return std::nullopt;
std::string msg = buf_.substr(0, total);
buf_.erase(0, total);
return msg;
}
std::size_t Size() const { return buf_.size(); }
private:
std::string buf_;
};
}

261
interfaces/Imsd-Util.cppm Normal file
View file

@ -0,0 +1,261 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
/*
Imsd:Util — small self-contained primitives the rest of the engine builds on:
random SIP tokens, IPv6 detection + host/port formatting, hex/base64 decoding,
and MD5 (RFC 1321). None of the standard library ships these last three, and
the IMS AKAv1-MD5 digest (Imsd:Aka) needs MD5 over raw bytes plus base64 to
unwrap the RAND||AUTN nonce, so they live here rather than pulling in a crypto
dependency. Pure std C++ (no GLib, no I/O); behavior is pinned by tests/Util.
*/
export module Imsd:Util;
import std;
export namespace imsd::util {
// The UE's protected client/server ports (TS 33.203 sec-agree). Single
// source of truth: Imsd:Messages puts them in Security-Client and every
// protected Via/Contact, Imsd:Ipsec selects on them in the SA policies,
// and the daemon binds them. They are also mirrored in the packaged
// firewall rules (packaging/ims-pdn-up.sh) — changing them here breaks
// an installed package's nftables accept rules.
constexpr int PortUc = 45061;
constexpr int PortUs = 45062;
// An address is IPv6 iff it contains a colon (matches the Python is6()).
inline bool Is6(std::string_view addr) { return addr.contains(':'); }
// "[addr]:port" for IPv6, "addr:port" for IPv4 — the SIP host:port form.
inline std::string HostPort(std::string_view addr, int port) {
return Is6(addr) ? std::format("[{}]:{}", addr, port)
: std::format("{}:{}", addr, port);
}
// A random token of `n` chars over [a-z0-9] — SIP branch/tag/Call-ID
// material (the Python rand()). The generator is a parameter so tests can
// pin output; the default constructor seeds from random_device.
class Rng {
public:
Rng() {
std::random_device rd;
gen_.seed((static_cast<std::uint64_t>(rd()) << 32) ^ rd());
}
explicit Rng(std::uint64_t seed) { gen_.seed(seed); }
std::string Token(std::size_t n) {
static constexpr std::string_view Alphabet =
"abcdefghijklmnopqrstuvwxyz0123456789";
std::uniform_int_distribution<std::size_t> dist(0, Alphabet.size() - 1);
std::string s;
s.reserve(n);
for (std::size_t i = 0; i < n; i++)
s.push_back(Alphabet[dist(gen_)]);
return s;
}
// A random integer in [lo, hi] — used for SPIs, RTP SSRC/seq/ts.
std::uint32_t UInt(std::uint32_t lo, std::uint32_t hi) {
std::uniform_int_distribution<std::uint32_t> dist(lo, hi);
return dist(gen_);
}
private:
std::mt19937_64 gen_;
};
// RFC 4122 version-4 UUID from the caller's Rng — the shape the stock
// modem mints for its registered Contact user-part (oracle capture
// 2026-07-21, journal/ims.md s53).
inline std::string Uuid4(Rng& rng) {
std::uint32_t a = rng.UInt(0, 0xFFFFFFFFu);
std::uint32_t b = rng.UInt(0, 0xFFFFFFFFu);
std::uint32_t c = rng.UInt(0, 0xFFFFFFFFu);
std::uint32_t d = rng.UInt(0, 0xFFFFFFFFu);
b = (b & 0xFFFF0FFFu) | 0x00004000u; // version nibble = 4
c = (c & 0x3FFFFFFFu) | 0x80000000u; // variant bits = 10
return std::format("{:08x}-{:04x}-{:04x}-{:04x}-{:04x}{:08x}", a, b >> 16, b & 0xFFFFu, c >> 16, c & 0xFFFFu, d);
}
// Lowercase hex of a byte span (no separators).
inline std::string ToHex(std::span<const std::uint8_t> bytes) {
std::string s;
s.reserve(bytes.size() * 2);
for (std::uint8_t b : bytes)
s += std::format("{:02x}", b);
return s;
}
// Decode a hex string (any case, even length) to bytes; nullopt on a bad
// char or odd length. Colons are skipped so qmicli's "AB:CD:.." APDU form
// decodes directly.
inline std::optional<std::vector<std::uint8_t>> FromHex(std::string_view hex) {
auto nibble = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
std::vector<std::uint8_t> out;
int hi = -1;
for (char c : hex) {
if (c == ':' || c == ' ') continue;
int v = nibble(c);
if (v < 0) return std::nullopt;
if (hi < 0) {
hi = v;
} else {
out.push_back(static_cast<std::uint8_t>((hi << 4) | v));
hi = -1;
}
}
if (hi >= 0)
return std::nullopt; // odd number of nibbles
return out;
}
// Standard base64 decode (RFC 4648, '+' '/', '=' padding). Whitespace is
// ignored; nullopt on an invalid character. The IMS 401 nonce is the
// base64 of RAND(16)||AUTN(16)||... — this unwraps it.
inline std::optional<std::vector<std::uint8_t>> FromBase64(std::string_view in) {
auto val = [](char c) -> int {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
};
std::vector<std::uint8_t> out;
int acc = 0;
int bits = 0;
for (char c : in) {
if (c == '=') break;
if (c == '\r' || c == '\n' || c == ' ' || c == '\t') continue;
int v = val(c);
if (v < 0) return std::nullopt;
acc = (acc << 6) | v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<std::uint8_t>((acc >> bits) & 0xFF));
}
}
return out;
}
// ---- MD5 (RFC 1321), streaming over arbitrary bytes. ------------------
class Md5 {
public:
Md5() { Reset(); }
void Update(std::span<const std::uint8_t> data) {
std::size_t i = 0;
std::size_t bufFill = static_cast<std::size_t>((count_ >> 3) & 0x3F);
count_ += static_cast<std::uint64_t>(data.size()) << 3;
if (bufFill) {
std::size_t need = 64 - bufFill;
if (data.size() < need) {
std::copy_n(data.begin(), data.size(), buf_.begin() + bufFill);
return;
}
std::copy_n(data.begin(), need, buf_.begin() + bufFill);
Transform(buf_.data());
i = need;
}
for (; i + 64 <= data.size(); i += 64)
Transform(data.data() + i);
std::copy(data.begin() + i, data.end(), buf_.begin());
}
void Update(std::string_view s) {
Update(std::span<const std::uint8_t>(reinterpret_cast<const std::uint8_t*>(s.data()), s.size()));
}
std::array<std::uint8_t, 16> Digest() {
std::array<std::uint8_t, 8> lenBytes{};
for (int i = 0; i < 8; i++)
lenBytes[i] = static_cast<std::uint8_t>((count_ >> (8 * i)) & 0xFF);
static constexpr std::uint8_t pad[64] = {0x80};
std::size_t bufFill = static_cast<std::size_t>((count_ >> 3) & 0x3F);
std::size_t padLen = (bufFill < 56) ? (56 - bufFill) : (120 - bufFill);
Update(std::span<const std::uint8_t>(pad, padLen));
Update(std::span<const std::uint8_t>(lenBytes.data(), 8));
std::array<std::uint8_t, 16> out{};
for (int i = 0; i < 4; i++)
for (int j = 0; j < 4; j++)
out[i * 4 + j] = static_cast<std::uint8_t>((state_[i] >> (8 * j)) & 0xFF);
return out;
}
// Convenience: hex digest of a single buffer.
static std::string HexDigest(std::span<const std::uint8_t> data) {
Md5 m;
m.Update(data);
auto d = m.Digest();
return ToHex(d);
}
static std::string HexDigest(std::string_view s) {
return HexDigest(std::span<const std::uint8_t>(reinterpret_cast<const std::uint8_t*>(s.data()), s.size()));
}
private:
std::array<std::uint32_t, 4> state_;
std::uint64_t count_; // bit count
std::array<std::uint8_t, 64> buf_;
void Reset() {
state_ = {0x67452301u, 0xefcdab89u, 0x98badcfeu, 0x10325476u};
count_ = 0;
buf_.fill(0);
}
static std::uint32_t Rotl(std::uint32_t x, int c) {
return (x << c) | (x >> (32 - c));
}
void Transform(const std::uint8_t* block) {
static constexpr std::uint32_t K[64] = {
0xd76aa478,0xe8c7b756,0x242070db,0xc1bdceee,0xf57c0faf,0x4787c62a,
0xa8304613,0xfd469501,0x698098d8,0x8b44f7af,0xffff5bb1,0x895cd7be,
0x6b901122,0xfd987193,0xa679438e,0x49b40821,0xf61e2562,0xc040b340,
0x265e5a51,0xe9b6c7aa,0xd62f105d,0x02441453,0xd8a1e681,0xe7d3fbc8,
0x21e1cde6,0xc33707d6,0xf4d50d87,0x455a14ed,0xa9e3e905,0xfcefa3f8,
0x676f02d9,0x8d2a4c8a,0xfffa3942,0x8771f681,0x6d9d6122,0xfde5380c,
0xa4beea44,0x4bdecfa9,0xf6bb4b60,0xbebfbc70,0x289b7ec6,0xeaa127fa,
0xd4ef3085,0x04881d05,0xd9d4d039,0xe6db99e5,0x1fa27cf8,0xc4ac5665,
0xf4292244,0x432aff97,0xab9423a7,0xfc93a039,0x655b59c3,0x8f0ccc92,
0xffeff47d,0x85845dd1,0x6fa87e4f,0xfe2ce6e0,0xa3014314,0x4e0811a1,
0xf7537e82,0xbd3af235,0x2ad7d2bb,0xeb86d391};
static constexpr int S[64] = {
7,12,17,22,7,12,17,22,7,12,17,22,7,12,17,22,
5,9,14,20,5,9,14,20,5,9,14,20,5,9,14,20,
4,11,16,23,4,11,16,23,4,11,16,23,4,11,16,23,
6,10,15,21,6,10,15,21,6,10,15,21,6,10,15,21};
std::uint32_t M[16];
for (int i = 0; i < 16; i++)
M[i] = static_cast<std::uint32_t>(block[i * 4]) |
(static_cast<std::uint32_t>(block[i * 4 + 1]) << 8) |
(static_cast<std::uint32_t>(block[i * 4 + 2]) << 16) |
(static_cast<std::uint32_t>(block[i * 4 + 3]) << 24);
std::uint32_t a = state_[0];
std::uint32_t b = state_[1];
std::uint32_t c = state_[2];
std::uint32_t d = state_[3];
for (int i = 0; i < 64; i++) {
std::uint32_t f;
int g;
if (i < 16) { f = (b & c) | (~b & d); g = i; }
else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) & 15; }
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) & 15; }
else { f = c ^ (b | ~d); g = (7 * i) & 15; }
f = f + a + K[i] + M[g];
a = d; d = c; c = b;
b = b + Rotl(f, S[i]);
}
state_[0] += a; state_[1] += b; state_[2] += c; state_[3] += d;
}
};
}

15
interfaces/Imsd.cppm Normal file
View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
/*
imsd — userspace IMS/VoLTE daemon for mainline Linux phones.
*/
export module Imsd;
export import :Util;
export import :Aka;
export import :Ipsec;
export import :Sip;
export import :Sdp;
export import :Messages;
export import :Engine;

858
lint-rules.h Normal file
View file

@ -0,0 +1,858 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#pragma once
// This repo's house-style lint rules. Deliberately project-local — the rule
// set is NOT part of the crafter-build library, so downstream consumers are
// never nudged toward one team's style. Include from project.cpp AFTER
// `import std;` and `import Crafter.Build;` (the header uses both and has no
// includes of its own), then call AddProjectLintRules(cfg).
//
// Report-only rules are registered first so their file:line numbers always
// match the on-disk file; transforms run last because each one sees the
// previous transform's output, which can shift line numbers.
//
// All detection runs on ctx.CommentStripped() — comments and string/char
// literal bodies are blanked to spaces with newlines preserved, so byte
// offsets and line numbers in the stripped text are valid in ctx.content.
// Transforms use that property directly: find in stripped, edit in content.
namespace ProjectLint {
inline bool IsCppFile(const Crafter::LintContext& ctx) {
std::string ext = ctx.file.extension().string();
return ext == ".cpp" || ext == ".cppm" || ext == ".h";
}
inline std::vector<std::string_view> Lines(std::string_view text) {
std::vector<std::string_view> lines;
std::size_t start = 0;
while (start <= text.size()) {
std::size_t end = text.find('\n', start);
if (end == std::string_view::npos) {
if (start < text.size()) lines.push_back(text.substr(start));
break;
}
lines.push_back(text.substr(start, end - start));
start = end + 1;
}
return lines;
}
inline std::string_view Trim(std::string_view s) {
while (!s.empty() && (s.front() == ' ' || s.front() == '\t' || s.front() == '\r')) s.remove_prefix(1);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) s.remove_suffix(1);
return s;
}
inline bool IsWordChar(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
}
// Net '(' minus ')' on one stripped line.
inline std::int64_t ParenDelta(std::string_view s) {
std::int64_t d = 0;
for (char c : s) {
if (c == '(') ++d;
if (c == ')') --d;
}
return d;
}
inline bool IsPascalCase(std::string_view name) {
return !name.empty() && name[0] >= 'A' && name[0] <= 'Z' && !name.contains('_');
}
inline bool IsCamelCase(std::string_view name) {
// One trailing underscore is the member-shadowing-a-keyword convention
// (requires_, label_) — allowed.
if (name.ends_with('_')) name.remove_suffix(1);
return !name.empty() && ((name[0] >= 'a' && name[0] <= 'z') || name[0] == '_') && name.find('_', 1) == std::string_view::npos;
}
// The identifier ending right before position `pos` (exclusive), or empty.
inline std::string_view WordBefore(std::string_view s, std::size_t pos) {
std::size_t end = pos;
while (end > 0 && (s[end - 1] == ' ' || s[end - 1] == '\t')) --end;
std::size_t begin = end;
while (begin > 0 && (IsWordChar(s[begin - 1]) || s[begin - 1] == '~')) --begin;
return s.substr(begin, end - begin);
}
inline void AddProjectLintRules(Crafter::Configuration& cfg) {
using Crafter::LintContext;
// ---------------- report-only rules ----------------
// License header: SPDX identifier on line 1, copyright on line 2, blank
// line 3.
cfg.AddLintRule("spdx-header", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
if (!ctx.Line(1).starts_with("// SPDX-License-Identifier:")) {
ctx.Report(1, "first line must be // SPDX-License-Identifier: ...");
}
if (!ctx.Line(2).starts_with("// SPDX-FileCopyrightText:")) {
ctx.Report(2, "second line must be // SPDX-FileCopyrightText: ...");
}
if (ctx.lines.size() >= 3 && !Trim(ctx.Line(3)).empty()) {
ctx.Report(3, "third line must be blank (separates the license header)");
}
});
cfg.AddLintRule("no-tabs", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
if (ctx.Line(n).contains('\t')) ctx.Report(n, "tab character (use spaces)");
}
});
// Naming: functions/types PascalCase, variables camelCase, statics and
// namespace-scope globals PascalCase. A line-based scope tracker decides
// whether a declaration sits at namespace scope (global) or inside a
// function/type (local/member). Heuristic by nature — report-only.
cfg.AddLintRule("naming", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
enum class Scope { Namespace, Type, Function, Other };
std::vector<Scope> stack;
auto currentScope = [&]() { return stack.empty() ? Scope::Namespace : stack.back(); };
static const std::regex typeDecl(R"(\b(?:class|struct|union)\s+(?:CRAFTER_API\s+)?([A-Za-z_]\w*))");
static const std::regex enumDecl(R"(\benum\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*))");
static const std::regex usingDecl(R"(^\s*using\s+([A-Za-z_]\w*)\s*=)");
static const std::regex varDecl(
R"(^\s*((?:static|constexpr|const|inline|mutable|thread_local|export|CRAFTER_API)\s+)*)"
R"((?:std::)?[A-Za-z_][\w:]*(?:<[^;={]*>)?(?:\s*[&*])*\s+([A-Za-z_]\w*)\s*(=|;|\{))");
static const std::unordered_set<std::string_view> keywords = {
"if", "for", "while", "switch", "catch", "return", "else", "do", "case", "goto",
"new", "delete", "throw", "using", "namespace", "template", "typedef", "friend",
"public", "private", "protected", "class", "struct", "enum", "union", "import",
"module", "export", "break", "continue", "co_return", "co_await", "co_yield",
"sizeof", "alignof", "decltype", "static_assert", "operator", "try", "requires",
};
// Cumulative paren depth at the start of each line: declaration and
// function checks only run at depth 0, so wrapped parameter lists and
// continuation lines (`) {` closers) never look like declarations.
std::int64_t parenDepth = 0;
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string_view trimmed = Trim(lines[i]);
std::string lineStr(lines[i]);
std::smatch m;
bool atDepth0 = parenDepth == 0;
parenDepth = std::max<std::int64_t>(0, parenDepth + ParenDelta(lines[i]));
if (!trimmed.starts_with('#') && atDepth0) {
// Type / alias names must be PascalCase.
if (std::regex_search(lineStr, m, typeDecl) || std::regex_search(lineStr, m, enumDecl)) {
std::string name = m[1].str();
if (!keywords.contains(name) && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("type '{}' should be PascalCase", name));
}
}
if (std::regex_search(lineStr, m, usingDecl) && !IsPascalCase(m[1].str())) {
ctx.Report(i + 1, std::format("type alias '{}' should be PascalCase", m[1].str()));
}
// Function definitions: identifier before the first '(' on a
// line that ends where a body opens or closes — a multi-line
// def's trailing '{' or a one-liner's trailing '}'. Statement
// calls with inline lambda arguments look similar
// (`bool x = std::any_of(..., [](T v) { ... });`) but end in
// ';' and/or carry '=' before the name — both excluded.
// Lambdas ("](") and control keywords are skipped; ctors/
// dtors pass the Pascal check by construction; `main` and
// operators exempt.
std::size_t bodyBrace = trimmed.find('{');
bool functionShaped = trimmed.ends_with('{') || trimmed.ends_with('}');
if (functionShaped && currentScope() != Scope::Function && !trimmed.starts_with("return")) {
std::size_t paren = trimmed.find('(');
if (paren != std::string_view::npos && paren > 0 && paren < bodyBrace) {
std::string_view name = WordBefore(trimmed, paren);
char before = trimmed[paren - 1];
bool looksLikeDef = !name.empty() && IsWordChar(before)
&& !keywords.contains(name) && name != "main"
&& !name.starts_with('~');
// Require a type token before the name (or a
// qualified Class::Name), with no '=' in front —
// plain calls and initializations don't match.
if (looksLikeDef) {
std::size_t nameStart = trimmed.rfind(name, paren);
std::string_view prefix = Trim(trimmed.substr(0, nameStart));
looksLikeDef = !prefix.empty() && !prefix.contains('=')
&& (IsWordChar(prefix.back()) || prefix.back() == '>'
|| prefix.back() == '*' || prefix.back() == '&'
|| prefix.ends_with("::"));
}
if (looksLikeDef && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("function '{}' should be PascalCase", name));
}
}
}
// Variable declarations: camelCase locally, PascalCase for
// statics and namespace-scope globals.
if (std::regex_search(lineStr, m, varDecl)) {
std::string name = m[2].str();
std::string_view typeToken = Trim(std::string_view(lineStr).substr(0, static_cast<std::size_t>(m.position(2))));
std::string_view firstWord = typeToken.substr(0, typeToken.find_first_of(" \t<"));
if (!keywords.contains(firstWord) && !keywords.contains(name)) {
bool isStatic = lineStr.contains("static ");
// constexpr variables are compile-time constants —
// constant naming (PascalCase) like statics/globals.
bool isConstexpr = lineStr.contains("constexpr ");
bool global = currentScope() == Scope::Namespace;
if ((isStatic || global || isConstexpr) && !IsPascalCase(name)) {
ctx.Report(i + 1, std::format("{} '{}' should be PascalCase",
isStatic ? "static variable"
: isConstexpr ? "constexpr constant"
: "global variable", name));
} else if (!isStatic && !global && !isConstexpr && !IsCamelCase(name)) {
ctx.Report(i + 1, std::format("variable '{}' should be camelCase", name));
}
}
}
}
// Scope tracking: classify each '{' opened on this line; pop on '}'.
for (std::size_t c = 0; c < lines[i].size(); ++c) {
if (lines[i][c] == '{') {
Scope kind = Scope::Other;
std::string_view upTo = Trim(lines[i].substr(0, c));
if (trimmed.starts_with("namespace") || upTo.contains("namespace ")) {
kind = Scope::Namespace;
} else if (std::regex_search(lineStr, typeDecl) || std::regex_search(lineStr, enumDecl)) {
kind = Scope::Type;
} else if (upTo.ends_with(')') || upTo.ends_with("const") || upTo.ends_with("noexcept")
|| upTo.ends_with("->") || trimmed.starts_with("extern")) {
kind = Scope::Function; // function/lambda/control body — all non-global
}
stack.push_back(kind);
} else if (lines[i][c] == '}') {
if (!stack.empty()) stack.pop_back();
}
}
}
});
cfg.AddLintRule("enum-class", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
static const std::regex plainEnum(R"(\benum\s+(?!class\b|struct\b)[A-Za-z_])");
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string lineStr(lines[i]);
if (std::regex_search(lineStr, plainEnum)) {
ctx.Report(i + 1, "use enum class instead of plain enum");
}
}
});
cfg.AddLintRule("no-iostream-print", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
for (std::size_t i = 0; i < lines.size(); ++i) {
if (lines[i].contains("std::cout")) {
ctx.Report(i + 1, "use std::println instead of std::cout");
} else if (lines[i].contains("std::cerr") && lines[i].contains("<<")) {
ctx.Report(i + 1, "use std::println(std::cerr, ...) instead of streaming to std::cerr");
}
}
});
// Prefer std::string / std::string_view over char*. OS interop stays:
// getenv returns char*, argv is char**, extern "C" prototypes mirror C.
cfg.AddLintRule("no-char-pointer", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> lines = Lines(ctx.CommentStripped());
static const std::regex charPtr(R"(\bchar\s*\*)");
for (std::size_t i = 0; i < lines.size(); ++i) {
std::string lineStr(lines[i]);
// C-interop stays char*: argv, getenv/setenv, dlerror, C APIs fed
// by c_str()/data(), binary IO reinterpret_casts, extern "C"
// prototypes. (extern " matches with the literal body blanked.)
if (lineStr.contains("argv") || lineStr.contains("getenv") || lineStr.contains("setenv")
|| lineStr.contains("dlerror") || lineStr.contains("c_str") || lineStr.contains(".data(")
|| lineStr.contains("reinterpret_cast") || lineStr.contains("extern \"")) continue;
if (std::regex_search(lineStr, charPtr)) {
ctx.Report(i + 1, "prefer std::string / std::string_view over char*");
}
}
});
// String building via `+` with a literal operand → std::format. Plain
// `a += b` accumulation (builder pattern) stays legal. Single-line chains
// of simple operands (identifier / member / call chains, parenthesized
// groups, literals) are REWRITTEN automatically; anything the operand
// scanner can't prove safe — raw-string lines, ternaries, mixed
// operators, multi-line expressions — is reported for a human instead.
cfg.AddLintRule("format-concat", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
std::vector<std::string_view> stripped = Lines(code);
// Walk one operand leftwards from `from` (exclusive). Returns the
// operand's begin, or npos when the shape isn't a simple postfix
// chain (then the whole chain bails to a report).
auto operandLeft = [](std::string_view s, std::size_t from) -> std::size_t {
std::size_t i = from;
while (i > 0 && (s[i - 1] == ' ' || s[i - 1] == '\t')) --i;
bool any = false;
for (;;) {
if (i == 0) break;
char c = s[i - 1];
if (c == ')' || c == ']') {
char open = c == ')' ? '(' : '[';
std::size_t depth = 0;
do {
--i;
if (s[i] == c) ++depth;
if (s[i] == open) --depth;
if (depth == 0) break;
} while (i > 0);
if (depth != 0) return std::string_view::npos;
any = true;
// A group directly preceded by an identifier (or another
// group) is a call/index postfix — keep consuming the
// callee: `path.string()` is ONE operand, not `()`.
if (i > 0 && (IsWordChar(s[i - 1]) || s[i - 1] == ')' || s[i - 1] == ']')) continue;
} else if (c == '"') {
--i; // closing quote; interior is blanked, find the opener
while (i > 0 && s[i - 1] != '"') --i;
if (i == 0) return std::string_view::npos;
--i;
any = true;
} else if (IsWordChar(c)) {
while (i > 0 && IsWordChar(s[i - 1])) --i;
any = true;
} else if (c == '.' && any) {
--i;
continue;
} else if (c == ':' && i > 1 && s[i - 2] == ':' && any) {
i -= 2;
continue;
} else if (c == '>' && i > 1 && s[i - 2] == '-' && any) {
i -= 2;
continue;
} else {
break;
}
// After a primary, only connectors continue the operand.
if (i > 0 && (s[i - 1] == '.' || (s[i - 1] == ':' && i > 1 && s[i - 2] == ':')
|| (s[i - 1] == '>' && i > 1 && s[i - 2] == '-'))) continue;
break;
}
if (!any) return std::string_view::npos;
// A unary operator, ternary, or other-precedence operator in
// front means expression structure we don't reason about — bail.
std::size_t b = i;
while (b > 0 && (s[b - 1] == ' ' || s[b - 1] == '\t')) --b;
if (b > 0 && std::string_view("!*&~-?:<>/%^|").contains(s[b - 1])) return std::string_view::npos;
return i;
};
// Walk one operand rightwards from `from` (inclusive). Returns one
// past the operand's end, or npos on bail.
auto operandRight = [](std::string_view s, std::size_t from) -> std::size_t {
std::size_t i = from;
while (i < s.size() && (s[i] == ' ' || s[i] == '\t')) ++i;
bool any = false;
for (;;) {
if (i >= s.size()) break;
char c = s[i];
if (c == '(' || c == '[') {
char close = c == '(' ? ')' : ']';
std::size_t depth = 0;
while (i < s.size()) {
if (s[i] == c) ++depth;
if (s[i] == close && --depth == 0) { ++i; break; }
++i;
}
if (depth != 0) return std::string_view::npos;
any = true;
} else if (c == '"') {
++i;
while (i < s.size() && s[i] != '"') ++i;
if (i >= s.size()) return std::string_view::npos;
++i;
any = true;
} else if (IsWordChar(c)) {
while (i < s.size() && IsWordChar(s[i])) ++i;
any = true;
} else if (any && c == '.') {
++i;
continue;
} else if (any && c == ':' && i + 1 < s.size() && s[i + 1] == ':') {
i += 2;
continue;
} else if (any && c == '-' && i + 1 < s.size() && s[i + 1] == '>') {
i += 2;
continue;
} else {
break;
}
if (i < s.size() && (s[i] == '(' || s[i] == '[' || s[i] == '.'
|| (s[i] == ':' && i + 1 < s.size() && s[i + 1] == ':')
|| (s[i] == '-' && i + 1 < s.size() && s[i + 1] == '>'))) continue;
break;
}
return any ? i : std::string_view::npos;
};
struct Edit { std::size_t begin; std::size_t end; std::string replacement; };
std::vector<Edit> edits; // offsets into ctx.content
std::size_t lineStart = 0;
for (std::size_t li = 0; li < stripped.size(); ++li) {
std::string_view line = stripped[li];
std::size_t lineOff = lineStart;
lineStart += line.size() + 1;
std::string_view trimmed = Trim(line);
if (trimmed.starts_with('#')) continue;
// Raw strings defeat the comment stripper's quote tracking; any
// literal-+ pattern on such a line is a manual fix.
bool hasRaw = std::string_view(ctx.content).substr(lineOff, line.size()).contains("R\"");
std::size_t searchFrom = 0;
while (searchFrom < line.size()) {
// A candidate `+` that isn't ++ / += and touches a literal.
std::size_t plus = line.find('+', searchFrom);
if (plus == std::string_view::npos) break;
searchFrom = plus + 1;
if (plus + 1 < line.size() && (line[plus + 1] == '+' || line[plus + 1] == '=')) { ++searchFrom; continue; }
if (plus > 0 && line[plus - 1] == '+') continue;
std::size_t leftEnd = plus;
while (leftEnd > 0 && (line[leftEnd - 1] == ' ' || line[leftEnd - 1] == '\t')) --leftEnd;
std::size_t rightBegin = plus + 1;
while (rightBegin < line.size() && (line[rightBegin] == ' ' || line[rightBegin] == '\t')) ++rightBegin;
bool literalAdjacent = (leftEnd > 0 && line[leftEnd - 1] == '"')
|| (rightBegin < line.size() && line[rightBegin] == '"');
if (!literalAdjacent) continue;
if (hasRaw) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (raw-string line, fix manually)");
break;
}
// Expand to the full chain: operands joined by `+`.
std::size_t chainBegin = operandLeft(line, plus);
std::size_t chainEnd = operandRight(line, plus + 1);
bool bail = chainBegin == std::string_view::npos || chainEnd == std::string_view::npos;
while (!bail) {
std::size_t b = chainBegin;
while (b > 0 && (line[b - 1] == ' ' || line[b - 1] == '\t')) --b;
if (b > 0 && line[b - 1] == '+' && !(b > 1 && line[b - 2] == '+')) {
std::size_t prev = operandLeft(line, b - 1);
if (prev == std::string_view::npos) { bail = true; break; }
chainBegin = prev;
} else break;
}
while (!bail) {
std::size_t e = chainEnd;
while (e < line.size() && (line[e] == ' ' || line[e] == '\t')) ++e;
if (e < line.size() && line[e] == '+' && !(e + 1 < line.size() && (line[e + 1] == '+' || line[e + 1] == '='))) {
std::size_t next = operandRight(line, e + 1);
if (next == std::string_view::npos) { bail = true; break; }
chainEnd = next;
} else break;
}
if (bail) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)");
break;
}
// Split the chain at depth-0 '+' into parts; literals feed the
// format string, everything else becomes an argument.
std::string_view chain = line.substr(chainBegin, chainEnd - chainBegin);
std::string_view rawChain = std::string_view(ctx.content).substr(lineOff + chainBegin, chainEnd - chainBegin);
std::string fmt;
std::vector<std::string_view> args;
std::size_t partBegin = 0;
std::int64_t depth = 0;
bool inLit = false;
for (std::size_t p = 0; p <= chain.size(); ++p) {
if (p < chain.size()) {
char c = chain[p];
if (c == '"') inLit = !inLit;
if (inLit) continue;
if (c == '(' || c == '[') ++depth;
if (c == ')' || c == ']') --depth;
if (!(c == '+' && depth == 0)) continue;
}
std::string_view part = Trim(chain.substr(partBegin, p - partBegin));
std::string_view rawPart = Trim(rawChain.substr(partBegin, p - partBegin));
if (part.starts_with('"') && part.ends_with('"') && part.size() >= 2
&& std::count(part.begin(), part.end(), '"') == 2) {
for (char c : rawPart.substr(1, rawPart.size() - 2)) {
fmt += c;
if (c == '{') fmt += '{';
if (c == '}') fmt += '}';
}
} else if (part.contains('"') && part.contains('+')) {
bail = true; // nested concat inside an operand — human territory
break;
} else {
fmt += "{}";
args.push_back(rawPart);
}
partBegin = p + 1;
}
if (bail || args.empty()) {
ctx.Report(li + 1, "use std::format instead of string concatenation with + (not auto-fixable)");
break;
}
std::string replacement = std::format("std::format(\"{}\"", fmt);
for (std::string_view a : args) replacement += std::format(", {}", a);
replacement += ")";
edits.push_back({lineOff + chainBegin, lineOff + chainEnd, std::move(replacement)});
searchFrom = chainEnd;
}
}
if (edits.empty()) return;
std::string out = ctx.content;
std::sort(edits.begin(), edits.end(), [](const Edit& a, const Edit& b) { return a.begin > b.begin; });
for (const Edit& e : edits) out.replace(e.begin, e.end - e.begin, e.replacement);
ctx.SetContent(std::move(out));
});
// ---------------- transforms (auto-fixed by `crafter-build format`) ----------------
// short/int/long → fixed-width types. Lines mentioning main/argc/argv or
// extern "C" keep their C-conventional ints.
cfg.AddLintRule("fixed-width-types", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
struct Rep { std::size_t pos; std::size_t len; std::string to; };
std::vector<Rep> reps;
// Builtin integer type specifiers combine in any order (`unsigned
// long`, `long unsigned int`, ...), so match whole RUNS of these
// keywords and classify the run, rather than the words one by one.
static const std::unordered_set<std::string_view> IntWords = {
"unsigned", "signed", "short", "long", "int", "char",
};
std::size_t lineStart = 0;
while (lineStart <= code.size()) {
std::size_t lineEnd = code.find('\n', lineStart);
if (lineEnd == std::string::npos) lineEnd = code.size();
std::string_view line(code.data() + lineStart, lineEnd - lineStart);
// `int main` / argc / argv keep their C-conventional type; so do
// extern "C" prototypes (the literal body is blanked in the
// stripped text, so match `extern "`).
bool exempt = line.contains("int main") || line.contains("argc") || line.contains("argv")
|| line.contains("extern \"");
std::size_t pos = 0;
while (!exempt && pos < line.size()) {
if (!IsWordChar(line[pos])) { ++pos; continue; }
std::size_t wordEnd = pos;
while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd;
if (!IntWords.contains(line.substr(pos, wordEnd - pos))) { pos = wordEnd; continue; }
// Extend the run over consecutive specifier keywords.
std::size_t runBegin = pos;
std::size_t runEnd = wordEnd;
bool hasUnsigned = false;
bool hasSigned = false;
bool hasShort = false;
bool hasChar = false;
bool hasLong = false;
std::string_view nextWord;
for (;;) {
std::string_view word = line.substr(pos, wordEnd - pos);
if (word == "unsigned") hasUnsigned = true;
else if (word == "signed") hasSigned = true;
else if (word == "short") hasShort = true;
else if (word == "char") hasChar = true;
else if (word == "long") hasLong = true;
runEnd = wordEnd;
pos = wordEnd;
while (pos < line.size() && (line[pos] == ' ' || line[pos] == '\t')) ++pos;
wordEnd = pos;
while (wordEnd < line.size() && IsWordChar(line[wordEnd])) ++wordEnd;
nextWord = line.substr(pos, wordEnd - pos);
if (!IntWords.contains(nextWord)) break;
}
pos = runEnd;
// Bare `char` is text, not an integer — only signed/unsigned
// char is byte arithmetic. `long double` is a floating type.
if (hasChar && !hasUnsigned && !hasSigned) continue;
if (nextWord == "double") continue;
std::string_view width = hasChar ? "8" : hasShort ? "16" : hasLong ? "64" : "32";
reps.push_back({lineStart + runBegin, runEnd - runBegin,
std::format("std::{}int{}_t", hasUnsigned ? "u" : "", width)});
}
lineStart = lineEnd + 1;
}
if (reps.empty()) return;
std::string out = ctx.content;
std::sort(reps.begin(), reps.end(), [](const Rep& a, const Rep& b) { return a.pos > b.pos; });
for (const Rep& r : reps) out.replace(r.pos, r.len, r.to);
ctx.SetContent(std::move(out));
});
// One declaration per statement. Auto-splits the simple initialized form
// `Type a = x, b = y;`; anything with pointers/references, parens, or
// templates in the declarators is only reported (splitting `int* a, b;`
// would change b's type).
cfg.AddLintRule("single-declaration", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
// The declarator char class excludes quotes: string-literal bodies are
// blanked in the stripped text, so reconstructing them would corrupt
// the file — those lines are left alone.
static const std::regex simpleMulti(
R"(^(\s*)((?:std::)?[A-Za-z_][\w:]*)\s+([A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+(?:,\s*[A-Za-z_]\w*\s*=\s*[^,;()<>*&"]+)+);\s*$)");
std::string out;
bool changed = false;
for (std::size_t i = 0; i < stripped.size(); ++i) {
std::string lineStr(stripped[i]);
std::smatch m;
std::string_view raw = i + 1 <= ctx.lines.size() ? ctx.Line(i + 1) : std::string_view{};
if (!raw.contains("//") && !ctx.Suppressed("single-declaration", i + 1)
&& std::regex_match(lineStr, m, simpleMulti)) {
std::string indent = m[1].str(), type = m[2].str(), decls = m[3].str();
std::size_t start = 0;
bool first = true;
while (start < decls.size()) {
std::size_t comma = decls.find(',', start);
std::string_view d = Trim(std::string_view(decls).substr(start, comma == std::string::npos ? std::string::npos : comma - start));
if (!first) out += '\n';
out += std::format("{}{} {};", indent, type, d);
first = false;
if (comma == std::string::npos) break;
start = comma + 1;
}
changed = true;
} else {
out += raw;
}
if (i + 1 < stripped.size() || ctx.content.ends_with('\n')) out += '\n';
}
if (changed) ctx.SetContent(std::move(out));
});
// K&R braces: `{` on its own line after a `)`/else/do/try header joins
// onto the header line. Standalone scope blocks (previous line ends with
// `;`, `{`, a comment, …) are intentional and stay.
cfg.AddLintRule("brace-style", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
std::vector<std::string> outLines;
outLines.reserve(ctx.lines.size());
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string_view trimmed = Trim(stripped[i]);
if (trimmed == "{" && !outLines.empty()) {
std::string_view prevTrim = i > 0 ? Trim(stripped[i - 1]) : std::string_view{};
bool headerBefore = prevTrim.ends_with(')') || prevTrim == "else" || prevTrim == "do" || prevTrim == "try";
bool hasComment = ctx.Line(i + 1).contains("//") || (i > 0 && ctx.Line(i).contains("//"));
bool suppressed = ctx.Suppressed("brace-style", i) || ctx.Suppressed("brace-style", i + 1);
if (headerBefore && !hasComment && !suppressed) {
std::string& prev = outLines.back();
while (!prev.empty() && (prev.back() == ' ' || prev.back() == '\t')) prev.pop_back();
prev += " {";
changed = true;
continue;
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
// Single-statement if bodies join onto the if line: `if (x)\n y;` →
// `if (x) y;` — when the condition's parens balance on one line, the body
// is one `;`-terminated statement, neither line carries a comment, and
// the joined line stays ≤ 250 columns.
cfg.AddLintRule("if-single-line", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)");
std::vector<std::string> outLines;
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string lineStr(stripped[i]);
if (i + 1 < ctx.lines.size() && std::regex_match(lineStr, ifHeader) && ParenDelta(stripped[i]) == 0) {
std::string_view body = Trim(stripped[i + 1]);
bool joinable = !body.empty() && body != "{" && !body.starts_with("if") && body.ends_with(';')
&& !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 2).contains("//")
&& !ctx.Suppressed("if-single-line", i + 1) && !ctx.Suppressed("if-single-line", i + 2);
std::string joined = std::string(ctx.Line(i + 1));
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
joined += " ";
joined += Trim(ctx.Line(i + 2));
if (joinable && joined.size() <= 250) {
outLines.push_back(std::move(joined));
++i; // consume the body line
changed = true;
continue;
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
// Wrapped call arguments join back onto one line when the whole
// expression fits in 250 columns. Lambda bodies (lines ending `{`),
// comments, raw strings, and preprocessor lines are left alone.
// Operator-style continuations (next line starts with && / || / |) join
// under the same length limit.
cfg.AddLintRule("wrap-join", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
// Fixpoint loop: a join can enable another (joining an inner paren
// wrap balances the line an operator continuation hangs off), so one
// pass is not idempotent. Iterate until a pass changes nothing; the
// cap is a safety net — joins strictly reduce the line count, so
// termination is guaranteed anyway.
for (std::int32_t pass = 0; pass < 16; ++pass) {
std::vector<std::string_view> stripped = Lines(ctx.CommentStripped());
std::vector<std::string> outLines;
bool changed = false;
for (std::size_t i = 0; i < ctx.lines.size(); ++i) {
std::string_view trimmed = Trim(stripped[i]);
std::int64_t delta = ParenDelta(stripped[i]);
bool candidate = delta > 0 && !trimmed.empty() && !trimmed.starts_with('#')
&& !trimmed.ends_with('{') && !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"");
if (candidate) {
std::string joined(ctx.Line(i + 1));
std::size_t j = i + 1;
bool ok = true;
while (delta > 0 && j < ctx.lines.size() && j - i <= 4) {
std::string_view next = Trim(stripped[j]);
// A `{`-ending line is fine when it closes the expression
// (a control header's `) {`); mid-expression it means a
// lambda body starts — leave those wrapped.
bool closes = delta + ParenDelta(stripped[j]) <= 0;
if (next.empty() || (next.ends_with('{') && !closes)
|| ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) {
ok = false;
break;
}
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
std::string_view fragment = Trim(ctx.Line(j + 1));
// No separator right after an opening paren or before a
// closing one — joining must not manufacture `( x` / `x )`.
if (!joined.ends_with('(') && !fragment.starts_with(')') && !fragment.starts_with(',')) joined += " ";
joined += fragment;
delta += ParenDelta(stripped[j]);
++j;
}
for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l);
if (ok && delta <= 0 && joined.size() <= 250) {
outLines.push_back(std::move(joined));
i = j - 1; // consumed through line j-1 (0-based i)
changed = true;
continue;
}
} else if (delta == 0 && i + 1 < ctx.lines.size() && !trimmed.starts_with('#')
&& !ctx.Line(i + 1).contains("//") && !ctx.Line(i + 1).contains("R\"")) {
auto isOpStart = [](std::string_view s) {
return s.starts_with("&&") || s.starts_with("||") || s.starts_with("| ");
};
// Operator-style continuation chain (`a\n && b\n && c`).
// Joined when the WHOLE chain stays within 250 columns —
// longer conditions legitimately wrap. Same mechanics as the
// paren join: chain lines must be comment-free, raw-string
// free, and paren-balanced.
if (isOpStart(Trim(stripped[i + 1])) && !isOpStart(trimmed)) {
std::string joined(ctx.Line(i + 1));
std::size_t j = i + 1;
bool ok = true;
while (j < ctx.lines.size() && isOpStart(Trim(stripped[j]))) {
if (ParenDelta(stripped[j]) != 0 || ctx.Line(j + 1).contains("//") || ctx.Line(j + 1).contains("R\"")) {
ok = false;
break;
}
while (!joined.empty() && (joined.back() == ' ' || joined.back() == '\t')) joined.pop_back();
joined += " ";
joined += Trim(ctx.Line(j + 1));
++j;
}
for (std::size_t l = i + 1; ok && l <= j; ++l) ok = !ctx.Suppressed("wrap-join", l);
if (ok && joined.size() <= 250) {
outLines.push_back(std::move(joined));
i = j - 1; // consumed the chain
changed = true;
continue;
}
}
}
outLines.emplace_back(ctx.Line(i + 1));
}
if (!changed) return;
std::string out;
for (std::size_t i = 0; i < outLines.size(); ++i) {
out += outLines[i];
if (i + 1 < outLines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
}
});
// No space padding inside parens: `( x` / `x )` → `(x` / `x)`. Only
// intra-line (a line legitimately ends with '(' when a call wraps);
// string/comment interiors are excluded via the stripped text.
cfg.AddLintRule("paren-spacing", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
const std::string& code = ctx.CommentStripped();
std::vector<std::pair<std::size_t, std::size_t>> cuts; // [begin, end) spans of spaces to delete
for (std::size_t i = 0; i < code.size(); ++i) {
if (code[i] == '(' ) {
std::size_t j = i + 1;
while (j < code.size() && code[j] == ' ') ++j;
if (j > i + 1 && j < code.size() && code[j] != '\n' && code[j] != '\r') cuts.push_back({i + 1, j});
} else if (code[i] == ')') {
std::size_t j = i;
while (j > 0 && code[j - 1] == ' ') --j;
// Only single-space padding: multi-space runs are usually
// deliberate alignment columns.
if (j == i - 1 && j > 0 && code[j - 1] != '\n' && code[j - 1] != ',') cuts.push_back({j, i});
}
}
if (cuts.empty()) return;
std::string out = ctx.content;
for (auto it = cuts.rbegin(); it != cuts.rend(); ++it) out.erase(it->first, it->second - it->first);
ctx.SetContent(std::move(out));
});
cfg.AddLintRule("trim-trailing-ws", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
std::string out;
out.reserve(ctx.content.size());
for (std::size_t n = 1; n <= ctx.lines.size(); ++n) {
std::string_view line = ctx.Line(n);
// Byte fidelity on CRLF files: peel the \r, trim, put it back.
bool crlf = line.ends_with('\r');
if (crlf) line.remove_suffix(1);
while (line.ends_with(' ') || line.ends_with('\t')) line.remove_suffix(1);
out += line;
if (crlf) out += '\r';
// The last line only had a newline if the file ended with one —
// preserve that byte exactly so this rule doesn't shadow
// final-newline.
if (n < ctx.lines.size() || ctx.content.ends_with('\n')) out += '\n';
}
ctx.SetContent(std::move(out));
});
cfg.AddLintRule("final-newline", [](LintContext& ctx) {
if (!IsCppFile(ctx)) return;
if (!ctx.content.empty() && !ctx.content.ends_with('\n')) {
ctx.SetContent(ctx.content + '\n');
}
});
}
} // namespace ProjectLint

42
packaging/APKBUILD Normal file
View file

@ -0,0 +1,42 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
# APKBUILD skeleton — buildable once crafter-build is packaged for Alpine
# (or replaced here by a bootstrap invocation). Until then this documents
# the packaging shape.
pkgname=imsd
pkgver=0.2.7
pkgrel=0
pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd"
arch="aarch64 x86_64"
license="GPL-3.0-only"
# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire —
# none of which abuild's .so auto-scan can see
depends="modemmanager opencore-amr vo-amrwbenc pipewire-tools"
# versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND
# excludes the real package: 81voltd serves the modem's own ims-PDN requests,
# which races imsd for the PDN and flaps it with a new prefix every ~2.5 min
# (fp6 journal/ims.md s57) — the two IMS stacks cannot share one PDN
provides="81voltd=$pkgver-r$pkgrel"
makedepends="clang lld libc++-dev glib-dev crafter-build"
source=""
options="!check" # crafter-build test needs qemu for cross; run natively in CI
build() {
crafter-build
crafter-build -- --product=media
crafter-build -- --product=dialerd
}
package() {
install -Dm755 bin/imsd-*/imsd "$pkgdir"/usr/bin/imsd
install -Dm755 bin/imsd-media-*/imsd-media "$pkgdir"/usr/libexec/imsd-media
install -Dm755 bin/imsd-dialerd-*/imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd
install -Dm755 packaging/ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh
install -Dm644 packaging/imsd.service "$pkgdir"/usr/lib/systemd/system/imsd.service
install -Dm644 packaging/imsd-dialerd.desktop \
"$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop
install -Dm644 packaging/net.catcrafts.IMS1.conf \
"$pkgdir"/usr/share/dbus-1/system.d/net.catcrafts.IMS1.conf
}

36
packaging/APKBUILD.binary Normal file
View file

@ -0,0 +1,36 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# Maintainer: Jorijn van der Graaf <jorijnvdgraaf@catcrafts.net>
# Interim packaging: wraps a crafter-build binary cross-compiled on a dev box
# (see README "Cross-compiling") into a proper apk, so the daemon is
# apk-managed on the phone while crafter-build itself isn't packaged for
# Alpine yet. The source tarball is produced by packaging/make-bin-tarball.sh.
pkgname=imsd
pkgver=0.2.7
pkgrel=0
pkgdesc="Userspace IMS/VoLTE daemon for mainline Linux phones"
url="https://forgejo.catcrafts.net/Catcrafts/imsd"
arch="aarch64"
license="GPL-3.0-only"
# the media leg dlopen's the AMR-WB codecs; pw-record/pw-play drive PipeWire
depends="modemmanager libc++ opencore-amr vo-amrwbenc pipewire-tools"
# versioned provides = imsd satisfies soc-qcom-modem's 81voltd dependency AND
# excludes the real package: 81voltd serves the modem's own ims-PDN requests,
# which races imsd for the PDN and flaps it with a new prefix every ~2.5 min
# (fp6 journal/ims.md s57) — the two IMS stacks cannot share one PDN
provides="81voltd=$pkgver-r$pkgrel"
options="!check"
source="imsd-$pkgver.tar.gz"
package() {
cd "$srcdir/imsd-$pkgver"
install -Dm755 imsd "$pkgdir"/usr/bin/imsd
install -Dm755 imsd-media "$pkgdir"/usr/libexec/imsd-media
install -Dm755 imsd-dialerd "$pkgdir"/usr/bin/imsd-dialerd
install -Dm755 ims-pdn-up.sh "$pkgdir"/usr/libexec/ims-pdn-up.sh
install -Dm644 imsd.service "$pkgdir"/usr/lib/systemd/system/imsd.service
install -Dm644 imsd-dialerd.desktop \
"$pkgdir"/etc/xdg/autostart/imsd-dialerd.desktop
install -Dm644 net.catcrafts.IMS1.conf \
"$pkgdir"/usr/share/dbus-1/system.d/net.catcrafts.IMS1.conf
}

106
packaging/ims-pdn-up.sh Normal file
View file

@ -0,0 +1,106 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# ims-pdn-up.sh — boot bring-up for the IPv6 `ims` PDN (journal/ims.md s45).
#
# Finds-or-creates the ims ipv6 bearer via ModemManager, connects it, and
# configures the muxed netdev with the MM-assigned address (the s34 recipe,
# automated). Idempotent — safe to run when the PDN is already up. Runs as
# ExecStartPre of imsd.service, so imsd only starts once the PDN exists; a
# nonzero exit fails the unit and systemd retries per Restart/RestartSec.
log() { echo "ims-pdn-up: $*"; }
kv() { mmcli "$@" -K 2>/dev/null; }
bearer_paths() {
kv -m "$MODEM" | sed -n 's/^modem\.generic\.bearers\.value\[[0-9]*\] *: *//p'
}
# ---- wait for a modem (MM + modem firmware take a while after boot)
n=0
while :; do
MODEM=$(mmcli -L 2>/dev/null | sed -n 's,.*/Modem/\([0-9]*\).*,\1,p' | head -n1)
[ -n "$MODEM" ] && break
n=$((n + 1))
[ "$n" -ge 60 ] && { log "no modem after 120 s"; exit 1; }
sleep 2
done
log "modem $MODEM"
# ---- find a connected ims bearer; else find-or-create one and connect it
# (LTE attach can lag boot, so connect attempts retry)
find_ims_bearer() { # $1 = required bearer.status.connected value
for B in $(bearer_paths); do
INFO=$(kv -b "$B") || continue
echo "$INFO" | grep -q '^bearer\.properties\.apn *: *ims$' || continue
echo "$INFO" | grep -q "^bearer\.status\.connected *: *$1\$" || continue
echo "$B"
return 0
done
return 1
}
BEARER=$(find_ims_bearer yes)
n=0
while [ -z "$BEARER" ]; do
n=$((n + 1))
[ "$n" -gt 10 ] && { log "bearer connect failed after 10 attempts"; exit 1; }
B=$(find_ims_bearer no) # reuse a stale disconnected ims bearer
if [ -z "$B" ]; then
B=$(mmcli -m "$MODEM" --create-bearer='apn=ims,ip-type=ipv6' 2>/dev/null |
sed -n 's,.*\(/org/freedesktop/ModemManager1/Bearer/[0-9]*\).*,\1,p')
[ -n "$B" ] && log "created bearer $B"
fi
if [ -n "$B" ] && mmcli -b "$B" --connect >/dev/null 2>&1; then
BEARER=$B
else
log "connect attempt $n failed; retrying in 10 s"
sleep 10
fi
done
log "connected: $BEARER"
# ---- configure the muxed netdev with the MM-assigned address
INFO=$(kv -b "$BEARER")
IFACE=$(echo "$INFO" | sed -n 's/^bearer\.status\.interface *: *//p')
ADDR=$(echo "$INFO" | sed -n 's/^bearer\.ipv6-config\.address *: *//p')
PREFIX=$(echo "$INFO" | sed -n 's/^bearer\.ipv6-config\.prefix *: *//p')
if [ -z "$IFACE" ] || [ -z "$ADDR" ]; then
log "bearer up but no interface/address in mmcli output"
exit 1
fi
ip link set "$IFACE" up || exit 1
ip -6 addr replace "$ADDR/${PREFIX:-64}" dev "$IFACE" || exit 1
# wait out IPv6 DAD: binding a tentative address gives EADDRNOTAVAIL
# (first boot attempt cost a 120 s systemd retry exactly this way)
n=0
while ip -6 addr show dev "$IFACE" | grep -q tentative; do
n=$((n + 1))
[ "$n" -ge 10 ] && { log "address still tentative after 10 s"; break; }
sleep 1
done
log "$IFACE up, $ADDR/${PREFIX:-64}"
# ---- hand the connected ims netdev to imsd (imsd.service reads
# /run/imsd.env) so the daemon carries no baked-in interface name
echo "DEV=$IFACE" > /run/imsd.env
# ---- open the IMS protected ports in the firewall (rung 5b root cause,
# journal/ims.md s56): pmOS's default nftables INPUT chain is policy-drop and
# explicitly drops all inbound on qmapmux*, which silently killed every
# network-initiated request (reg-event NOTIFY, MT INVITE) after ESP decap —
# the P-CSCF's TCP SYNs to the protected server port never reached the
# listener, so terminating delivery failed and MT calls fell back to CS.
# 45061/45062 = imsd's protected client/server ports (kPortUc/kPortUs).
# Best-effort: never fail the unit over a missing/foreign firewall.
if nft list table inet filter >/dev/null 2>&1; then
if ! nft list chain inet filter input | grep -q imsd-protected-ports; then
nft insert rule inet filter input iifname "qmapmux*" tcp dport 45061-45062 accept comment '"imsd-protected-ports"' &&
nft insert rule inet filter input iifname "qmapmux*" udp dport 45061-45062 accept comment '"imsd-protected-ports"' &&
log "nftables: opened protected ports 45061-45062 on qmapmux*" ||
log "nftables: rule insert failed (continuing)"
fi
fi
exit 0

View file

@ -0,0 +1,11 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
[Desktop Entry]
Type=Application
Name=IMS Dialer Daemon
Comment=Plasma Dialer backend for imsd (replaces plasma-dialer's modem-daemon)
Exec=/usr/bin/imsd-dialerd
Icon=call-start
NoDisplay=true
X-KDE-autostart-phase=1
X-KDE-StartupNotify=false

34
packaging/imsd.service Normal file
View file

@ -0,0 +1,34 @@
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
[Unit]
Description=Userspace IMS/VoLTE daemon (SIP register + calls over the ims PDN)
# needs the ims PDN (multiplexed netdev + global v6) — ims-pdn-up.sh
# creates/connects the bearer through ModemManager and configures the netdev
After=ModemManager.service network.target
Wants=ModemManager.service
[Service]
Type=simple
# bring up the ims PDN first; waits for modem + LTE attach, idempotent.
# worst case ~4 min (modem wait + connect retries), hence TimeoutStartSec
ExecStartPre=/usr/libexec/ims-pdn-up.sh
TimeoutStartSec=600
# /run/imsd.env: written by ims-pdn-up.sh (DEV= the connected ims netdev).
# /etc/imsd.env: admin configuration — PCSCF= is REQUIRED (the carrier's
# P-CSCF address; see the README's Configuration section) and wins over the
# runtime-derived values.
EnvironmentFile=-/run/imsd.env
EnvironmentFile=-/etc/imsd.env
# registration state (imsreg.state) + media stats live here
StateDirectory=imsd
ExecStart=/usr/bin/imsd
# registration: resume an existing SA when possible (avoids fresh-SA
# throttles); a failed bring-up exits nonzero and systemd retries
Restart=on-failure
RestartSec=120
# SIGTERM: engine keeps the SA + registration state for the next resume
KillMode=mixed
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target

28
packaging/make-bin-tarball.sh Executable file
View file

@ -0,0 +1,28 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# make-bin-tarball.sh — bundle the cross-compiled imsd binary + runtime files
# into the source tarball APKBUILD.binary consumes. Run from the repo root
# after a cross build; output lands in the current directory.
set -eu
VER="${1:-$(sed -n 's/.*char\* Version = "\(.*\)".*/\1/p' implementations/main.cpp)}"
[ -n "$VER" ] || { echo "could not determine version — pass it as \$1" >&2; exit 1; }
BIN=$(ls -t bin/imsd-aarch64-*/imsd 2>/dev/null | head -n1)
MEDIA=$(ls -t bin/imsd-media-aarch64-*/imsd-media 2>/dev/null | head -n1)
DIALERD=$(ls -t bin/imsd-dialerd-aarch64-*/imsd-dialerd 2>/dev/null | head -n1)
[ -n "$BIN" ] || { echo "no aarch64 imsd build found — cross-compile first" >&2; exit 1; }
[ -n "$MEDIA" ] || { echo "no aarch64 imsd-media build — cross-compile with --product=media" >&2; exit 1; }
[ -n "$DIALERD" ] || { echo "no aarch64 imsd-dialerd build — cross-compile with --product=dialerd" >&2; exit 1; }
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
mkdir "$stage/imsd-$VER"
cp "$BIN" "$stage/imsd-$VER/imsd"
cp "$MEDIA" "$stage/imsd-$VER/imsd-media"
cp "$DIALERD" "$stage/imsd-$VER/imsd-dialerd"
cp packaging/ims-pdn-up.sh packaging/imsd.service \
packaging/imsd-dialerd.desktop \
packaging/net.catcrafts.IMS1.conf "$stage/imsd-$VER/"
tar -C "$stage" -czf "imsd-$VER.tar.gz" "imsd-$VER"
echo "wrote imsd-$VER.tar.gz ($(du -h "imsd-$VER.tar.gz" | cut -f1))"

45
packaging/make-sysroot.sh Executable file
View file

@ -0,0 +1,45 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# make-sysroot.sh — build an Alpine aarch64 sysroot for cross-compiling imsd.
#
# Downloads apk-tools-static + the Alpine signing keys from the CDN and
# populates a minimal rootfs (musl, libc++, glib and their headers) that
# clang can use via --sysroot. No root, no qemu, no device needed:
#
# packaging/make-sysroot.sh [dir] # default: ~/.cache/imsd/sysroot-aarch64-alpine
# crafter-build --target=aarch64-alpine-linux-musl --sysroot=<dir>
set -eu
SYSROOT="${1:-$HOME/.cache/imsd/sysroot-aarch64-alpine}"
MIRROR="${MIRROR:-https://dl-cdn.alpinelinux.org/alpine/edge}"
ARCH=aarch64
# gcc is in the list for its crtbeginS.o/libgcc: clang's driver for
# *-alpine-linux-musl links against the GCC runtime it detects under
# <sysroot>/usr/lib/gcc/<triple>/
PKGS="musl-dev libc++ libc++-dev glib-dev compiler-rt llvm-libunwind-dev gcc"
work=$(mktemp -d)
trap 'rm -rf "$work"' EXIT
fetch_pkg() { # $1 = repo (main/community), $2 = package name -> path on stdout
file=$(curl -fsSL "$MIRROR/$1/x86_64/" |
grep -o "href=\"$2-[0-9][^\"]*\.apk\"" | head -n1 | cut -d'"' -f2)
[ -n "$file" ] || { echo "cannot find $2 in $MIRROR/$1" >&2; exit 1; }
curl -fsSL -o "$work/$file" "$MIRROR/$1/x86_64/$file"
echo "$work/$file"
}
echo ">> fetching apk-tools-static + alpine-keys"
tar -xzf "$(fetch_pkg main apk-tools-static)" -C "$work" sbin/apk.static 2>/dev/null
tar -xzf "$(fetch_pkg main alpine-keys)" -C "$work" usr/share/apk/keys 2>/dev/null
echo ">> populating $SYSROOT ($ARCH: $PKGS)"
mkdir -p "$SYSROOT"
"$work/sbin/apk.static" \
--arch "$ARCH" --root "$SYSROOT" --initdb --no-scripts --no-cache \
--usermode --keys-dir "$work/usr/share/apk/keys/$ARCH" \
-X "$MIRROR/main" -X "$MIRROR/community" \
add $PKGS
echo ">> done: $SYSROOT"

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?> <!--*-nxml-*-->
<!-- SPDX-License-Identifier: GPL-3.0-only -->
<!-- SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® -->
<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<!-- imsd (userspace VoLTE daemon) system-bus policy.
Install: /usr/share/dbus-1/system.d/net.catcrafts.IMS1.conf -->
<busconfig>
<policy user="root">
<allow own="net.catcrafts.IMS1"/>
<allow send_destination="net.catcrafts.IMS1"/>
<allow receive_sender="net.catcrafts.IMS1"/>
</policy>
<!-- the phone session user drives calls via the imsdialerd shim -->
<policy user="user">
<allow send_destination="net.catcrafts.IMS1"/>
<allow receive_sender="net.catcrafts.IMS1"/>
</policy>
<policy context="default">
<deny send_destination="net.catcrafts.IMS1"/>
</policy>
</busconfig>

164
project.cpp Normal file
View file

@ -0,0 +1,164 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file no-char-pointer
import std;
import Crafter.Build;
#include "lint-rules.h"
namespace fs = std::filesystem;
using namespace Crafter;
// pkg-config wrapper (std-only: no popen in import std). Returns the flags
// split on whitespace, or empty if pkg-config is unavailable — callers
// provide a fallback for that case.
static std::vector<std::string> PkgConfig(std::string_view args) {
fs::path tmp = fs::temp_directory_path() /
std::format("imsd-pkgconfig-{}.txt", std::hash<std::string_view>{}(args));
std::string cmd = std::format("pkg-config {} > {} 2>/dev/null", args, tmp.string());
std::vector<std::string> flags;
if (std::system(cmd.c_str()) == 0) {
std::ifstream f(tmp);
std::string flag;
while (f >> flag)
flags.push_back(flag);
}
std::error_code ec;
fs::remove(tmp, ec);
return flags;
}
// GDBus (gio-2.0) — bus layer for the two daemons; chosen over sd-bus because
// the platform stack (ModemManager, libqmi for the future direct-AKA port) is
// GLib, so they want a GMainLoop regardless.
static void ApplyGioFlags(Configuration& cfg) {
if (!cfg.sysroot.empty()) {
// cross build (e.g. --target=aarch64-alpine-linux-musl
// --sysroot=<packaging/make-sysroot.sh output>): the host's
// pkg-config would answer for the wrong architecture. glib's
// include layout is stable across distros; the `-I=` form makes
// clang resolve the paths inside the sysroot.
cfg.compileFlags.push_back("-I=/usr/include/glib-2.0");
cfg.compileFlags.push_back("-I=/usr/lib/glib-2.0/include");
for (const char* l : { "-lgio-2.0", "-lgobject-2.0", "-lglib-2.0" })
cfg.linkFlags.push_back(l);
} else {
// native build: ask pkg-config. Only -I/-D flags pass through:
// pkg-config also emits -pthread, which would config-mismatch the
// shared prebuilt std.pcm (and pthread lives in libc on every
// platform we target).
for (std::string& f : PkgConfig("--cflags gio-2.0"))
if (f.starts_with("-I") || f.starts_with("-D")) cfg.compileFlags.push_back(std::move(f));
std::vector<std::string> libs;
for (std::string& f : PkgConfig("--libs gio-2.0"))
if (f.starts_with("-l") || f.starts_with("-L")) libs.push_back(std::move(f));
if (libs.empty()) libs = { "-lgio-2.0", "-lgobject-2.0", "-lglib-2.0" };
for (std::string& f : libs)
cfg.linkFlags.push_back(std::move(f));
}
}
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
// Three executables come out of this repo; which one a build produces is
// chosen by `--product=`:
// daemon (default) — imsd, the control-plane daemon (GDBus + engine),
// and the unit tests, which link imsd-core.
// media — imsd-media, the standalone RTP/AMR-WB data plane
// the daemon spawns per call (pure std + POSIX, no
// core, no GLib). Built with `-- --product=media`.
// dialerd — imsd-dialerd, the session-bus Plasma Dialer
// backend (org.kde.telephony.*) bridging to imsd
// (pure GDBus translation, no core).
bool wantMedia = false;
bool wantDialerd = false;
for (std::string_view a : args) {
if (a == "--product=media") wantMedia = true;
if (a == "--product=dialerd") wantDialerd = true;
}
if (wantDialerd) {
Configuration dialerd;
dialerd.path = "./";
dialerd.name = "imsd-dialerd";
dialerd.outputName = "imsd-dialerd";
ApplyStandardArgs(dialerd, args);
dialerd.type = ConfigurationType::Executable;
{
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "implementations/dialerd" };
dialerd.GetInterfacesAndImplementations(ifaces, impls);
}
ApplyGioFlags(dialerd);
ProjectLint::AddProjectLintRules(dialerd);
return dialerd;
}
if (wantMedia) {
Configuration media;
media.path = "./";
media.name = "imsd-media";
media.outputName = "imsd-media";
ApplyStandardArgs(media, args);
media.type = ConfigurationType::Executable;
{
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "implementations/media" };
media.GetInterfacesAndImplementations(ifaces, impls);
}
media.linkFlags.push_back("-ldl"); // dlopen the AMR-WB codecs
media.linkFlags.push_back("-lpthread"); // std::jthread playout/mic
ProjectLint::AddProjectLintRules(media);
return media;
}
// imsd-core — the engine as a static library of pure C++ modules (SIP,
// SDP, later AKA/IPsec/media/engine). Deliberately GLib-free so every
// piece is unit-testable without a bus or a phone.
static auto Core = std::make_unique<Configuration>();
Core->path = "./";
Core->name = "imsd-core";
Core->outputName = "imsd-core";
ApplyStandardArgs(*Core, args);
Core->type = ConfigurationType::LibraryStatic;
{
std::array<fs::path, 8> ifaces = {
"interfaces/Imsd",
"interfaces/Imsd-Util",
"interfaces/Imsd-Aka",
"interfaces/Imsd-Ipsec",
"interfaces/Imsd-Sip",
"interfaces/Imsd-Sdp",
"interfaces/Imsd-Messages",
"interfaces/Imsd-Engine",
};
std::array<fs::path, 0> impls = {};
Core->GetInterfacesAndImplementations(ifaces, impls);
}
// imsd — the daemon executable: GLib main loop + GDBus service
// (net.catcrafts.IMS1, same ABI as the Python imsd.py) around the core.
Configuration cfg;
cfg.path = "./";
cfg.name = "imsd";
cfg.outputName = "imsd";
ApplyStandardArgs(cfg, args);
cfg.type = ConfigurationType::Executable;
cfg.dependencies = { Core.get() };
{
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "implementations/main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
}
ApplyGioFlags(cfg);
cfg.linkFlags.push_back("-lpthread"); // std::thread engine loop
cfg.AddTest("Util").Dependencies({ Core.get() });
cfg.AddTest("Aka").Dependencies({ Core.get() });
cfg.AddTest("Ipsec").Dependencies({ Core.get() });
cfg.AddTest("Messages").Dependencies({ Core.get() });
cfg.AddTest("Engine").Dependencies({ Core.get() });
cfg.AddTest("Sip").Dependencies({ Core.get() });
cfg.AddTest("Sdp").Dependencies({ Core.get() });
ProjectLint::AddProjectLintRules(cfg);
return cfg;
}

150
tests/Aka/main.cpp Normal file
View file

@ -0,0 +1,150 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Aka unit tests — the AKAv1-MD5 digest against a vector cross-computed
// with the Python reference, identity assembly, the qmicli/mmcli text
// parsers (fed real device output), and the AUTHENTICATE APDU build/parse.
import std;
import Imsd;
using namespace imsd::aka;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
// `qmicli --uim-get-card-status` in the shape a real FP6 eSIM (slot 2)
// produces.
constexpr std::string_view CardStatus =
"[qrtr://0] Successfully got card status\n"
"Provisioning applications:\n"
"\tPrimary GW: slot '2', application '1'\n"
"Slot [1]:\n"
"\tCard state: 'error: no-atr-received (3)'\n"
"\tUPIN state: 'not-initialized'\n"
"Slot [2]:\n"
"\tCard state: 'present'\n"
"\tApplication [1]:\n"
"\t\tApplication type: 'usim (2)'\n"
"\t\tApplication state: 'ready'\n"
"\t\tApplication ID:\n"
"\t\t\tA0:00:00:00:87:10:02:FF:31:F0:04:89:0E:00:00:01\n"
"\t\tPIN1 state: 'disabled'\n";
constexpr std::string_view MmcliSim =
" Properties | active: yes\n"
" | imsi: 001010123456789\n"
" | iccid: 8900101234567890123\n"
" | operator id: 00101\n"
" | operator name: Test PLMN 1-1\n";
}
int main() {
// ---- AKAv1-MD5 digest: pinned vector (the algorithm was cross-checked
// against the Python reference; this vector is recomputed for the
// synthetic test identity)
{
std::array<std::uint8_t, 8> res{0xa1,0xb2,0xc3,0xd4,0xe5,0xf6,0x07,0x18};
std::string d = DigestAkav1("abc123nonce==", res, "cnonce0123456789", "sip:ims.mnc001.mcc001.3gppnetwork.org", "001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "ims.mnc001.mcc001.3gppnetwork.org");
Check(d == "09fd8f8f7ef716c029666a7c6df1db1e", "AKAv1-MD5 digest matches pinned vector");
}
// ---- identity assembly
{
Identity id = MakeIdentity("001010123456789", "001", "01");
Check(id.mnc == "001", "MNC zero-padded to 3");
Check(id.domain == "ims.mnc001.mcc001.3gppnetwork.org", "domain");
Check(id.impi == "001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "IMPI");
Check(id.impu == "sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org", "IMPU");
Check(id.regUri == "sip:ims.mnc001.mcc001.3gppnetwork.org", "reg URI");
}
// ---- card-status parse (real device output)
{
auto sel = ParseCardStatus(CardStatus);
Check(sel.has_value(), "card status yields a ready USIM");
Check(sel && sel->slot == 2, "slot 2 selected (slot 1 is error/no-atr)");
Check(sel && sel->aid == "A0000000871002FF31F004890E000001", "AID uppercased, colons stripped");
}
Check(!ParseCardStatus("Slot [1]:\n\tCard state: 'absent'\n").has_value(), "no ready USIM -> nullopt");
// ---- mmcli parsers
Check(ParsePrimarySimPath(" SIM | primary sim path: /org/freedesktop/ModemManager1/SIM/0\n") == "/org/freedesktop/ModemManager1/SIM/0", "primary sim path");
{
auto si = ParseSimInfo(MmcliSim);
Check(si.has_value(), "sim info parses");
Check(si && si->imsi == "001010123456789", "imsi");
Check(si && si->mcc == "001" && si->mnc == "01", "mcc/mnc split");
// full pipeline: parsed info -> identity
Identity id = MakeIdentity(si->imsi, si->mcc, si->mnc);
Check(id.domain == "ims.mnc001.mcc001.3gppnetwork.org", "info->identity domain");
}
Check(ParseEquipmentId(" Hardware | equipment id: 359999990000001\n") == "359999990000001", "equipment id");
Check(ImeiUrn("359999990000001") == "urn:gsma:imei:35999999-000000-1", "IMEI -> RFC 7254 URN");
Check(!ImeiUrn("35999999000000").has_value(), "14-digit IMEI rejected");
Check(!ImeiUrn("35999999000000x").has_value(), "non-digit IMEI rejected");
// ---- AUTHENTICATE APDU build + response parse
{
std::vector<std::uint8_t> rnd(16, 0x11), autn(16, 0x22);
std::string apdu = BuildAkaApdu(0x01, rnd, autn);
// CLA=01 INS=88 P1=00 P2=81 Lc=22(34) 10 <rand> 10 <autn>
Check(apdu.starts_with("0188008122"), "APDU header CLA/INS/P1/P2/Lc");
Check(apdu == "0188008122" "10" "11111111111111111111111111111111" "10" "22222222222222222222222222222222", "APDU byte-exact");
Check(BuildGetResponseApdu(0x01, 0x2E) == "01c000002e", "GET RESPONSE APDU");
}
{
// DB | 08 RES | 10 CK | 10 IK | SW1 SW2(9000)
std::vector<std::uint8_t> body = {0xDB, 0x08};
for (int i = 0; i < 8; i++) body.push_back(0xA0 + i); // RES
body.push_back(0x10);
for (int i = 0; i < 16; i++) body.push_back(0xC0 + i); // CK
body.push_back(0x10);
for (int i = 0; i < 16; i++) body.push_back(0xD0 + i); // IK
body.push_back(0x90); body.push_back(0x00); // SW
auto r = ParseAkaResponse(body);
Check(r.has_value(), "AKA response parses");
Check(r && r->res.size() == 8 && r->res[0] == 0xA0, "RES extracted");
Check(r && r->ck.size() == 16 && r->ck[0] == 0xC0, "CK extracted");
Check(r && r->ik.size() == 16 && r->ik[0] == 0xD0, "IK extracted");
}
{
// sync-failure tag (0xDC) or truncated body -> nullopt
std::vector<std::uint8_t> dc = {0xDC, 0x0E, 0x90, 0x00};
Check(!ParseAkaResponse(dc).has_value(), "non-DB tag rejected");
std::vector<std::uint8_t> tr = {0xDB, 0x08, 0x00, 0x90, 0x00};
Check(!ParseAkaResponse(tr).has_value(), "length past end rejected");
}
{
// P-Access-Network-Info from `mmcli --location-get` (real-device
// output shape; TAC/CI zero-padded wider than the 4/7 hex digits
// PANI uses). The mcc+mnc+TAC+ECI concatenation was validated
// against a stock modem's on-wire PANI; values here are synthetic.
constexpr std::string_view Loc =
" --------------------------\n"
" 3GPP | operator mcc: 001\n"
" | operator mnc: 01\n"
" | location area code: 0000\n"
" | tracking area code: 001234\n"
" | cell id: 00ABCDE0\n";
auto p = ParsePani(Loc);
Check(p.has_value(), "PANI parses");
Check(p == "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0", "PANI concatenation mcc+mnc+TAC+ECI");
Check(!ParsePani("no location here").has_value(), "PANI absent fields rejected");
constexpr std::string_view Zeros =
" 3GPP | operator mcc: 001\n"
" | operator mnc: 01\n"
" | tracking area code: 000000\n"
" | cell id: 00000000\n";
Check(!ParsePani(Zeros).has_value(), "PANI all-zero cell rejected");
}
if (Failures == 0) std::println("Aka: all tests passed");
return Failures;
}

530
tests/Engine/main.cpp Normal file
View file

@ -0,0 +1,530 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Engine unit tests — the call state machine driven through recorded
// network dialogs. Outgoing: reliable-18x PRACK, 2xx ACK + media start,
// media-plane far-end hangup, local hang-up (CANCEL before answer, BYE
// after), the CANCEL/answer race, and busy/error finals. Incoming: 100+180
// on the INVITE (reliable when the caller requires 100rel), Accept -> 200
// with the SDP answer at the offer's payload types + media, ACK silence,
// reject -> 486, remote CANCEL -> 200+487, unusable offer -> 488, ring
// timeout -> 480, BYE both ways. Pure reducer, no I/O.
import std;
import Imsd;
using namespace imsd::engine;
using imsd::msg::Context;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
Context MakeCtx() {
Context c;
c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01");
c.local = "2001:db8::db43";
c.route = "<sip:[2001:db8::105]:6000;lr>";
c.ppi = "tel:+31611111111";
c.securityServer = "ipsec-3gpp; spi-c=1; spi-s=2; port-c=3; port-s=4";
return c;
}
bool Has(const std::vector<Action>& a, Action::Type t) {
for (const auto& x : a) if (x.type == t) return true;
return false;
}
const Action* Find(const std::vector<Action>& a, Action::Type t) {
for (const auto& x : a) if (x.type == t) return &x;
return nullptr;
}
// First SendClient whose text starts with `method `.
const Action* Sent(const std::vector<Action>& a, std::string_view method) {
for (const auto& x : a)
if (x.type == Action::Type::SendClient && x.text.starts_with(method)) return &x;
return nullptr;
}
// Build a response with the machine's own Call-ID (so dialog matching in a
// real shell would hold; the reducer itself doesn't re-check).
std::string R183(std::string_view callid) {
return std::format(
"SIP/2.0 183 Session Progress\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Record-Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"Require: 100rel\r\n"
"RSeq: 1\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 120\r\n\r\n"
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
}
std::string R200(std::string_view callid) {
return std::format(
"SIP/2.0 200 OK\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:termgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 120\r\n\r\n"
"v=0\r\no=- 1 1 IN IP6 2001:db8::9\r\ns=-\r\nc=IN IP6 2001:db8::9\r\n"
"t=0 0\r\nm=audio 6004 RTP/AVP 97\r\na=rtpmap:97 AMR-WB/16000/1\r\n", callid);
}
std::string Final(std::string_view callid, int code, std::string_view text) {
return std::format(
"SIP/2.0 {} {}\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45061;branch=z9hG4bKx\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=it\r\n"
"To: <sip:1233@ims>;tag=remote7\r\n"
"Call-ID: {}\r\nCSeq: 1 INVITE\r\nContent-Length: 0\r\n\r\n",
code, text, callid);
}
// First SendResponse whose text starts with `prefix`.
const Action* Responded(const std::vector<Action>& a, std::string_view prefix) {
for (const auto& x : a)
if (x.type == Action::Type::SendResponse && x.text.starts_with(prefix)) return &x;
return nullptr;
}
// An inbound (terminating) INVITE the way KPN's P-CSCF delivers one:
// two Vias, a Record-Route pair, P-Asserted-Identity, an AMR-WB offer
// at non-default payload types (104/105) with telephone-event.
std::string MtInvite(std::string_view extraHeaders = "") {
std::string sdp =
"v=0\r\no=- 55 55 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 104 105\r\n"
"a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=fmtp:104 octet-align=1;mode-change-capability=2\r\n"
"a=rtpmap:105 telephone-event/16000\r\n"
"a=fmtp:105 0-15\r\n"
"a=ptime:20\r\na=sendrecv\r\n";
return std::format(
"INVITE sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
"Via: SIP/2.0/UDP [2001:db8::9]:5060;branch=z9hG4bKscscf1\r\n"
"Record-Route: <sip:[2001:db8::105]:5100;lr>\r\n"
"Record-Route: <sip:[2001:db8::9]:5060;lr>\r\n"
"Max-Forwards: 68\r\n"
"From: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
"Call-ID: mt-call-abc123\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
"P-Asserted-Identity: <tel:+31612345678>\r\n"
"Session-Expires: 1800\r\n"
"{}"
"Content-Type: application/sdp\r\n"
"Content-Length: {}\r\n\r\n{}",
extraHeaders, sdp.size(), sdp);
}
}
int main() {
Context c = MakeCtx();
// ---- full answered call: INVITE -> 100 -> 183(rel)+PRACK -> 200 -> ACK
// + media -> far-end hangup
{
imsd::util::Rng rng(42);
CallMachine m(c, rng, "ims-call-1", "1233", 50004, false);
auto start = m.Start();
Check(Sent(start, "INVITE ") != nullptr, "Start sends INVITE");
Check(Has(start, Action::Type::SetDeadline), "Start arms ring timeout");
Check(m.State() == CallState::Dialing, "starts dialing");
// 100 Trying: nothing changes
auto a100 = m.OnInviteResponse(Final(m.CallId(), 100, "Trying"));
Check(a100.empty(), "100 Trying is a no-op");
Check(m.State() == CallState::Dialing, "still dialing after 100");
// 183 with reliable 100rel + SDP: -> ringing, PRACK
auto a183 = m.OnInviteResponse(R183(m.CallId()));
Check(m.State() == CallState::Ringing, "183 -> ringing");
const Action* stAct = Find(a183, Action::Type::State);
Check(stAct && stAct->state == "ringing" && stAct->reason == "outgoing", "183 emits ringing/outgoing");
const Action* prack = Sent(a183, "PRACK ");
Check(prack != nullptr, "reliable 183 triggers PRACK");
Check(prack && prack->text.contains("RAck: 1 1 INVITE\r\n"), "PRACK RAck");
Check(prack && prack->text.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq 2");
// PRACK targets the remote Contact and routes via the Record-Route
Check(prack && prack->text.starts_with("PRACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "PRACK targets remote Contact");
Check(prack && prack->text.contains("Route: <sip:[2001:db8::105]:6000;lr>\r\n"), "PRACK routes via reversed Record-Route");
// a second identical 183 must NOT re-PRACK the same RSeq
auto a183b = m.OnInviteResponse(R183(m.CallId()));
Check(Sent(a183b, "PRACK ") == nullptr, "duplicate RSeq not re-PRACKed");
// 200 OK: ACK + StartMedia + active
auto a200 = m.OnInviteResponse(R200(m.CallId()));
const Action* ack = Sent(a200, "ACK ");
Check(ack != nullptr, "200 -> ACK");
Check(ack && ack->text.starts_with("ACK sip:termgw@[2001:db8::9]:5060 SIP/2.0"), "ACK targets remote Contact");
Check(ack && ack->text.contains("To: <sip:1233@ims>;tag=remote7\r\n"), "ACK carries dialog To tag");
const Action* media = Find(a200, Action::Type::StartMedia);
Check(media != nullptr, "200 starts media");
Check(media && media->media.Valid(), "media leg valid");
Check(media && media->media.remoteIp == "2001:db8::9", "media remote ip");
Check(media && media->media.remotePort == 6004, "media remote port");
Check(media && media->media.payloadType == 97, "media pt");
Check(media && media->media.codec == "AMR-WB", "media codec");
const Action* active = Find(a200, Action::Type::State);
Check(active && active->state == "active" && active->reason == "accepted", "200 emits active/accepted");
Check(m.State() == CallState::Active, "state active");
// media leg exits code 3 (downlink dried up) -> BYE + terminated
auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE");
const Action* term = Find(amx, Action::Type::State);
Check(term && term->state == "terminated" && term->reason == "remote-hangup", "media exit -> terminated/remote-hangup");
Check(Has(amx, Action::Type::StopMedia), "stops media");
Check(Has(amx, Action::Type::Deleted), "emits deleted");
Check(m.Terminated(), "terminated");
}
// ---- remote BYE mid-call
{
imsd::util::Rng rng(7);
CallMachine m(c, rng, "ims-call-2", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R200(m.CallId()));
Check(m.State() == CallState::Active, "answered");
std::string bye = std::format(
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/TCP [x]:1;branch=z9hG4bKb\r\n"
"From: <sip:them>;tag=remote7\r\nTo: <sip:me>;tag=it\r\n"
"Reason: SIP;cause=503;text=\"PT: Session timer expired.\"\r\n"
"Call-ID: {}\r\nCSeq: 9 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ab = m.OnRequest(bye);
const Action* resp = Find(ab, Action::Type::SendResponse);
Check(resp && resp->text.starts_with("SIP/2.0 200 OK"), "remote BYE answered 200");
Check(resp && resp->text.contains("CSeq: 9 BYE\r\n"), "200 echoes BYE CSeq");
const Action* t = Find(ab, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
const Action* lg = Find(ab, Action::Type::Log);
Check(lg && lg->text.contains("ended: remote-hangup") && lg->text.contains("Reason: SIP;cause=503"), "teardown log names the peer's Reason");
Check(m.Terminated(), "terminated after remote BYE");
}
// ---- local hang-up before answer sends CANCEL; the 200/487 race
{
imsd::util::Rng rng(9);
CallMachine m(c, rng, "ims-call-3", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R183(m.CallId())); // ringing
auto ah = m.OnHangup();
Check(Sent(ah, "CANCEL ") != nullptr, "hangup while ringing sends CANCEL");
Check(Has(ah, Action::Type::SetDeadline), "arms 487 timeout after CANCEL");
Check(!m.Terminated(), "not terminated until 487");
// a late 200 wins the race: ACK, then BYE, then terminate local-hangup
auto ar = m.OnInviteResponse(R200(m.CallId()));
Check(Sent(ar, "ACK ") != nullptr, "race 200 is ACKed");
Check(Sent(ar, "BYE ") != nullptr, "race 200 then BYE (we cancelled)");
const Action* t = Find(ar, Action::Type::State);
Check(t && t->reason == "local-hangup", "cancel race -> local-hangup");
// no media for a cancelled call
Check(!Has(ar, Action::Type::StartMedia), "cancelled call starts no media");
}
// ---- 487 after CANCEL
{
imsd::util::Rng rng(11);
CallMachine m(c, rng, "ims-call-4", "1233", 50004, false);
m.Start();
m.OnHangup(); // CANCEL while dialing
auto a487 = m.OnInviteResponse(Final(m.CallId(), 487, "Request Terminated"));
Check(Sent(a487, "ACK ") != nullptr, "487 is ACKed (non-2xx)");
const Action* t = Find(a487, Action::Type::State);
Check(t && t->reason == "local-hangup", "487 -> local-hangup");
Check(m.Terminated(), "terminated on 487");
}
// ---- busy / declined finals
{
for (auto [code, want] : std::vector<std::pair<int, std::string>>{
{486, "refused-or-busy"}, {480, "refused-or-busy"},
{603, "refused-or-busy"}, {500, "error"}, {403, "error"}}) {
imsd::util::Rng rng(code);
CallMachine m(c, rng, "u", "1233", 50004, false);
m.Start();
auto a = m.OnInviteResponse(Final(m.CallId(), code, "x"));
Check(Sent(a, "ACK ") != nullptr, std::format("{} ACKed", code));
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == want, std::format("{} -> {}", code, want));
Check(m.Terminated(), std::format("{} terminates", code));
}
}
// ---- BYE after answer (local hangup during active call)
{
imsd::util::Rng rng(13);
CallMachine m(c, rng, "ims-call-5", "1233", 50004, false);
m.Start();
m.OnInviteResponse(R200(m.CallId()));
auto a = m.OnHangup();
const Action* bye = Sent(a, "BYE ");
Check(bye != nullptr, "active hangup sends BYE");
Check(bye && bye->text.contains("CSeq: 3 BYE\r\n"), "BYE CSeq after ACK=1");
const Action* t = Find(a, Action::Type::State);
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
}
// ---- setup timeout -> CANCEL, then error
{
imsd::util::Rng rng(15);
CallMachine m(c, rng, "ims-call-6", "1233", 50004, false);
m.Start();
auto a1 = m.OnDeadline(); // ring timeout
Check(Sent(a1, "CANCEL ") != nullptr, "ring timeout sends CANCEL");
Check(!m.Terminated(), "not terminated after first timeout");
auto a2 = m.OnDeadline(); // 487 never came
const Action* t = Find(a2, Action::Type::State);
Check(t && t->reason == "error", "second timeout -> error");
Check(m.Terminated(), "terminated on second timeout");
}
// ================= incoming (terminating / UAS) =======================
// ---- full accepted incoming call: INVITE -> 100+180, Accept -> 200 with
// the answer at the offer's payload types + media, ACK, remote BYE
{
imsd::util::Rng rng(21);
CallMachine m(c, rng, "ims-call-7", IncomingInvite{MtInvite()}, 50004);
Check(m.Dir() == Direction::Incoming, "direction incoming");
Check(m.State() == CallState::Incoming, "starts incoming");
Check(m.Number() == "+31612345678", "caller id from P-Asserted-Identity");
Check(m.CallId() == "mt-call-abc123", "call id from INVITE");
auto ai = m.OnInvite();
const Action* trying = Responded(ai, "SIP/2.0 100 Trying");
Check(trying != nullptr, "INVITE -> 100 Trying");
Check(trying && trying->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"), "100 leaves To untagged");
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
Check(ringing != nullptr, "INVITE -> 180 Ringing");
Check(ringing && !ringing->text.contains("RSeq:"), "180 unreliable when 100rel not required");
Check(Has(ai, Action::Type::SetDeadline), "arms ring timeout");
// both Vias echoed, in order
Check(ringing && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::105]:5100") && ringing->text.contains("Via: SIP/2.0/UDP [2001:db8::9]:5060"), "180 echoes all Vias");
Check(ringing && ringing->text.find("[2001:db8::105]:5100") < ringing->text.find("[2001:db8::9]:5060"), "180 Via order preserved");
Check(ringing && ringing->text.contains("Record-Route: <sip:[2001:db8::105]:5100;lr>"), "180 returns the Record-Route set");
Check(ringing && ringing->text.contains("To: <sip:+31611111111@ims.mnc001.mcc001." "3gppnetwork.org;user=phone>;tag="), "180 mints a To tag");
// retransmitted INVITE (lost provisional): repeat the 180
auto art = m.OnRequest(MtInvite());
Check(Responded(art, "SIP/2.0 180 Ringing") != nullptr, "INVITE retransmit re-sends the 180");
Check(m.State() == CallState::Incoming, "still incoming");
auto aa = m.OnAccept();
const Action* ok = Responded(aa, "SIP/2.0 200 OK");
Check(ok != nullptr, "Accept -> 200 OK");
Check(ok && ok->text.contains("m=audio 50004 RTP/AVP 104 105\r\n"), "answer uses the offer's payload types");
Check(ok && ok->text.contains("a=rtpmap:104 AMR-WB/16000/1\r\n"), "answer keeps AMR-WB at the offer's pt");
Check(ok && ok->text.contains("a=fmtp:104 octet-align=1\r\n"), "answer mirrors octet-align");
Check(ok && ok->text.contains("a=rtpmap:105 telephone-event/16000\r\n"), "answer keeps telephone-event");
Check(ok && ok->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires, caller refreshes");
Check(ok && ok->text.contains("Contact: <sip:001010123456789@"), "200 carries our Contact");
// the 180 and 200 carry the SAME To tag (one dialog)
if (ringing && ok) {
auto tag = [](const std::string& s) {
std::size_t at = s.find(";tag=", s.find("To:"));
return s.substr(at, s.find('\r', at) - at);
};
Check(tag(ringing->text) == tag(ok->text), "180/200 share the To tag");
}
const Action* media = Find(aa, Action::Type::StartMedia);
Check(media != nullptr, "Accept starts media");
Check(media && media->media.remoteIp == "2001:db8::30c", "media ip from offer");
Check(media && media->media.remotePort == 27864, "media port from offer");
Check(media && media->media.payloadType == 104, "media pt from offer");
const Action* st = Find(aa, Action::Type::State);
Check(st && st->state == "active" && st->reason == "accepted", "Accept emits active/accepted");
Check(m.State() == CallState::Active, "active after Accept");
// a second Accept is a no-op
Check(m.OnAccept().empty(), "double Accept is a no-op");
// the caller's ACK is consumed silently
std::string ack = std::format(
"ACK sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKack\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 1 ACK\r\nContent-Length: 0\r\n\r\n", m.CallId());
Check(m.OnRequest(ack).empty(), "ACK gets no response");
// session-refresh UPDATE without a body: bodyless 200
std::string upd = std::format(
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 2 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto au = m.OnRequest(upd);
const Action* u200 = Responded(au, "SIP/2.0 200 OK");
Check(u200 != nullptr, "bodyless UPDATE answered 200");
Check(u200 && u200->text.contains("Content-Length: 0"), "bodyless UPDATE gets a bodyless 200");
Check(u200 && !u200->text.contains("Session-Expires"), "no Session-Expires in the UPDATE -> none invented in the 200");
// session-audit UPDATE with Session-Expires (KPN B2BUA, ~55 s into
// every call): the 200 MUST echo it (RFC 4028 §9) or the network
// releases with "Session timer expired"
std::string se = std::format(
"UPDATE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKu2\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Min-SE: 90\r\nSession-Expires: 1800;refresher=uac\r\n"
"Supported: timer\r\n"
"Call-ID: {}\r\nCSeq: 3 UPDATE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ase = m.OnRequest(se);
const Action* se200 = Responded(ase, "SIP/2.0 200 OK");
Check(se200 != nullptr, "refresh UPDATE answered 200");
Check(se200 && se200->text.contains("Session-Expires: 1800;refresher=uac\r\n"), "200 echoes Session-Expires");
Check(se200 && se200->text.contains("Require: timer\r\n"), "200 carries Require: timer");
// remote BYE ends it
std::string bye = std::format(
"BYE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKb\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 4 BYE\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ab = m.OnRequest(bye);
Check(Responded(ab, "SIP/2.0 200 OK") != nullptr, "remote BYE answered");
const Action* t = Find(ab, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote BYE -> remote-hangup");
Check(m.Terminated(), "terminated");
}
// ---- reliable 180 when the caller requires 100rel; PRACK answered
{
imsd::util::Rng rng(23);
CallMachine m(c, rng, "ims-call-8", IncomingInvite{MtInvite("Require: 100rel\r\n")}, 50004);
auto ai = m.OnInvite();
const Action* ringing = Responded(ai, "SIP/2.0 180 Ringing");
Check(ringing && ringing->text.contains("Require: 100rel\r\n") && ringing->text.contains("RSeq: 1\r\n"), "Require: 100rel makes the 180 reliable");
std::string prack = std::format(
"PRACK sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKp\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>;tag=x\r\n"
"Call-ID: {}\r\nCSeq: 2 PRACK\r\nRAck: 1 1 INVITE\r\n"
"Content-Length: 0\r\n\r\n", m.CallId());
auto ap = m.OnRequest(prack);
const Action* p200 = Responded(ap, "SIP/2.0 200 OK");
Check(p200 != nullptr, "PRACK answered 200");
Check(p200 && p200->text.contains("CSeq: 2 PRACK\r\n"), "PRACK 200 echoes CSeq");
Check(m.State() == CallState::Incoming, "PRACK leaves state incoming");
}
// ---- local reject: HangUp while ringing -> 486
{
imsd::util::Rng rng(25);
CallMachine m(c, rng, "ims-call-9", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
auto ah = m.OnHangup();
const Action* busy = Responded(ah, "SIP/2.0 486 Busy Here");
Check(busy != nullptr, "reject sends 486");
Check(busy && busy->text.contains("CSeq: 1 INVITE\r\n"), "486 answers the INVITE");
const Action* t = Find(ah, Action::Type::State);
Check(t && t->reason == "local-hangup", "reject -> local-hangup");
Check(m.Terminated(), "terminated after reject");
}
// ---- remote CANCEL while ringing: 200 the CANCEL, 487 the INVITE
{
imsd::util::Rng rng(27);
CallMachine m(c, rng, "ims-call-10", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
std::string cancel = std::format(
"CANCEL sip:001010123456789@[2001:db8::db43]:45061 SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:5100;branch=z9hG4bKpcscf1\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: {}\r\nCSeq: 1 CANCEL\r\nContent-Length: 0\r\n\r\n", m.CallId());
auto ac = m.OnRequest(cancel);
const Action* c200 = Responded(ac, "SIP/2.0 200 OK");
Check(c200 && c200->text.contains("CSeq: 1 CANCEL\r\n"), "CANCEL answered 200 on its own transaction");
const Action* r487 = Responded(ac, "SIP/2.0 487 Request Terminated");
Check(r487 && r487->text.contains("CSeq: 1 INVITE\r\n"), "the INVITE transaction gets the 487");
const Action* t = Find(ac, Action::Type::State);
Check(t && t->reason == "remote-hangup", "remote CANCEL -> remote-hangup");
Check(m.Terminated(), "terminated after remote CANCEL");
}
// ---- offer we cannot serve (no AMR-WB): 488 before ringing
{
imsd::util::Rng rng(29);
std::string sdp =
"v=0\r\no=- 5 5 IN IP6 2001:db8::30c\r\ns=-\r\n"
"c=IN IP6 2001:db8::30c\r\nt=0 0\r\n"
"m=audio 27864 RTP/AVP 8\r\na=rtpmap:8 PCMA/8000\r\n";
std::string inv = std::format(
"INVITE sip:me SIP/2.0\r\nVia: SIP/2.0/UDP [x]:1;branch=z9hG4bKi\r\n"
"From: <sip:+31612345678@ims;user=phone>;tag=caller1\r\n"
"To: <sip:+31611111111@ims;user=phone>\r\n"
"Call-ID: mt-pcma\r\nCSeq: 1 INVITE\r\n"
"Contact: <sip:origgw@[2001:db8::9]:5060>\r\n"
"Content-Type: application/sdp\r\nContent-Length: {}\r\n\r\n{}",
sdp.size(), sdp);
CallMachine m(c, rng, "ims-call-11", IncomingInvite{inv}, 50004);
auto ai = m.OnInvite();
Check(Responded(ai, "SIP/2.0 488 Not Acceptable Here") != nullptr, "non-AMR-WB offer refused with 488");
Check(Responded(ai, "SIP/2.0 180 Ringing") == nullptr, "no 180 for a 488");
Check(m.Terminated(), "terminated on 488");
}
// ---- ring timeout: 480, missed call
{
imsd::util::Rng rng(31);
CallMachine m(c, rng, "ims-call-12", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
auto ad = m.OnDeadline();
Check(Responded(ad, "SIP/2.0 480 Temporarily Unavailable") != nullptr, "ring timeout sends 480");
const Action* t = Find(ad, Action::Type::State);
Check(t && t->reason == "remote-hangup", "timeout reads as missed");
Check(m.Terminated(), "terminated on ring timeout");
}
// ---- our BYE after accepting: UAS dialog fields hold
{
imsd::util::Rng rng(33);
CallMachine m(c, rng, "ims-call-13", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
m.OnAccept();
auto ah = m.OnHangup();
const Action* bye = Sent(ah, "BYE ");
Check(bye != nullptr, "hangup after accept sends BYE");
Check(bye && bye->text.starts_with("BYE sip:origgw@[2001:db8::9]:5060 SIP/2.0"), "BYE targets the caller's Contact");
Check(bye && bye->text.contains("CSeq: 1 BYE\r\n"), "our UAS CSeq space starts at 1");
Check(bye && bye->text.contains("To: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone>" ";tag=caller1\r\n"), "BYE To is the caller (their tag)");
Check(bye && bye->text.contains("From: <sip:001010123456789@ims.mnc001.mcc001." "3gppnetwork.org>;tag="), "BYE From is us (our tag)");
// UAS route set: Record-Route in RECEIVED order
Check(bye && bye->text.contains("Route: <sip:[2001:db8::105]:5100;lr>, " "<sip:[2001:db8::9]:5060;lr>\r\n"), "BYE routes via Record-Route in received order");
const Action* t = Find(ah, Action::Type::State);
Check(t && t->reason == "local-hangup", "active hangup -> local-hangup");
}
// ---- media death after accept releases the call
{
imsd::util::Rng rng(35);
CallMachine m(c, rng, "ims-call-14", IncomingInvite{MtInvite()}, 50004);
m.OnInvite();
m.OnAccept();
auto amx = m.OnMediaExit(3);
Check(Sent(amx, "BYE ") != nullptr, "media far-end hangup sends BYE (incoming too)");
Check(m.Terminated(), "terminated");
}
if (Failures == 0) std::println("Engine: all tests passed");
return Failures;
}

124
tests/Ipsec/main.cpp Normal file
View file

@ -0,0 +1,124 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Ipsec unit tests — the `ip xfrm` setup command sequence (byte-exact
// argv) and the warm-SA reader, fed `ip xfrm state`/`policy` output in the
// exact shape captured from a registered FP6 (addresses/keys synthetic).
import std;
import Imsd;
using namespace imsd::ipsec;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
std::string Join(const std::vector<std::string>& v) {
std::string s;
for (const auto& t : v) { s += t; s += ' '; }
if (!s.empty()) s.pop_back();
return s;
}
// `ip xfrm state` in the shape of a registered FP6's kernel state
// (P-CSCF ::105, UE ...db43).
constexpr std::string_view State =
"src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
"\tproto esp spi 0x0a84ab2f reqid 2 mode transport\n"
"\treplay-window 0 \n"
"\tauth-trunc hmac(sha1) 0x00112233445566778899aabbccddeeff 96\n"
"\tenc cbc(aes) 0xffeeddccbbaa99887766554433221100\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
"\tproto esp spi 0x0526f2c1 reqid 2 mode transport\n"
"\tenc cbc(aes) 0xffeeddccbbaa99887766554433221100\n"
"src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
"\tproto esp spi 0x0071acd4 reqid 1 mode transport\n"
"\tlastused 2026-07-19 20:42:01\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
"\tproto esp spi 0x0526f2c0 reqid 1 mode transport\n"
"\tlastused 2026-07-19 20:42:01\n";
constexpr std::string_view Policy =
"src 2001:db8::105/128 dst 2001:db8:1111:2222:3333:4444:5555:db43/128 sport 33421 dport 45062 \n"
"\tdir in priority 0 \n"
"\ttmpl src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
"\t\tproto esp reqid 2 mode transport\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::105/128 sport 45062 dport 33421 \n"
"\tdir out priority 0 \n"
"\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
"\t\tproto esp reqid 2 mode transport\n"
"src 2001:db8::105/128 dst 2001:db8:1111:2222:3333:4444:5555:db43/128 sport 6000 dport 45061 \n"
"\tdir in priority 0 \n"
"\ttmpl src 2001:db8::105 dst 2001:db8:1111:2222:3333:4444:5555:db43\n"
"\t\tproto esp reqid 1 mode transport\n"
"src 2001:db8:1111:2222:3333:4444:5555:db43/128 dst 2001:db8::105/128 sport 45061 dport 6000 \n"
"\tdir out priority 0 \n"
"\ttmpl src 2001:db8:1111:2222:3333:4444:5555:db43 dst 2001:db8::105\n"
"\t\tproto esp reqid 1 mode transport\n";
}
int main() {
// ---- warm-SA reader against real device output
{
auto sa = ParseExistingSa(State, Policy, "2001:db8::105", "2001:db8:1111:2222:3333:4444:5555:db43");
Check(sa.has_value(), "existing SA parses");
Check(sa && sa->portPs == 6000, "port-s (reqid1 out dport) = 6000");
Check(sa && sa->portPc == 33421, "port-c (reqid2 out dport) = 33421");
Check(sa && sa->spiUc == 0x0071acd4u, "spi_uc (P->UE reqid1)");
Check(sa && sa->spiUs == 0x0a84ab2fu, "spi_us (P->UE reqid2)");
Check(sa && sa->spiPs == 0x0526f2c0u, "spi_ps (UE->P reqid1)");
Check(sa && sa->spiPc == 0x0526f2c1u, "spi_pc (UE->P reqid2)");
Check(sa && sa->securityServer == "ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; " "spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000", "reconstructed Security-Server value");
}
// incomplete input -> nullopt
Check(!ParseExistingSa("src a dst b\n\tspi 0x1 reqid 1 mode transport\n", "", "b", "a").has_value(), "missing pieces -> nullopt");
// ---- fresh setup command sequence
{
SaParams p;
p.local = "2001:db8::db43";
p.pcscf = "2001:db8::105";
p.ik = std::vector<std::uint8_t>(16, 0xAB);
p.ck = std::vector<std::uint8_t>(16, 0xCD);
p.spiUc = 100; p.spiUs = 200; p.spiPc = 300; p.spiPs = 400;
p.portUc = 45061; p.portUs = 45062; p.portPs = 6000; p.portPc = 33421;
auto cmds = BuildSetupCommands(p);
Check(cmds.size() == 10, "flush x2 + 4 states + 4 policies");
Check(Join(cmds[0]) == "ip xfrm state flush", "state flush first");
Check(Join(cmds[1]) == "ip xfrm policy flush", "policy flush second");
// reqid1 outbound state: src LOCAL dst P spi=spiPs, IK auth, CK enc
Check(Join(cmds[2]) ==
"ip xfrm state add src 2001:db8::db43 dst 2001:db8::105 "
"proto esp spi 400 mode transport reqid 1 "
"auth-trunc hmac(sha1) 0xabababababababababababababababab 96 "
"enc cbc(aes) 0xcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
"state add reqid1 outbound byte-exact");
// first out policy: sport PORT_UC dport PORT_PS reqid 1
Check(Join(cmds[6]) == "ip xfrm policy add src 2001:db8::db43/128 dst 2001:db8::105/128 " "sport 45061 dport 6000 dir out tmpl src 2001:db8::db43 " "dst 2001:db8::105 proto esp reqid 1 mode transport", "policy add out reqid1 byte-exact");
// v4 uses /32
SaParams p4 = p;
p4.local = "10.0.0.2"; p4.pcscf = "10.0.0.1";
auto c4 = BuildSetupCommands(p4);
Check(c4[6][5] == "10.0.0.2/32", "v4 prefix length /32");
}
// null cipher -> cipher_null + empty key arg
{
SaParams p;
p.local = "::2"; p.pcscf = "::1";
p.ik = std::vector<std::uint8_t>(16, 0x01);
p.ck = {};
p.ealg = "null";
auto cmds = BuildSetupCommands(p);
auto& st = cmds[2];
Check(st[st.size() - 3] == "enc" && st[st.size() - 2] == "cipher_null" && st[st.size() - 1].empty(), "null cipher: cipher_null + empty key");
}
if (Failures == 0) std::println("Ipsec: all tests passed");
return Failures;
}

314
tests/Messages/main.cpp Normal file
View file

@ -0,0 +1,314 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Messages unit tests — every builder compared byte-for-byte against a
// reference message cross-generated from the Python VoLTE stack with pinned
// tags/branches/SPIs. A divergence here is a wire-format change, i.e. a
// registration or call that the commercial IMS core may reject; these are the
// hardest pins in the project. Two deliberate post-parity divergences:
// the REGISTER Contact carries +sip.instance and the MMTEL ICSI feature tag
// (the Python stack registered without them, which made the network divert
// incoming calls to voicemail — see ContactFeatures in Imsd:Messages), and
// every ICSI feature-tag value is percent-encoded (urn%3Aurn-7%3A...) — the
// TS 24.229 coding and what the stock modem sends on the wire; the Python
// stack's plain-colon form is a different string under RFC 3841 matching
// and left terminating access-domain selection routing incoming calls to
// CS fallback (observed live 2026-07-20).
import std;
import Imsd;
using namespace imsd::msg;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
void Eq(std::string_view got, std::string_view want, std::string_view msg) {
if (got != want) {
std::println(std::cerr, "FAIL: {}\n--- got ---\n{}\n--- want ---\n{}", msg, got, want);
++Failures;
}
}
Context MakeCtx() {
Context c;
c.id = imsd::aka::MakeIdentity("001010123456789", "001", "01");
c.local = "2001:db8::db43";
c.route = "<sip:[2001:db8::105]:6000;lr>";
c.ppi = "tel:+31611111111";
c.securityServer =
"ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; "
"spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000";
c.instanceId = "urn:gsma:imei:35999999-000000-1";
c.panInfo = "3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0";
return c;
}
constexpr std::string_view Reg1 =
"REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::db43]:5060;branch=z9hG4bKREGBRANCH123456;rport\r\n"
"Max-Forwards: 70\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=FTAG5678\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: REGCALLID@2001:db8::db43\r\n"
"CSeq: 1 REGISTER\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:5060>;+sip.instance=\"<urn:gsma:imei:35999999-000000-1>\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"\", response=\"\"\r\n"
"Require: sec-agree\r\n"
"Proxy-Require: sec-agree\r\n"
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n"
"Supported: sec-agree, path\r\n"
"Expires: 600000\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Content-Length: 0\r\n"
"\r\n";
constexpr std::string_view Reg2 =
"REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKREGBRANCH123456;rport\r\n"
"Max-Forwards: 70\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=FTAG5678\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: REGCALLID@2001:db8::db43\r\n"
"CSeq: 2 REGISTER\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>;+sip.instance=\"<urn:gsma:imei:35999999-000000-1>\";+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\";expires=600000\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, NOTIFY, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Authorization: Digest username=\"001010123456789@ims.mnc001.mcc001.3gppnetwork.org\", realm=\"ims.mnc001.mcc001.3gppnetwork.org\", uri=\"sip:ims.mnc001.mcc001.3gppnetwork.org\", nonce=\"NONCEXYZ\", qop=auth, nc=00000001, cnonce=\"CNONCE0123456789\", response=\"RESP0000\", algorithm=AKAv1-MD5\r\n"
"Require: sec-agree\r\n"
"Proxy-Require: sec-agree\r\n"
"Security-Client: ipsec-3gpp; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=7449812; spi-s=176466735; port-c=45061; port-s=45062\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Supported: sec-agree, path\r\n"
"Expires: 600000\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Content-Length: 0\r\n"
"\r\n";
constexpr std::string_view Sdp =
"v=0\r\n"
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8::db43\r\n"
"t=0 0\r\n"
"m=audio 50004 RTP/AVP 97 98\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
constexpr std::string_view Invite =
"INVITE sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKINVBRANCH0000000000;rport\r\n"
"Max-Forwards: 70\r\n"
"Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=ITAG123456\r\n"
"To: <sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone>\r\n"
"Call-ID: CALLID789@2001:db8::db43\r\n"
"CSeq: 1 INVITE\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n"
"Allow: INVITE, ACK, BYE, CANCEL, OPTIONS, PRACK, UPDATE, MESSAGE, REFER\r\n"
"Supported: 100rel, timer\r\n"
"Accept-Contact: *;+g.3gpp.icsi-ref=\"urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel\"\r\n"
"P-Preferred-Identity: <tel:+31611111111>\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Proxy-Require: sec-agree\r\n"
"Require: sec-agree\r\n"
"Session-Expires: 1800;refresher=uac\r\n"
"Min-SE: 90\r\n"
"Content-Type: application/sdp\r\n"
"Content-Length: 323\r\n"
"\r\n"
"v=0\r\n"
"o=- 7777777 7777777 IN IP6 2001:db8::db43\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8::db43\r\n"
"t=0 0\r\n"
"m=audio 50004 RTP/AVP 97 98\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
constexpr std::string_view Subscribe =
"SUBSCRIBE sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"
"Via: SIP/2.0/TCP [2001:db8::db43]:45062;branch=z9hG4bKSUBBRANCH0000000000;rport\r\n"
"Max-Forwards: 70\r\n"
"Route: <sip:[2001:db8::105]:6000;lr>\r\n"
"From: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>;tag=STAG1234\r\n"
"To: <sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Call-ID: SUBCALLID@2001:db8::db43\r\n"
"CSeq: 1 SUBSCRIBE\r\n"
"Contact: <sip:001010123456789@[2001:db8::db43]:45062>\r\n"
"Event: reg\r\n"
"Accept: application/reginfo+xml\r\n"
"Expires: 600000\r\n"
"P-Preferred-Identity: <tel:+31611111111>\r\n"
"P-Access-Network-Info: 3GPP-E-UTRAN-FDD;utran-cell-id-3gpp=0010112340ABCDE0\r\n"
"Security-Verify: ipsec-3gpp; q=0.1; alg=hmac-sha-1-96; ealg=aes-cbc; spi-c=86438593; spi-s=86438592; port-c=33421; port-s=6000\r\n"
"Proxy-Require: sec-agree\r\n"
"Require: sec-agree\r\n"
"Content-Length: 0\r\n"
"\r\n";
}
int main() {
Context c = MakeCtx();
// ---- REGISTER (initial + protected), byte-exact vs Python
Eq(BuildRegisterInitial(c, "REGCALLID@2001:db8::db43", "FTAG5678",
"REGBRANCH123456", 7449812u, 176466735u),
Reg1, "unprotected REGISTER");
{
std::string auth = AuthAka(c, "NONCEXYZ", "CNONCE0123456789", "RESP0000");
Eq(BuildRegisterProtected(c, "REGCALLID@2001:db8::db43", "FTAG5678",
"REGBRANCH123456", 2, 7449812u, 176466735u, auth),
Reg2, "protected REGISTER");
}
// ---- RuriFor variants
Eq(RuriFor("1233", c.id.domain), "sip:1233;phone-context=ims.mnc001.mcc001.3gppnetwork.org@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri short code");
Eq(RuriFor("+31612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri E.164");
Eq(RuriFor("0612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri Dutch national");
Eq(RuriFor("0031612345678", c.id.domain), "sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org;user=phone", "ruri 00 intl");
// ---- SUBSCRIBE (reg event, RFC 3680) — no Python counterpart; pinned
// against the shape of the other protected in-flow requests
Eq(BuildSubscribeReg(c, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000"), Subscribe, "reg-event SUBSCRIBE (no aor: temporary IMPU fallback)");
{
Context c2 = MakeCtx();
c2.aor = "sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org";
std::string sub = BuildSubscribeReg(c2, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000");
Check(sub.starts_with("SUBSCRIBE sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0\r\n"), "reg-event SUBSCRIBE aor R-URI");
Check(sub.contains("\r\nFrom: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>;tag=STAG1234\r\n"), "reg-event SUBSCRIBE aor From");
Check(sub.contains("\r\nTo: <sip:+31611111111@ims.mnc001.mcc001.3gppnetwork.org>\r\n"), "reg-event SUBSCRIBE aor To");
}
{
// no identity learned yet: P-Preferred-Identity is omitted, not "<>"
Context c3 = MakeCtx();
c3.ppi.clear();
Check(!BuildSubscribeReg(c3, "SUBCALLID@2001:db8::db43", "STAG1234", "SUBBRANCH0000000000") .contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in SUBSCRIBE");
Dialog d;
d.ruri = RuriFor("1233", c3.id.domain);
d.callid = "CID@x"; d.itag = "it"; d.invBranch = "ib";
Check(!BuildInvite(c3, d, "v=0\r\n", false).contains("P-Preferred-Identity"), "empty ppi omits P-Preferred-Identity in INVITE");
}
// ---- INVITE, byte-exact vs Python
{
Dialog d;
d.ruri = RuriFor("1233", c.id.domain);
d.callid = "CALLID789@2001:db8::db43";
d.itag = "ITAG123456";
d.invBranch = "INVBRANCH0000000000";
Eq(BuildInvite(c, d, Sdp, /*precond=*/false), Invite, "INVITE + AMR-WB SDP offer");
}
// ---- in-dialog PRACK/BYE + ACK + CANCEL + 200 (structural checks)
{
Dialog d;
d.ruri = RuriFor("1233", c.id.domain);
d.callid = "CID@x";
d.itag = "it";
d.invBranch = "ib";
d.dialogTo = "<sip:1233@x>;tag=remote99";
d.remoteTarget = "sip:termgw@2001:db8::9";
d.recordRoute = {"<sip:rr1;lr>", "<sip:rr2;lr>"};
// dialog route = reversed record-route
Check(DialogRoute(c, d) == "<sip:rr2;lr>, <sip:rr1;lr>", "dialog route reversed");
std::array<std::string, 1> rack = {"RAck: 42 1 INVITE"};
std::string prack = BuildInDialog(c, d, "PRACK", 2, "prbranch", rack);
Check(prack.starts_with("PRACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "PRACK request-URI = remote target");
Check(prack.contains("Route: <sip:rr2;lr>, <sip:rr1;lr>\r\n"), "PRACK dialog route");
Check(prack.contains("To: <sip:1233@x>;tag=remote99\r\n"), "PRACK To has remote tag");
Check(prack.contains("CSeq: 2 PRACK\r\n"), "PRACK CSeq");
Check(prack.contains("RAck: 42 1 INVITE\r\n"), "PRACK RAck");
Check(prack.ends_with("Content-Length: 0\r\n\r\n"), "PRACK empty body");
std::string bye = BuildInDialog(c, d, "BYE", 3, "byebranch");
Check(bye.starts_with("BYE sip:termgw@2001:db8::9 SIP/2.0\r\n"), "BYE line");
Check(bye.contains("CSeq: 3 BYE\r\n"), "BYE CSeq");
std::string ack = BuildAck2xx(c, d, "ackbranch", "<ignored>");
Check(ack.starts_with("ACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "ACK 2xx target");
Check(ack.contains("To: <sip:1233@x>;tag=remote99\r\n"), "ACK 2xx dialog To");
Check(ack.contains("CSeq: 1 ACK\r\n"), "ACK CSeq 1");
std::string cancel = BuildCancel(c, d);
Check(cancel.starts_with(std::format("CANCEL {} SIP/2.0\r\n", d.ruri)), "CANCEL request-URI = ruri");
Check(cancel.contains("branch=z9hG4bKib;rport\r\n"), "CANCEL reuses INVITE branch");
Check(cancel.contains("CSeq: 1 CANCEL\r\n"), "CANCEL CSeq copies INVITE");
}
{
// non-2xx ACK uses the INVITE branch + registration route + resp To
Dialog d;
d.ruri = "sip:x@y"; d.callid = "CID"; d.itag = "it"; d.invBranch = "ib9";
std::string ack = BuildAckNon2xx(c, d, "<sip:x@y>;tag=z");
Check(ack.contains("branch=z9hG4bKib9;rport\r\n"), "non-2xx ACK INVITE branch");
Check(ack.contains(std::format("Route: {}\r\n", c.route)), "non-2xx ACK registration route");
Check(ack.contains("To: <sip:x@y>;tag=z\r\n"), "non-2xx ACK echoes response To");
}
// ---- 200 OK response echoes the request's dialog headers, in order
{
std::string req =
"BYE sip:me@here SIP/2.0\r\n"
"Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"
"From: <sip:them@far>;tag=T\r\n"
"To: <sip:me@here>;tag=M\r\n"
"Call-ID: xyz@far\r\n"
"CSeq: 7 BYE\r\n"
"Content-Length: 0\r\n\r\n";
std::string r = BuildResponse200(c, req);
Check(r.starts_with("SIP/2.0 200 OK\r\n"), "200 status line");
Check(r.contains("Via: SIP/2.0/TCP [a::1]:45061;branch=z9hG4bKother\r\n"), "200 echoes Via");
Check(r.contains("Call-ID: xyz@far\r\n"), "200 echoes Call-ID");
Check(r.contains("CSeq: 7 BYE\r\n"), "200 echoes CSeq");
Check(r.ends_with("Content-Length: 0\r\n\r\n"), "200 empty body");
// with an SDP answer: our Contact + application/sdp appear
std::string r2 = BuildResponse200(c, req, Sdp);
Check(r2.contains("Content-Type: application/sdp\r\n"), "200 w/ SDP content-type");
Check(r2.contains("Contact: <sip:001010123456789@[2001:db8::db43]:45062>\r\n"), "200 w/ SDP includes our Contact");
Check(r2.ends_with(Sdp), "200 w/ SDP body");
}
// ---- s53 oracle diff: UUID Contact user-part + User-Agent ------------
{
Context c = MakeCtx();
c.contactUser = "029dd36c-857c-4270-ad7f-0ae70d31d50f";
c.userAgent = "Fairphone_Fairphone 6_FP6.QREL.16.82.0";
std::string r1 = BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2);
Check(r1.contains("Contact: <sip:029dd36c-857c-4270-ad7f-0ae70d31d50f@" "[2001:db8::db43]:5060>"), "reg1 Contact carries contactUser, not the IMSI");
Check(!r1.contains("sip:001010123456789@[2001:db8::db43]"), "reg1 has no IMSI-user Contact when contactUser set");
Check(r1.contains("From: <sip:001010123456789@ims.mnc001.mcc001"), "reg1 From/AOR stays the IMSI IMPU");
Check(r1.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg1 User-Agent emitted");
std::string r2 = BuildRegisterProtected(c, "cid@h", "ft", "br", 2, 1, 2, "Authorization: x");
Check(r2.contains("Contact: <sip:029dd36c-857c-4270-ad7f-0ae70d31d50f@" "[2001:db8::db43]:45062>"), "reg2 Contact carries contactUser at the protected port");
Check(r2.contains("User-Agent: Fairphone_Fairphone 6_FP6.QREL.16.82.0\r\n"), "reg2 User-Agent emitted");
c.userAgent.clear();
Check(!BuildRegisterInitial(c, "cid@h", "ft", "br", 1, 2) .contains("User-Agent:"), "empty userAgent omits the header");
}
if (Failures == 0) std::println("Messages: all tests passed");
return Failures;
}

204
tests/Sdp/main.cpp Normal file
View file

@ -0,0 +1,204 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Sdp unit tests — offer building (byte-exact) and answer parsing
// (payload-type preference, octet-align, address family). The IPv6 answer
// mirrors what a live commercial IMS core actually returns (AMR-WB pt=97
// octet-aligned, IPv6 media gateway).
import std;
import Imsd;
using namespace imsd::sdp;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
}
int main() {
// ---- BuildOffer, IPv6 + preconditions (the shape a VoLTE UE sends)
{
std::string sdp = BuildOffer({
.local = "2001:db8:29e9:a05f::1",
.rtpPort = 22222,
.sessionId = 1234567,
.precond = true,
});
std::string expected =
"v=0\r\n"
"o=- 1234567 1234567 IN IP6 2001:db8:29e9:a05f::1\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8:29e9:a05f::1\r\n"
"t=0 0\r\n"
"m=audio 22222 RTP/AVP 97 98\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=fmtp:98 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=curr:qos local sendrecv\r\n"
"a=curr:qos remote none\r\n"
"a=des:qos mandatory local sendrecv\r\n"
"a=des:qos mandatory remote sendrecv\r\n"
"a=sendrecv\r\n";
Check(sdp == expected, "IPv6 precondition offer is byte-exact");
}
// ---- BuildOffer, IPv4 without preconditions
{
std::string sdp = BuildOffer({
.local = "10.0.0.2",
.rtpPort = 4000,
.sessionId = 42,
.precond = false,
});
Check(sdp.contains("o=- 42 42 IN IP4 10.0.0.2\r\n"), "IPv4 origin line");
Check(sdp.contains("c=IN IP4 10.0.0.2\r\n"), "IPv4 connection line");
Check(!sdp.contains("a=curr:qos"), "no precondition block");
Check(sdp.ends_with("a=sendrecv\r\n"), "trailing sendrecv + CRLF");
}
// ---- ParseAnswer, real-world IPv6 answer
{
Answer a = ParseAnswer(
"v=0\r\n"
"o=- 99 99 IN IP6 2001:db8::307\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8::307\r\n"
"t=0 0\r\n"
"m=audio 35868 RTP/AVP 97 98\r\n"
"a=rtpmap:97 AMR-WB/16000/1\r\n"
"a=fmtp:97 octet-align=1;mode-change-capability=2\r\n"
"a=rtpmap:98 telephone-event/16000\r\n"
"a=ptime:20\r\n");
Check(a.ip == "2001:db8::307", "answer IP6");
Check(a.port == 35868, "answer port");
Check(a.payloadType == 97, "answer payload type");
Check(a.octetAlign, "answer octet-align");
Check(a.codec == "AMR-WB", "answer codec");
}
// ---- payload-type preference: AMR remembered, later AMR-WB overrides
{
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 96 97\r\n" "a=rtpmap:96 AMR/8000/1\r\n" "a=rtpmap:97 AMR-WB/16000/1\r\n");
Check(a.payloadType == 97, "AMR-WB preferred over earlier AMR");
Check(a.codec == "AMR-WB", "codec follows the preferred rtpmap");
}
// ---- octet-aligned AMR-WB preferred over an earlier BE AMR-WB
// (the s56 MT-static shape: an offer listing bandwidth-efficient first)
{
Answer a = ParseAnswer(
"c=IN IP6 2001:db8::1\r\n"
"m=audio 5000 RTP/AVP 116 118 110\r\n"
"a=rtpmap:116 AMR-WB/16000/1\r\n"
"a=fmtp:116 mode-change-capability=2;max-red=0\r\n"
"a=rtpmap:118 AMR-WB/16000/1\r\n"
"a=fmtp:118 octet-align=1;mode-change-capability=2\r\n"
"a=rtpmap:110 telephone-event/16000\r\n");
Check(a.payloadType == 118, "octet-aligned AMR-WB wins over earlier BE");
Check(a.octetAlign, "octet-align follows the chosen pt");
Check(a.codec == "AMR-WB", "codec is AMR-WB");
}
// ---- BE-only AMR-WB offer stays BE at the first pt
{
Answer a = ParseAnswer(
"c=IN IP6 2001:db8::1\r\n"
"m=audio 5000 RTP/AVP 116 96\r\n"
"a=rtpmap:116 AMR-WB/16000/1\r\n"
"a=fmtp:116 mode-change-capability=2\r\n"
"a=rtpmap:96 AMR/8000/1\r\n"
"a=fmtp:96 octet-align=1\r\n");
Check(a.payloadType == 116, "BE-only AMR-WB beats octet-aligned AMR");
Check(!a.octetAlign, "BE-only offer negotiates bandwidth-efficient");
}
// ---- AMR-only answer
{
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 96\r\n" "a=rtpmap:96 AMR/8000/1\r\n" "a=fmtp:96 octet-align=1\r\n");
Check(a.payloadType == 96 && a.codec == "AMR", "plain AMR fallback");
Check(a.octetAlign, "octet-align seen for AMR too");
}
// ---- no rtpmap at all -> first payload type, bandwidth-efficient
{
Answer a = ParseAnswer("c=IN IP4 192.0.2.1\r\n" "m=audio 5000 RTP/AVP 8 0\r\n");
Check(a.payloadType == 8, "no rtpmap -> first payload type");
Check(!a.octetAlign, "no fmtp -> bandwidth-efficient");
Check(a.codec.empty(), "no AMR rtpmap -> codec unknown, not assumed");
}
// ---- degenerate body
{
Answer a = ParseAnswer("v=0\r\n");
Check(a.ip.empty() && a.port == -1 && a.payloadType == -1, "empty answer parses to absent fields");
}
// ---- TelephoneEventPt
{
std::string_view offer =
"m=audio 27864 RTP/AVP 104 105\r\n"
"a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=rtpmap:105 telephone-event/16000\r\n"
"a=rtpmap:106 telephone-event/8000\r\n";
Check(TelephoneEventPt(offer, 16000) == 105, "telephone-event pt by clock rate");
Check(TelephoneEventPt(offer, 8000) == 106, "narrowband telephone-event pt");
Check(!TelephoneEventPt(offer, 48000).has_value(), "absent clock rate -> nullopt");
Check(!TelephoneEventPt("m=audio 1 RTP/AVP 0\r\n", 16000).has_value(), "no rtpmap -> nullopt");
}
// ---- BuildAnswer: the offer's own payload types, octet-align mirrored
{
std::string sdp = BuildAnswer({
.local = "2001:db8:29e9:a05f::1",
.rtpPort = 50004,
.sessionId = 777,
.payloadType = 104,
.octetAlign = true,
.codec = "AMR-WB",
.dtmfPt = 105,
});
std::string expected =
"v=0\r\n"
"o=- 777 777 IN IP6 2001:db8:29e9:a05f::1\r\n"
"s=-\r\n"
"c=IN IP6 2001:db8:29e9:a05f::1\r\n"
"t=0 0\r\n"
"m=audio 50004 RTP/AVP 104 105\r\n"
"b=AS:41\r\n"
"b=RS:512\r\n"
"b=RR:1536\r\n"
"a=rtpmap:104 AMR-WB/16000/1\r\n"
"a=fmtp:104 octet-align=1\r\n"
"a=rtpmap:105 telephone-event/16000\r\n"
"a=fmtp:105 0-15\r\n"
"a=ptime:20\r\n"
"a=maxptime:240\r\n"
"a=sendrecv\r\n";
Check(sdp == expected, "AMR-WB answer is byte-exact");
}
// ---- BuildAnswer: bandwidth-efficient offer, no DTMF, narrowband
{
std::string sdp = BuildAnswer({
.local = "10.0.0.2",
.rtpPort = 4000,
.sessionId = 8,
.payloadType = 96,
.octetAlign = false,
.codec = "AMR",
});
Check(sdp.contains("m=audio 4000 RTP/AVP 96\r\n"), "single-pt m-line");
Check(sdp.contains("a=rtpmap:96 AMR/8000/1\r\n"), "narrowband rtpmap");
Check(!sdp.contains("octet-align"), "bandwidth-efficient stays unmarked");
Check(!sdp.contains("telephone-event"), "no DTMF when the offer had none");
Check(sdp.contains("b=AS:30\r\n"), "narrowband bandwidth");
}
if (Failures == 0) std::println("Sdp: all tests passed");
return Failures;
}

179
tests/Sip/main.cpp Normal file
View file

@ -0,0 +1,179 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Sip unit tests — header access, status-line parsing, granted-expires
// extraction, caller-identity extraction, and the TCP frame buffer (split
// delivery, keepalive pongs, pipelined messages). These pin the module's
// edge-case behavior; see the module header before "fixing" one.
import std;
import Imsd;
using namespace imsd::sip;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
const std::string Resp200 =
"SIP/2.0 200 OK\r\n"
"Via: SIP/2.0/TCP [2001:db8::1]:45061;branch=z9hG4bK1\r\n"
"Via: SIP/2.0/TCP [2001:db8::2]:5060;branch=z9hG4bK2\r\n"
"From: <sip:user@ims.mnc001.mcc001.3gppnetwork.org>;tag=abc\r\n"
"To: <sip:user@ims.mnc001.mcc001.3gppnetwork.org>;tag=def\r\n"
"Call-ID: 12345@host\r\n"
"CSeq: 2 REGISTER\r\n"
"contact: <sip:user@[2001:db8::1]:45061>;expires=600000\r\n"
"P-Associated-URI: <sip:+31612345678@ims.mnc001.mcc001.3gppnetwork.org>\r\n"
"Content-Length: 0\r\n"
"\r\n";
}
int main() {
// ---- Status
Check(Status(Resp200) == 200, "Status parses 200");
Check(Status("SIP/2.0 183 Session Progress\r\n\r\n") == 183, "Status parses 183");
Check(!Status("INVITE sip:+31@x SIP/2.0\r\n\r\n").has_value(), "Status rejects a request start-line");
Check(!Status("SIP/2.0 xx3\r\n").has_value(), "Status rejects non-digits");
// ---- Header / Headers
Check(Header(Resp200, "Call-ID") == "12345@host", "Header finds Call-ID");
Check(Header(Resp200, "call-id") == "12345@host", "Header is case-insensitive (query)");
Check(Header(Resp200, "Contact") == "<sip:user@[2001:db8::1]:45061>;expires=600000", "Header is case-insensitive (message)");
Check(!Header(Resp200, "Route").has_value(), "Header absent -> nullopt");
Check(Header(Resp200, "CSeq") == "2 REGISTER", "Header trims the value");
Check(Headers(Resp200, "Via").size() == 2, "Headers returns every Via");
Check(Headers(Resp200, "Via")[1] == "SIP/2.0/TCP [2001:db8::2]:5060;branch=z9hG4bK2", "Headers keeps order");
// name must sit at line start: "P-Associated-URI" must not match "URI"
Check(!Header(Resp200, "URI").has_value(), "Header anchors at line start");
// bare "Name:" (only CR after the colon) matches with empty value
Check(Header("X-Empty:\r\nX-Real: v\r\n\r\n", "X-Empty") == "", "bare header matches with empty value");
// ---- GrantedExpires
Check(GrantedExpires(Resp200) == 600000, "GrantedExpires from Contact expires=");
Check(GrantedExpires("SIP/2.0 200 OK\r\nExpires: 3600\r\n\r\n") == 3600, "GrantedExpires falls back to Expires");
Check(!GrantedExpires("SIP/2.0 200 OK\r\nExpires: abc\r\n\r\n").has_value(), "non-numeric Expires -> nullopt");
Check(!GrantedExpires("SIP/2.0 200 OK\r\n\r\n").has_value(), "no lifetime -> nullopt");
Check(GrantedExpires("SIP/2.0 200 OK\r\n" "Contact: <sip:a@b>\r\n" "Contact: <sip:c@d>;expires=7200\r\n\r\n") == 7200, "GrantedExpires scans all Contacts");
// ---- FrameBuffer
{
FrameBuffer fb;
fb.Append(Resp200);
auto m = fb.TryExtract();
Check(m == Resp200, "whole message extracts intact");
Check(!fb.TryExtract().has_value(), "buffer empty after extract");
}
{
// split mid-header and mid-body
std::string body = "v=0\r\no=- 1 1 IN IP6 ::1\r\n";
std::string msg = std::format("SIP/2.0 200 OK\r\nContent-Length: {}\r\n\r\n{}", body.size(), body);
FrameBuffer fb;
fb.Append(msg.substr(0, 20));
Check(!fb.TryExtract().has_value(), "incomplete head -> nullopt");
fb.Append(msg.substr(20, msg.size() - 20 - 5));
Check(!fb.TryExtract().has_value(), "incomplete body -> nullopt");
fb.Append(msg.substr(msg.size() - 5));
Check(fb.TryExtract() == msg, "split message reassembles");
}
{
// leading CRLF keepalive pongs are discarded (RFC 5626)
FrameBuffer fb;
fb.Append("\r\n\r\n");
Check(!fb.TryExtract().has_value(), "pure keepalive -> nullopt");
Check(fb.Size() == 0, "keepalive bytes discarded");
fb.Append("\r\n");
fb.Append(Resp200);
Check(fb.TryExtract() == Resp200, "message after keepalive extracts");
}
{
// two pipelined messages come out one at a time, in order
std::string first = "SIP/2.0 100 Trying\r\nContent-Length: 0\r\n\r\n";
FrameBuffer fb;
fb.Append(first + Resp200);
Check(fb.TryExtract() == first, "pipelined: first message");
Check(fb.TryExtract() == Resp200, "pipelined: second message");
Check(!fb.TryExtract().has_value(), "pipelined: then empty");
}
{
// missing Content-Length counts as 0
std::string msg = "SIP/2.0 200 OK\r\nCall-ID: x\r\n\r\n";
FrameBuffer fb;
fb.Append(std::format("{}LEFTOVER", msg));
Check(fb.TryExtract() == msg, "no Content-Length -> body length 0");
Check(fb.Size() == 8, "trailing bytes stay buffered");
}
// ---- CallerId: P-Asserted-Identity preferred, From fallback
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"From: \"Someone\" <sip:+31687654321@ims.example;user=phone>;tag=f\r\n"
"P-Asserted-Identity: <tel:+31612345678>\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "+31612345678", "tel: P-Asserted-Identity wins");
}
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"P-Asserted-Identity: <sip:+31612345678@ims.example;user=phone>\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "+31612345678", "sip: identity yields the user part");
}
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"From: <sip:+31687654321@ims.example;user=phone>;tag=f\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "+31687654321", "From fallback without P-Asserted-Identity");
}
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"From: <sip:anonymous@anonymous.invalid>;tag=f\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "anonymous", "anonymous caller passes through");
}
{
std::string inv =
"INVITE sip:me SIP/2.0\r\n"
"From: tel:+31600000001;tag=f\r\n"
"Content-Length: 0\r\n\r\n";
Check(CallerId(inv) == "+31600000001", "bare (bracketless) tel URI");
Check(CallerId("INVITE sip:me SIP/2.0\r\nContent-Length: 0\r\n\r\n").empty(), "no identity headers -> empty");
}
// ---- ViaResponsePort: RFC 3261 18.2.2 UDP response routing
{
std::string upd =
"UPDATE sip:me SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:6000;branch=z9hG4bKx;_aluscr_\r\n"
"Content-Length: 0\r\n\r\n";
Check(ViaResponsePort(upd) == 6000, "v6 sent-by port extracted");
std::string rp =
"UPDATE sip:me SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105]:6000;rport;branch=z9hG4bKx\r\n"
"Content-Length: 0\r\n\r\n";
Check(!ViaResponsePort(rp).has_value(), "rport -> symmetric (nullopt)");
std::string v4 =
"UPDATE sip:me SIP/2.0\r\n"
"Via: SIP/2.0/UDP 192.0.2.1:5062;branch=z9hG4bKx\r\n"
"Content-Length: 0\r\n\r\n";
Check(ViaResponsePort(v4) == 5062, "v4 sent-by port extracted");
std::string np =
"UPDATE sip:me SIP/2.0\r\n"
"Via: SIP/2.0/UDP [2001:db8::105];branch=z9hG4bKx\r\n"
"Content-Length: 0\r\n\r\n";
Check(!ViaResponsePort(np).has_value(), "portless v6 sent-by -> nullopt");
Check(!ViaResponsePort("UPDATE sip:me SIP/2.0\r\n\r\n").has_value(), "no Via -> nullopt");
}
if (Failures == 0) std::println("Sip: all tests passed");
return Failures;
}

100
tests/Util/main.cpp Normal file
View file

@ -0,0 +1,100 @@
// SPDX-License-Identifier: GPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// lint-disable-file fixed-width-types
// Imsd:Util unit tests — MD5 against the RFC 1321 test suite, base64/hex
// decoding, host/port formatting, and the deterministic-seed RNG. MD5 and
// base64 are our own (the standard library ships neither) and the AKAv1-MD5
// digest depends on them exactly, so these are pinned hard.
import std;
import Imsd;
using namespace imsd::util;
namespace {
int Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
}
int main() {
// ---- MD5: the RFC 1321 appendix A.5 test suite
Check(Md5::HexDigest("") == "d41d8cd98f00b204e9800998ecf8427e", "md5 empty");
Check(Md5::HexDigest("a") == "0cc175b9c0f1b6a831c399e269772661", "md5 a");
Check(Md5::HexDigest("abc") == "900150983cd24fb0d6963f7d28e17f72", "md5 abc");
Check(Md5::HexDigest("message digest") == "f96b697d7cb7938d525a2f31aaf161d0", "md5 message digest");
Check(Md5::HexDigest("abcdefghijklmnopqrstuvwxyz") == "c3fcd3d76192e4007dfb496cca67e13b", "md5 alphabet");
Check(Md5::HexDigest("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") == "d174ab98d277d9f5a5611c2c9f419d9f", "md5 alnum");
Check(Md5::HexDigest("12345678901234567890123456789012345678901234567890" "123456789012345678901234567890") == "57edf4a22be3c955ac49da2e2107b67a", "md5 80 digits");
// streaming across a block boundary must equal a single-shot digest
{
std::string big(200, 'x');
Md5 m;
m.Update(std::string_view(big).substr(0, 13));
m.Update(std::string_view(big).substr(13, 100));
m.Update(std::string_view(big).substr(113));
Check(ToHex(m.Digest()) == Md5::HexDigest(big), "md5 streaming == oneshot");
}
// ---- base64 decode
{
auto d = FromBase64("aGVsbG8=");
Check(d.has_value(), "b64 decodes");
Check(std::string(d->begin(), d->end()) == "hello", "b64 hello");
}
{
// 32-byte RAND||AUTN style payload round-trips through hex->b64->decode
auto d = FromBase64("TWFuIGlzIGRpc3Rpbmd1aXNoZWQ=");
Check(d && std::string(d->begin(), d->end()) == "Man is distinguished", "b64 longer");
}
Check(!FromBase64("****").has_value(), "b64 rejects junk");
// ---- hex
{
auto d = FromHex("00ff10");
Check(d && d->size() == 3 && (*d)[1] == 0xFF, "hex decodes");
auto c = FromHex("A0:00:00:00:87");
Check(c && c->size() == 5 && (*c)[0] == 0xA0, "hex skips colons (APDU form)");
Check(!FromHex("abc").has_value(), "hex rejects odd length");
Check(!FromHex("zz").has_value(), "hex rejects bad char");
std::array<std::uint8_t, 3> b{0x0a, 0x84, 0xab};
Check(ToHex(b) == "0a84ab", "tohex");
}
// ---- host/port + is6
Check(Is6("2001:db8::1") && !Is6("10.0.0.1"), "is6");
Check(HostPort("2001:db8::1", 45061) == "[2001:db8::1]:45061", "hostport v6");
Check(HostPort("10.0.0.1", 5060) == "10.0.0.1:5060", "hostport v4");
// ---- RNG: deterministic seed reproducibility + alphabet + length
{
Rng a(1234), b(1234);
std::string ta = a.Token(20), tb = b.Token(20);
Check(ta.size() == 20, "token length");
Check(ta == tb, "same seed -> same token");
for (char c : ta)
Check((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'), "token alphabet");
Rng c(9999);
Check(c.Token(20) != ta, "different seed -> different token");
}
// ---- Uuid4: RFC 4122 v4 shape
{
Rng r(42);
std::string u = Uuid4(r);
Check(u.size() == 36, "uuid length");
Check(u[8] == '-' && u[13] == '-' && u[18] == '-' && u[23] == '-', "uuid dashes");
Check(u[14] == '4', "uuid version nibble");
Check(u[19] == '8' || u[19] == '9' || u[19] == 'a' || u[19] == 'b', "uuid variant nibble");
for (std::size_t i = 0; i < u.size(); i++)
if (i != 8 && i != 13 && i != 18 && i != 23) Check((u[i] >= '0' && u[i] <= '9') || (u[i] >= 'a' && u[i] <= 'f'), "uuid hex alphabet");
Rng r2(42);
Check(Uuid4(r2) == u, "same seed -> same uuid");
}
if (Failures == 0) std::println("Util: all tests passed");
return Failures;
}