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:
commit
6e77823933
31 changed files with 8761 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
674
LICENSE
Normal 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
215
README.md
Normal 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 (0–8) |
|
||||
| `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
590
implementations/dialerd.cpp
Normal 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)", ®);
|
||||
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
1451
implementations/main.cpp
Normal file
File diff suppressed because it is too large
Load diff
665
implementations/media.cpp
Normal file
665
implementations/media.cpp
Normal 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
297
interfaces/Imsd-Aka.cppm
Normal 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
507
interfaces/Imsd-Engine.cppm
Normal 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
208
interfaces/Imsd-Ipsec.cppm
Normal 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;
|
||||
}
|
||||
}
|
||||
457
interfaces/Imsd-Messages.cppm
Normal file
457
interfaces/Imsd-Messages.cppm
Normal 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
227
interfaces/Imsd-Sdp.cppm
Normal 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
236
interfaces/Imsd-Sip.cppm
Normal 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
261
interfaces/Imsd-Util.cppm
Normal 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
15
interfaces/Imsd.cppm
Normal 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
858
lint-rules.h
Normal 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
42
packaging/APKBUILD
Normal 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
36
packaging/APKBUILD.binary
Normal 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
106
packaging/ims-pdn-up.sh
Normal 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
|
||||
11
packaging/imsd-dialerd.desktop
Normal file
11
packaging/imsd-dialerd.desktop
Normal 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
34
packaging/imsd.service
Normal 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
28
packaging/make-bin-tarball.sh
Executable 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
45
packaging/make-sysroot.sh
Executable 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"
|
||||
22
packaging/net.catcrafts.IMS1.conf
Normal file
22
packaging/net.catcrafts.IMS1.conf
Normal 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
164
project.cpp
Normal 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
150
tests/Aka/main.cpp
Normal 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
530
tests/Engine/main.cpp
Normal 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
124
tests/Ipsec/main.cpp
Normal 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
314
tests/Messages/main.cpp
Normal 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
204
tests/Sdp/main.cpp
Normal 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
179
tests/Sip/main.cpp
Normal 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
100
tests/Util/main.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue