commit 6e77823933f795d443ad24382c7ddfa157a739bf Author: Jorijn van der Graaf Date: Wed Jul 22 22:53:28 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51b3a5d --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ad6543 --- /dev/null +++ b/README.md @@ -0,0 +1,215 @@ + + + +# 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--/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/` | 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 diff --git a/implementations/dialerd.cpp b/implementations/dialerd.cpp new file mode 100644 index 0000000..df6b3e8 --- /dev/null +++ b/implementations/dialerd.cpp @@ -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 +#include + +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::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 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(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(m), err->message)); + g_error_free(err); + } + if (r) g_variant_unref(r); + }, + const_cast(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( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +)xml"; + +constexpr const char* DeviceXml = R"xml( + + + + + + + + + +)xml"; + +constexpr const char* UssdXml = R"xml( + + + + + + + + + + + + + + + + + + +)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(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(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; +} diff --git a/implementations/main.cpp b/implementations/main.cpp new file mode 100644 index 0000000..1c56fe6 --- /dev/null +++ b/implementations/main.cpp @@ -0,0 +1,1451 @@ +// SPDX-License-Identifier: GPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// lint-disable-file fixed-width-types no-char-pointer +/* +imsd — the userspace IMS/VoLTE daemon (control plane). + +Owns net.catcrafts.IMS1 on the system bus with the project's frozen D-Bus ABI +(README) and drives it with the engine core: register on the IMS PDN (USIM AKA ++ IPsec sec-agree, fresh or warm-resume, with a keepalive re-REGISTER refresh), +run outgoing and incoming calls through the Imsd:Engine call machine (inbound +INVITEs on the protected-port listeners become ringing calls a dialer answers +via Accept), and spawn one imsd-media data-plane process per answered call. +GLib/GDBus lives only here; every message, digest, SA command, and call-state +decision comes from the GLib-free core. + +Threads: a dedicated engine thread runs the SIP loop (blocking framed recv, +protected-port listeners, media reaping, keepalive); the GLib main loop owns +the bus. D-Bus method calls push commands to the engine queue; engine events +reach the bus via g_idle_add, where the calls table + status snapshot live +(touched only on the main thread). + + --session own the name on the session bus (development; no policy needed) +*/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +import std; +import Imsd; + +namespace { + +// ---- static config (env-overridable, same knobs as imsd.py) --------------- +constexpr const char* Version = "0.2.7"; +constexpr const char* BusName = "net.catcrafts.IMS1"; +constexpr const char* ObjPath = "/net/catcrafts/IMS1"; +constexpr const char* Iface = "net.catcrafts.IMS1"; +constexpr int PortUc = imsd::util::PortUc; +constexpr int PortUs = imsd::util::PortUs; +constexpr int InitPort = 5060; + +std::string EnvOr(const char* k, std::string d) { + const char* v = std::getenv(k); + return v ? std::string(v) : std::move(d); +} + +std::string SelfDir; // dir of argv[0], for locating imsd-media + +void Log(std::string_view m) { std::println("imsd: {}", m); std::fflush(stdout); } + +bool Is6(std::string_view a) { return a.contains(':'); } + +// ---- subprocess exec ------------------------------------------------------ + +struct ProcResult { int rc = -1; std::string out; }; + +// Run argv, capture stdout (stderr discarded), wait. No shell. +ProcResult RunCapture(const std::vector& argv) { + int pipefd[2]; + if (pipe(pipefd) != 0) return {}; + pid_t pid = fork(); + if (pid == 0) { + dup2(pipefd[1], STDOUT_FILENO); + close(pipefd[0]); close(pipefd[1]); + int dn = open("/dev/null", O_WRONLY); + if (dn >= 0) { dup2(dn, STDERR_FILENO); close(dn); } + std::vector c; + for (auto& s : argv) c.push_back(const_cast(s.c_str())); + c.push_back(nullptr); + execvp(c[0], c.data()); + _exit(127); + } + if (pid < 0) { close(pipefd[0]); close(pipefd[1]); return {}; } + close(pipefd[1]); + std::string out; + char buf[4096]; + ssize_t n; + while ((n = read(pipefd[0], buf, sizeof buf)) > 0) + out.append(buf, static_cast(n)); + close(pipefd[0]); + int status = 0; + waitpid(pid, &status, 0); + return {WIFEXITED(status) ? WEXITSTATUS(status) : -1, std::move(out)}; +} + +// Run argv, discard output, return exit code. +int RunCmd(const std::vector& argv) { return RunCapture(argv).rc; } + +// ---- small text extractors (401 header params, qmicli "completed:") ------- + +std::optional QuotedAfter(std::string_view text, std::string_view key) { + std::size_t at = text.find(key); + if (at == std::string_view::npos) return std::nullopt; + std::size_t start = at + key.size(); + std::size_t end = text.find('"', start); + if (end == std::string_view::npos) return std::nullopt; + return std::string(text.substr(start, end - start)); +} + +std::optional IntAfter(std::string_view text, std::string_view key) { + std::size_t at = text.find(key); + if (at == std::string_view::npos) return std::nullopt; + std::size_t i = at + key.size(); + std::size_t start = i; + while (i < text.size() && text[i] >= '0' && text[i] <= '9') i++; + if (i == start) return std::nullopt; + long v = 0; + std::from_chars(text.data() + start, text.data() + i, v); + return v; +} + +std::string EalgAfter(std::string_view ss) { + std::size_t at = ss.find("ealg="); + if (at == std::string_view::npos) return "null"; + std::size_t i = at + 5; + std::size_t start = i; + auto ok = [](char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-'; }; + while (i < ss.size() && ok(ss[i])) i++; + return std::string(ss.substr(start, i - start)); +} + +// value after "completed:" — integer or the hex ("AB:CD:..") token +std::optional CompletedInt(std::string_view out) { + return IntAfter(out, "completed: "); +} +std::optional CompletedHex(std::string_view out) { + std::size_t at = out.find("completed:"); + if (at == std::string_view::npos) return std::nullopt; + std::size_t i = at + 10; + while (i < out.size() && (out[i] == ' ' || out[i] == '\t')) i++; + std::size_t start = i; + auto ok = [](char c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || + (c >= 'a' && c <= 'f') || c == ':'; + }; + while (i < out.size() && ok(out[i])) i++; + if (i == start) return std::nullopt; + std::string s(out.substr(start, i - start)); + std::string h; + for (char c : s) if (c != ':') h.push_back(static_cast(std::tolower(c))); + return h; +} + +// global IPv6 on the ims PDN, from `ip -6 addr show dev scope global`. +std::optional DetectLocal(const std::string& dev) { + auto r = RunCapture({"ip", "-6", "addr", "show", "dev", dev, "scope", "global"}); + std::size_t at = r.out.find("inet6 "); + if (at == std::string_view::npos) return std::nullopt; + std::size_t start = at + 6; + std::size_t end = r.out.find('/', start); + if (end == std::string_view::npos) return std::nullopt; + return r.out.substr(start, end - start); +} + +// ---- sockaddr helper ------------------------------------------------------ +socklen_t MakeAddr(std::string_view ip, int port, sockaddr_storage& ss) { + std::memset(&ss, 0, sizeof ss); + std::string s(ip); + if (Is6(ip)) { + auto* a = reinterpret_cast(&ss); + a->sin6_family = AF_INET6; a->sin6_port = htons(static_cast(port)); + inet_pton(AF_INET6, s.c_str(), &a->sin6_addr); + return sizeof(sockaddr_in6); + } + auto* a = reinterpret_cast(&ss); + a->sin_family = AF_INET; a->sin_port = htons(static_cast(port)); + inet_pton(AF_INET, s.c_str(), &a->sin_addr); + return sizeof(sockaddr_in); +} + +// ---- SIP over the protected TCP flow -------------------------------------- +class SipTcp { +public: + bool Connect(const std::string& local, int lport, const std::string& pcscf, int pport, int tries = 10) { + int fam = Is6(local) ? AF_INET6 : AF_INET; + for (int attempt = 0; attempt < tries; attempt++) { + int fd = socket(fam, SOCK_STREAM, 0); + if (fd < 0) return false; + int one = 1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + // REUSEPORT (paired with the listener's): a reconnect must bind + // the protected client port while the ServerPorts listener holds + // it in LISTEN state — REUSEADDR alone only covers TIME_WAIT. + setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); + sockaddr_storage la; socklen_t ll = MakeAddr(local, lport, la); + if (bind(fd, reinterpret_cast(&la), ll) != 0) { close(fd); return false; } + sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf, pport, pa); + // non-blocking connect + poll: Linux connect() ignores SO_SNDTIMEO, + // so bound it ourselves (a silent P-CSCF must not hang the engine). + fcntl(fd, F_SETFL, O_NONBLOCK); + int rc = connect(fd, reinterpret_cast(&pa), pl); + bool connected = (rc == 0); + if (rc < 0 && errno == EINPROGRESS) { + pollfd p{fd, POLLOUT, 0}; + if (poll(&p, 1, 12000) > 0) { + int err = 0; socklen_t el = sizeof err; + getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &el); + connected = (err == 0); + } + } + if (connected) { + fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) & ~O_NONBLOCK); + fd_ = fd; alive_ = true; fb_ = {}; + return true; + } + close(fd); + if (attempt == tries - 1) return false; + Log(std::format("connect failed; retry in 10s (TIME_WAIT?) [{}/{}]", attempt + 1, tries)); + sleep(10); + } + return false; + } + // MSG_NOSIGNAL: a flow the network killed (CSFB excursion, P-CSCF idle + // drop) must surface as a send error, not a SIGPIPE. + bool Send(std::string_view msg) { + std::size_t off = 0; + while (off < msg.size()) { + ssize_t w = send(fd_, msg.data() + off, msg.size() - off, MSG_NOSIGNAL); + if (w <= 0) { alive_ = false; return false; } + off += static_cast(w); + } + return true; + } + void SendKeepalive() { + if (send(fd_, "\r\n\r\n", 4, MSG_NOSIGNAL) <= 0) alive_ = false; + } + + std::optional RecvMsg(double timeoutSec) { + auto deadline = std::chrono::steady_clock::now() + + std::chrono::duration_cast(std::chrono::duration(timeoutSec)); + for (;;) { + if (auto m = fb_.TryExtract()) return m; + auto now = std::chrono::steady_clock::now(); + if (now >= deadline) return std::nullopt; + int ms = static_cast(std::chrono::duration_cast(deadline - now).count()); + pollfd p{fd_, POLLIN, 0}; + int r = poll(&p, 1, ms); + if (r <= 0) { if (r == 0) return std::nullopt; if (errno == EINTR) continue; return std::nullopt; } + char buf[65535]; + ssize_t n = recv(fd_, buf, sizeof buf, 0); + // EOF/RST is not a timeout: mark the flow dead so the engine's + // reconnect path notices within one loop turn instead of at the + // next (possibly 30-min-away) keepalive refresh. + if (n <= 0) { alive_ = false; return std::nullopt; } + fb_.Append(std::string_view(buf, static_cast(n))); + } + } + int Fd() const { return fd_; } + bool Alive() const { return fd_ >= 0 && alive_; } + void Close() { if (fd_ >= 0) { close(fd_); fd_ = -1; } alive_ = false; } +private: + int fd_ = -1; + bool alive_ = false; + imsd::sip::FrameBuffer fb_; +}; + +// ---- protected-port listeners (terminating requests: remote BYE/UPDATE) --- +struct Inbound { + std::string msg; + std::function reply; +}; + +class ServerPorts { +public: + void Open(const std::string& local) { + int fam = Is6(local) ? AF_INET6 : AF_INET; + for (int port : {PortUc, PortUs}) { + int ls = socket(fam, SOCK_STREAM, 0); + if (ls >= 0) { + int one = 1; + setsockopt(ls, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + // paired with SipTcp::Connect's REUSEPORT (reconnect binds + // the client port while this listener holds it) + setsockopt(ls, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one); + sockaddr_storage a; socklen_t l = MakeAddr(local, port, a); + if (bind(ls, reinterpret_cast(&a), l) == 0 && listen(ls, 4) == 0) { + fcntl(ls, F_SETFL, O_NONBLOCK); + listeners_.push_back(ls); + } else { close(ls); } + } + int us = socket(fam, SOCK_DGRAM, 0); + if (us >= 0) { + int one = 1; + setsockopt(us, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + sockaddr_storage a; socklen_t l = MakeAddr(local, port, a); + if (bind(us, reinterpret_cast(&a), l) == 0) { + fcntl(us, F_SETFL, O_NONBLOCK); + udp_.push_back(us); + if (port == PortUc) udpUc_ = us; + } else { close(us); } + } + Log(std::format("listening on protected port {} (TCP+UDP)", port)); + } + } + + std::vector Poll() { + std::vector out; + for (int ls : listeners_) { + for (;;) { + int c = accept(ls, nullptr, nullptr); + if (c < 0) break; + fcntl(c, F_SETFL, O_NONBLOCK); + conns_.push_back({c, {}}); + } + } + for (int us : udp_) { + for (;;) { + char buf[65535]; + sockaddr_storage src; socklen_t sl = sizeof src; + ssize_t n = recvfrom(us, buf, sizeof buf, 0, reinterpret_cast(&src), &sl); + if (n <= 0) break; + std::string_view sv(buf, static_cast(n)); + bool blank = sv.find_first_not_of("\r\n \t") == std::string_view::npos; + if (blank) continue; + std::string msg(sv); + if (!imsd::sip::Status(msg).has_value()) { // request only + sockaddr_storage s = src; socklen_t sll = sl; + int rs = us; + // RFC 3261 18.2.2: rport-less UDP requests get their + // response at the Via sent-by port, not the source + // port. KPN's P-CSCF sends in-dialog requests from its + // protected client port but silently drops responses + // sent back there (s57: every UPDATE/BYE 200 ignored, + // audit kills the call); the Via port is its protected + // server port, whose SA pairs with OUR client-port + // socket — respond from that one so the uc->ps SA + // carries it. + if (auto vp = imsd::sip::ViaResponsePort(msg)) { + if (s.ss_family == AF_INET6) + reinterpret_cast(&s)->sin6_port = + htons(static_cast(*vp)); + else + reinterpret_cast(&s)->sin_port = + htons(static_cast(*vp)); + if (udpUc_ >= 0) rs = udpUc_; + } + out.push_back({std::move(msg), [rs, s, sll](const std::string& r) mutable { + sendto(rs, r.data(), r.size(), 0, reinterpret_cast(&s), sll); + }}); + } + } + } + for (auto it = conns_.begin(); it != conns_.end();) { + bool eof = false; + for (;;) { + char buf[65535]; + ssize_t n = recv(it->fd, buf, sizeof buf, 0); + if (n == 0) { eof = true; break; } + if (n < 0) break; + it->fb.Append(std::string_view(buf, static_cast(n))); + } + while (auto m = it->fb.TryExtract()) { + if (!imsd::sip::Status(*m).has_value()) { + int fd = it->fd; + out.push_back({*m, [fd](const std::string& r) { + (void)!write(fd, r.data(), r.size()); + }}); + } + } + if (eof) { close(it->fd); it = conns_.erase(it); } else { ++it; } + } + return out; + } + + void CloseAll() { + for (int f : listeners_) close(f); + for (int f : udp_) close(f); + for (auto& c : conns_) close(c.fd); + listeners_.clear(); udp_.clear(); conns_.clear(); + } +private: + struct Conn { int fd; imsd::sip::FrameBuffer fb; }; + int udpUc_ = -1; + std::vector listeners_, udp_; + std::vector conns_; +}; + +// ---- registration state --------------------------------------------------- +struct RegState { + std::string callid, ftag; + long cseq = 2; + std::uint32_t spiUc = 0; + std::uint32_t spiUs = 0; + long expiry = 0; + int slot = 0; + std::string aid; + // Contact user-part of the stored binding (UUID since the s53 oracle + // diff; empty = legacy IMSI binding from an older state file). + std::string contactUser; +}; + +// Writable directory for the daemon's own files: the persisted registration +// context, media stats, and optional debug dumps. Root daemon (the packaged +// service): /var/lib/imsd; --session/dev runs: the XDG state dir. +std::string StateDir() { + if (geteuid() == 0) return "/var/lib/imsd"; + if (const char* x = std::getenv("XDG_STATE_HOME")) return std::format("{}/imsd", std::string(x)); + return std::format("{}/.local/state/imsd", EnvOr("HOME", "/tmp")); +} + +// tiny JSON reader/writer for the persisted registration context. +std::string StatePath() { return EnvOr("STATE_FILE", std::format("{}/imsreg.state", StateDir())); } + +void PersistState(const RegState& r, const std::string& route, const std::string& ppi) { + std::string j = std::format( + "{{\"callid\": \"{}\", \"ftag\": \"{}\", \"cseq\": {}, \"spi_uc\": {}, " + "\"spi_us\": {}, \"expiry\": {}, \"route\": \"{}\", \"ppi\": \"{}\", " + "\"contact_user\": \"{}\"}}", + r.callid, r.ftag, r.cseq, r.spiUc, r.spiUs, r.expiry, route, ppi, + r.contactUser); + std::string path = StatePath(); + std::error_code ec; + std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec); + if (std::FILE* f = std::fopen(path.c_str(), "w")) { + std::fwrite(j.data(), 1, j.size(), f); + std::fclose(f); + } +} + +// Raw dump of a received message for offline diffing against the stock-modem +// oracle (rung-5b registration-parity work). One file per message kind, +// overwritten on every registration cycle. The dumps carry the subscriber's +// IMSI/MSISDN and addresses, so they are opt-in (DUMP_SIP=1) and mode 0600. +void DumpRaw(std::string_view name, std::string_view msg) { + if (EnvOr("DUMP_SIP", "0") != "1") return; + std::string path = std::format("{}/{}", EnvOr("DUMP_DIR", StateDir()), name); + int fd = open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return; + (void)!write(fd, msg.data(), msg.size()); + close(fd); +} + +struct PersistedState { + std::optional callid, ftag, route, ppi, contactUser; + std::optional cseq, spiUc, spiUs, expiry; +}; +std::optional LoadState() { + std::FILE* f = std::fopen(StatePath().c_str(), "r"); + if (!f) return std::nullopt; + std::string s; + char buf[1024]; std::size_t n; + while ((n = std::fread(buf, 1, sizeof buf, f)) > 0) s.append(buf, n); + std::fclose(f); + PersistedState ps; + ps.callid = QuotedAfter(s, "\"callid\": \""); + ps.ftag = QuotedAfter(s, "\"ftag\": \""); + ps.route = QuotedAfter(s, "\"route\": \""); + ps.ppi = QuotedAfter(s, "\"ppi\": \""); + ps.contactUser = QuotedAfter(s, "\"contact_user\": \""); + ps.cseq = IntAfter(s, "\"cseq\": "); + ps.spiUc = IntAfter(s, "\"spi_uc\": "); + ps.spiUs = IntAfter(s, "\"spi_us\": "); + ps.expiry = IntAfter(s, "\"expiry\": "); + return ps; +} + +// ---- daemon-level shared state (main thread only) ------------------------- +struct CallInfo { + std::string uni, number, state, reason; + std::int64_t startedAt = 0; + std::int64_t answeredAt = 0; + std::string direction = "outgoing"; +}; +struct StatusSnapshot { + bool registered = false; + std::string local, pcscf, ppi; + std::int64_t expiry = 0; + std::int64_t cseq = 0; +}; + +// engine -> main-loop event +struct Event { + enum class Type { Added, State, Deleted, Registration, Status, Fatal } type; + CallInfo info; // Added + std::string uni, state, reason; // State/Deleted + StatusSnapshot status; // Registration/Status + std::string text; // Fatal +}; + +GDBusConnection* DbusConn = nullptr; +std::map Calls; // main thread only +StatusSnapshot StatusSnap; // main thread only +GMainLoop* MainLoop = nullptr; + +std::int64_t NowEpoch() { + return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +// build an a{sv} from a CallInfo (matches imsd.py _d typing) +GVariant* CallVariant(const CallInfo& c) { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&b, "{sv}", "uni", g_variant_new_string(c.uni.c_str())); + g_variant_builder_add(&b, "{sv}", "number", g_variant_new_string(c.number.c_str())); + g_variant_builder_add(&b, "{sv}", "state", g_variant_new_string(c.state.c_str())); + g_variant_builder_add(&b, "{sv}", "reason", g_variant_new_string(c.reason.c_str())); + g_variant_builder_add(&b, "{sv}", "startedAt", g_variant_new_int64(c.startedAt)); + g_variant_builder_add(&b, "{sv}", "answeredAt", g_variant_new_int64(c.answeredAt)); + g_variant_builder_add(&b, "{sv}", "direction", g_variant_new_string(c.direction.c_str())); + return g_variant_builder_end(&b); +} + +void EmitSignal(const char* name, GVariant* params) { + if (!DbusConn) return; + g_dbus_connection_emit_signal(DbusConn, nullptr, ObjPath, Iface, name, params, nullptr); +} + +gboolean OnIdleEvent(gpointer data) { + std::unique_ptr ev(static_cast(data)); + switch (ev->type) { + case Event::Type::Added: { + Calls[ev->info.uni] = ev->info; + EmitSignal("CallAdded", g_variant_new("(s@a{sv})", ev->info.uni.c_str(), CallVariant(ev->info))); + break; + } + case Event::Type::State: { + auto it = Calls.find(ev->uni); + if (it != Calls.end()) { + it->second.state = ev->state; + it->second.reason = ev->reason; + if (ev->state == "active" && it->second.answeredAt == 0) it->second.answeredAt = NowEpoch(); + } + EmitSignal("CallStateChanged", g_variant_new("(sss)", ev->uni.c_str(), ev->state.c_str(), ev->reason.c_str())); + break; + } + case Event::Type::Deleted: + Calls.erase(ev->uni); + EmitSignal("CallDeleted", g_variant_new("(s)", ev->uni.c_str())); + break; + case Event::Type::Registration: + StatusSnap = ev->status; + EmitSignal("RegistrationChanged", g_variant_new("(b)", ev->status.registered ? TRUE : FALSE)); + break; + case Event::Type::Status: + StatusSnap = ev->status; + break; + case Event::Type::Fatal: + Log(std::format("engine fatal: {} — exiting for systemd restart", ev->text)); + g_main_loop_quit(MainLoop); + break; + } + return G_SOURCE_REMOVE; +} + +void PostEvent(std::unique_ptr ev) { + g_idle_add(OnIdleEvent, ev.release()); +} + +// ================= engine ================================================== +class Engine { +public: + Engine() { + // No baked-in default: the P-CSCF is carrier infrastructure, learned + // from the IMS PDN's PCO on a stock stack. Until PCO discovery is + // implemented, PCSCF= must be configured (see README). + pcscf_ = EnvOr("PCSCF", ""); + pcscfPort_ = std::atoi(EnvOr("PCSCF_PORT", "5060").c_str()); + dev_ = EnvOr("DEV", "qmapmux0.0"); + outDir_ = EnvOr("OUT_DIR", StateDir()); + rtpPort_ = std::atoi(EnvOr("RTP_PORT", "50004").c_str()); + precond_ = EnvOr("PRECOND", "0") == "1"; + ealgOffer_ = EnvOr("EALG", "aes-cbc"); + mediaBin_ = ResolveMediaBin(); + } + + void SetLocal(std::string l) { local_ = std::move(l); } + void SetDevMode(bool d) { devMode_ = d; } + const std::string& Local() const { return local_; } + + // Dial() runs on the D-Bus thread: allocate the uni, queue the work. + std::string Dial(const std::string& number) { + std::scoped_lock g(cmdLock_); + std::string uni = std::format("ims-call-{}", ++callSeq_); + cmds_.push_back({Cmd::Kind::Dial, uni, number}); + return uni; + } + void HangUp(const std::string& uni) { + std::scoped_lock g(cmdLock_); + cmds_.push_back({Cmd::Kind::HangUp, uni, {}}); + } + void Accept(const std::string& uni) { + std::scoped_lock g(cmdLock_); + cmds_.push_back({Cmd::Kind::Accept, uni, {}}); + } + void Stop() { + { std::scoped_lock g(cmdLock_); cmds_.push_back({Cmd::Kind::Stop, {}, {}}); } + quit_.store(true); + } + + void Run() { + try { + BringUp(); + } catch (const std::exception& e) { + if (devMode_) { + // dev/session mode (no modem): keep the ABI up, unregistered, + // so the D-Bus surface can be exercised without a phone. + Log(std::format("bring-up skipped (dev mode): {}", e.what())); + registered_ = false; + EmitStatus(true); + DevLoop(); + return; + } + Log(std::format("bring-up FAILED: {}", e.what())); + auto ev = std::make_unique(); + ev->type = Event::Type::Fatal; ev->text = e.what(); + PostEvent(std::move(ev)); + return; + } + registered_ = true; + EmitStatus(true); + lastRefresh_ = Mono(); + lastKa_ = Mono(); + try { server_.Open(local_); } + catch (...) { Log("server ports unavailable; remote hangup/incoming not seen"); } + SubscribeRegEvent(); + + while (!quit_.load()) { + if (auto cmd = PopCmd()) { HandleCmd(*cmd); continue; } + for (Inbound& in : server_.Poll()) { + reply_ = in.reply; + DispatchRequest(in.msg); + } + MaybeRefresh(); + MaybeReconnect(); + if (!call_ && sip_.Alive() && Mono() - lastKa_ > 30) { sip_.SendKeepalive(); lastKa_ = Mono(); } + ReapMedia(); + if (call_ && call_->State() != imsd::engine::CallState::Active && Mono() > deadline_) { + Execute(call_->OnDeadline()); + MaybeDropCall(); + } + auto msg = sip_.RecvMsg(0.3); + if (!msg) continue; + auto st = imsd::sip::Status(*msg); + if (!st) { reply_ = [this](const std::string& r) { sip_.Send(r); }; DispatchRequest(*msg); continue; } + std::string cseq(imsd::sip::Header(*msg, "CSeq").value_or("")); + std::string callid(imsd::sip::Header(*msg, "Call-ID").value_or("")); + if (call_ && callid == call_->CallId() && cseq.ends_with("INVITE")) { + Execute(call_->OnInviteResponse(*msg)); + MaybeDropCall(); + } else if (!subCallid_.empty() && callid == subCallid_ && cseq.ends_with("SUBSCRIBE")) { + Log(std::format("reg-event SUBSCRIBE -> {}", *st)); + DumpRaw("imsd-subscribe-200.raw", *msg); + } + } + // shutdown: release an active call, keep the SA/registration for resume + if (call_) { Execute(call_->OnHangup()); MaybeDropCall(); } + sip_.Close(); + Log("engine stopped (SA left up for resume)"); + } + +private: + struct Cmd { enum class Kind { Dial, HangUp, Accept, Stop } kind; std::string uni, number; }; + + // dev/session mode without a modem: process commands so the ABI answers, + // but there is no SIP socket — a Dial collapses to terminated/error. + void DevLoop() { + while (!quit_.load()) { + if (auto cmd = PopCmd()) { HandleCmd(*cmd); MaybeDropCall(); continue; } + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + } + + // config + std::string pcscf_, dev_, outDir_, local_, ealgOffer_, mediaBin_; + int pcscfPort_ = 5060; + int rtpPort_ = 50004; + bool precond_ = false; + + // registration + imsd::msg::Context ctx_; + RegState reg_; + std::string route_, ppi_, ss_; + bool registered_ = false; + SipTcp sip_; + ServerPorts server_; + std::string subCallid_; // live reg-event subscription dialog (empty: none) + int portPs_ = 0; // P-CSCF protected server port (reconnect target) + double nextReconnect_ = 0; + double reconnectDelay_ = 5; + + // call + imsd::util::Rng rng_; + std::optional call_; + bool dropCall_ = false; + pid_t mediaPid_ = -1; + double deadline_ = 0; + // reply_ answers the inbound request being dispatched right now; + // callReply_ is the call's durable response channel — refreshed from + // reply_ on every in-dialog request, so an Accept()'s 200 OK (which + // happens outside any inbound dispatch) still reaches the caller. + std::function reply_; + std::function callReply_; + + // timers + double lastRefresh_ = 0; + double lastKa_ = 0; + std::atomic quit_{false}; + bool devMode_ = false; + + // command queue + std::mutex cmdLock_; + std::deque cmds_; + // atomic: Dial (D-Bus thread) and an inbound INVITE (engine thread) both mint unis + std::atomic callSeq_{0}; + + static double Mono() { + return std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); + } + + std::optional PopCmd() { + std::scoped_lock g(cmdLock_); + if (cmds_.empty()) return std::nullopt; + Cmd c = cmds_.front(); cmds_.pop_front(); + return c; + } + + static std::string ResolveMediaBin() { + if (const char* e = std::getenv("IMSD_MEDIA")) return e; + if (!SelfDir.empty()) { + std::string p = std::format("{}/imsd-media", SelfDir); + if (access(p.c_str(), X_OK) == 0) return p; + } + return "/usr/libexec/imsd-media"; + } + + // Refresh P-Access-Network-Info from the live serving cell (mmcli 3GPP + // location). Serving cell changes as the phone moves, so this runs + // before every REGISTER/INVITE-carrying step; on failure the previous + // value (possibly empty = header omitted) stays. + void RefreshPani() { + auto loc = RunCapture({"mmcli", "-m", "any", "--location-get"}); + if (auto p = imsd::aka::ParsePani(loc.out)) ctx_.panInfo = *p; + } + + // ---- bring-up: warm resume if an SA + state file exist, else fresh ---- + void BringUp() { + if (pcscf_.empty()) throw std::runtime_error("PCSCF not set — configure the carrier's P-CSCF address " "(P-CSCF discovery from the PDN's PCO is not implemented yet)"); + auto card = RunCapture({"qmicli", "-d", "qrtr://0", "--uim-get-card-status"}); + auto sel = imsd::aka::ParseCardStatus(card.out); + if (!sel) throw std::runtime_error("no ready USIM found"); + auto modem = RunCapture({"mmcli", "-m", "any"}); + auto simPath = imsd::aka::ParsePrimarySimPath(modem.out); + if (!simPath) throw std::runtime_error("no primary SIM path"); + auto siminfo = RunCapture({"mmcli", "-i", *simPath}); + auto info = imsd::aka::ParseSimInfo(siminfo.out); + if (!info) throw std::runtime_error("SIM IMSI/operator not found"); + ctx_.id = imsd::aka::MakeIdentity(info->imsi, info->mcc, info->mnc); + ctx_.local = local_; + ctx_.ealgOffer = ealgOffer_; + // USER_AGENT overrides (some networks fingerprint UAs — setting the + // stock firmware's build string gives oracle parity, journal/ims.md + // s53); USER_AGENT= (empty) omits the header. + ctx_.userAgent = EnvOr("USER_AGENT", std::format("imsd/{}", Version)); + if (auto imei = imsd::aka::ParseEquipmentId(modem.out)) + if (auto urn = imsd::aka::ImeiUrn(*imei)) ctx_.instanceId = *urn; + reg_.slot = sel->slot; + reg_.aid = sel->aid; + Log(std::format("SIM slot={} IMSI={} domain={}", sel->slot, info->imsi, ctx_.id.domain)); + RefreshPani(); + + std::string resume = EnvOr("RESUME", "auto"); + if (resume != "0" && TryResume()) return; + FreshRegister(); + } + + bool TryResume() { + auto state = RunCapture({"ip", "xfrm", "state"}); + auto policy = RunCapture({"ip", "xfrm", "policy"}); + auto sa = imsd::ipsec::ParseExistingSa(state.out, policy.out, pcscf_, local_); + if (!sa) return false; + auto ps = LoadState(); + if (!ps || !ps->callid) { Log("no reg state file; resume falls back to fresh"); return false; } + + route_ = ps->route.value_or(std::format("", imsd::util::HostPort(pcscf_, sa->portPs))); + // No fallback identity: an empty ppi omits P-Preferred-Identity and + // is re-learned from the next 200's P-Associated-URI. + ppi_ = ps->ppi.value_or(""); + ss_ = sa->securityServer; + ctx_.route = route_; ctx_.ppi = ppi_; ctx_.securityServer = ss_; + ctx_.portUc = PortUc; ctx_.portUs = PortUs; + Log(std::format("RESUME via existing SA (cseq {})", ps->cseq.value_or(1))); + + portPs_ = sa->portPs; + if (!sip_.Connect(local_, PortUc, pcscf_, sa->portPs)) { + Log("resume connect failed; falling back to fresh"); + return false; + } + reg_.callid = *ps->callid; + reg_.ftag = ps->ftag.value_or(""); + // The refresh must carry the SAME Contact URI as the stored binding; + // an absent key means the binding predates the oracle diff and used + // the IMSI (the empty-contactUser fallback). + reg_.contactUser = ps->contactUser.value_or(""); + ctx_.contactUser = reg_.contactUser; + reg_.cseq = ps->cseq.value_or(1); + reg_.spiUc = static_cast(ps->spiUc.value_or(sa->spiUc)); + reg_.spiUs = static_cast(ps->spiUs.value_or(sa->spiUs)); + reg_.expiry = ps->expiry.value_or(0); + auto [okMsg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + reg_.cseq = used; + if (auto e = imsd::sip::GrantedExpires(okMsg)) reg_.expiry = *e; + UpdateRouteAndPpi(okMsg); + LogBindings(okMsg); + PersistState(reg_, route_, ppi_); + Log("REGISTERED (resumed + true-refreshed)"); + return true; + } + + // The registrar's 200 OK echoes EVERY current binding for the implicit + // set — the network's own view. Log them: a stale binding at a dead + // SLAAC address is still tried by terminating routing until it expires. + void LogBindings(const std::string& ok) { + for (auto ct : imsd::sip::Headers(ok, "Contact")) + Log(std::format("binding: {}", ct)); + } + + void FreshRegister() { + Log("FRESH registration"); + // Oracle diff (journal/ims.md s53): stock's Contact user-part is a + // fresh UUID per registration, not the IMSI. Minted before reg1 so + // both REGISTERs and the stored binding carry the same URI. + reg_.contactUser = imsd::util::Uuid4(rng_); + ctx_.contactUser = reg_.contactUser; + Log(std::format("contact user-part: {}", reg_.contactUser)); + std::string callidReg = std::format("{}@{}", rng_.Token(16), local_); + std::string ftag = rng_.Token(8); + std::uint32_t spiUc = rng_.UInt(0x10000, 0xFFFFFFF); + std::uint32_t spiUs = rng_.UInt(0x10000, 0xFFFFFFF); + ctx_.portUc = PortUc; ctx_.portUs = PortUs; ctx_.initPort = InitPort; + + // reg1 over UDP from the init port -> 401 + int fam = Is6(local_) ? AF_INET6 : AF_INET; + int us = socket(fam, SOCK_DGRAM, 0); + int one = 1; setsockopt(us, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one); + sockaddr_storage la; socklen_t ll = MakeAddr(local_, InitPort, la); + if (bind(us, reinterpret_cast(&la), ll) != 0) { + close(us); throw std::runtime_error("bind init port failed"); + } + timeval tv{8, 0}; setsockopt(us, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + std::string reg1 = imsd::msg::BuildRegisterInitial(ctx_, callidReg, ftag, rng_.Token(16), spiUc, spiUs); + sockaddr_storage pa; socklen_t pl = MakeAddr(pcscf_, pcscfPort_, pa); + sendto(us, reg1.data(), reg1.size(), 0, reinterpret_cast(&pa), pl); + char buf[65535]; + ssize_t n = recv(us, buf, sizeof buf, 0); + close(us); + if (n <= 0) throw std::runtime_error("no 401 to unprotected REGISTER"); + std::string resp(buf, static_cast(n)); + if (imsd::sip::Status(resp) != 401) throw std::runtime_error("expected 401"); + auto nonceB64 = QuotedAfter(resp, "nonce=\""); + if (!nonceB64) throw std::runtime_error("no nonce in 401"); + auto raw = imsd::util::FromBase64(*nonceB64); + if (!raw || raw->size() < 32) throw std::runtime_error("bad nonce"); + std::vector rand16(raw->begin(), raw->begin() + 16); + std::vector autn16(raw->begin() + 16, raw->begin() + 32); + auto ssv = imsd::sip::Header(resp, "Security-Server"); + if (!ssv) throw std::runtime_error("no Security-Server"); + ss_ = std::string(*ssv); + int portPs = static_cast(IntAfter(ss_, "port-s=").value_or(0)); + int portPc = static_cast(IntAfter(ss_, "port-c=").value_or(0)); + std::uint32_t spiPs = static_cast(IntAfter(ss_, "spi-s=").value_or(0)); + std::uint32_t spiPc = static_cast(IntAfter(ss_, "spi-c=").value_or(0)); + std::string ealg = EalgAfter(ss_); + if (spiPs == 0) throw std::runtime_error("fresh-SA throttle active (spi-s=0); retry later"); + + auto aka = Authenticate(rand16, autn16); + if (!aka) throw std::runtime_error("USIM AKA failed"); + + // install the IPsec SA pair + imsd::ipsec::SaParams sp; + sp.local = local_; sp.pcscf = pcscf_; + sp.ik = aka->ik; sp.ck = aka->ck; + sp.spiUc = spiUc; sp.spiUs = spiUs; sp.spiPc = spiPc; sp.spiPs = spiPs; + sp.portUc = PortUc; sp.portUs = PortUs; sp.portPs = portPs; sp.portPc = portPc; + sp.ealg = ealg; + for (auto& cmd : imsd::ipsec::BuildSetupCommands(sp)) + RunCmd(cmd); + + ctx_.securityServer = ss_; + portPs_ = portPs; + if (!sip_.Connect(local_, PortUc, pcscf_, portPs)) throw std::runtime_error("protected TCP connect failed"); + + std::string cnonce = rng_.Token(16); + std::string response = imsd::aka::DigestAkav1(*nonceB64, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain); + std::string auth = imsd::msg::AuthAka(ctx_, *nonceB64, cnonce, response); + std::string reg2 = imsd::msg::BuildRegisterProtected(ctx_, callidReg, ftag, rng_.Token(16), 2, spiUc, spiUs, auth); + if (!sip_.Send(reg2)) throw std::runtime_error("send protected REGISTER failed"); + std::string ok; + for (;;) { + auto m = sip_.RecvMsg(12.0); + if (!m) throw std::runtime_error("no reply to protected REGISTER"); + auto st = imsd::sip::Status(*m); + if (st == 200) { ok = *m; break; } + if (st && *st >= 300) throw std::runtime_error(std::format("REGISTER failed {}", *st)); + } + reg_.callid = callidReg; reg_.ftag = ftag; reg_.cseq = 2; + reg_.spiUc = spiUc; reg_.spiUs = spiUs; + reg_.expiry = imsd::sip::GrantedExpires(ok).value_or(0); + DumpRaw("imsd-register-200.raw", ok); + UpdateRouteAndPpi(ok); + LogBindings(ok); + if (route_.empty()) route_ = std::format("", imsd::util::HostPort(pcscf_, portPs)); + ctx_.route = route_; ctx_.ppi = ppi_; + PersistState(reg_, route_, ppi_); + Log("REGISTERED (fresh)"); + } + + // re-REGISTER over the (resumed or live) SA. Returns (200 msg, used cseq). + std::pair Reregister(const std::string& callid, const std::string& ftag, long startCseq, std::uint32_t spiUc, std::uint32_t spiUs) { + std::string auth = imsd::msg::AuthEmpty(ctx_); + long cseq = startCseq; + bool challenged = false; + for (;;) { + std::string reg = imsd::msg::BuildRegisterProtected(ctx_, callid, ftag, rng_.Token(16), cseq, spiUc, spiUs, auth); + if (!sip_.Send(reg)) throw std::runtime_error("send re-REGISTER failed"); + auto m = sip_.RecvMsg(12.0); + if (!m) throw std::runtime_error("no reply to re-REGISTER"); + auto st = imsd::sip::Status(*m); + if (st == 200) return {*m, cseq}; + if (st == 401 && !challenged) { + challenged = true; + auto nonce = QuotedAfter(*m, "nonce=\""); + auto raw = nonce ? imsd::util::FromBase64(*nonce) : std::nullopt; + if (!raw || raw->size() < 32) throw std::runtime_error("bad 401 nonce on refresh"); + std::vector rnd(raw->begin(), raw->begin() + 16); + std::vector autn(raw->begin() + 16, raw->begin() + 32); + auto aka = Authenticate(rnd, autn); + if (!aka) throw std::runtime_error("AKA failed on refresh"); + std::string cnonce = rng_.Token(16); + std::string response = imsd::aka::DigestAkav1(*nonce, aka->res, cnonce, ctx_.id.regUri, ctx_.id.impi, ctx_.id.domain); + auth = imsd::msg::AuthAka(ctx_, *nonce, cnonce, response); + cseq++; + continue; + } + throw std::runtime_error(std::format("re-REGISTER failed {}", st.value_or(0))); + } + } + + void UpdateRouteAndPpi(const std::string& okMsg) { + auto svc = imsd::sip::Headers(okMsg, "Service-Route"); + if (svc.empty()) Log("200: no Service-Route (route stays P-CSCF)"); + if (!svc.empty()) { + std::string r; + for (std::size_t i = 0; i < svc.size(); i++) { r += svc[i]; if (i + 1 < svc.size()) r += ", "; } + route_ = r; ctx_.route = r; + Log(std::format("200 Service-Route: {}", r)); + } + for (auto p : imsd::sip::Headers(okMsg, "Path")) + Log(std::format("200 Path: {}", p)); + auto pau = imsd::sip::Headers(okMsg, "P-Associated-URI"); + if (pau.empty()) Log("200: NO P-Associated-URI"); + for (auto a : pau) + Log(std::format("200 P-Associated-URI: {}", a)); + for (auto a : imsd::sip::Headers(okMsg, "P-Associated-URI")) { + std::size_t lt = a.find("', lt); + ppi_ = std::string(a.substr(lt + 1, gt - lt - 1)); + ctx_.ppi = ppi_; + break; + } + } + // default public identity for self-targeted requests (reg-event + // SUBSCRIBE): first sip: P-Associated-URI entry, else the tel: one + // (the temporary IMPU is barred for anything but REGISTER) + for (auto a : imsd::sip::Headers(okMsg, "P-Associated-URI")) { + std::size_t lt = a.find("', lt); + ctx_.aor = std::string(a.substr(lt + 1, gt - lt - 1)); + break; + } + } + if (ctx_.aor.empty() && !ppi_.empty()) ctx_.aor = ppi_; + if (ppi_.empty()) Log("200: no tel: P-Associated-URI — P-Preferred-Identity omitted"); + Log(std::format("self AOR: {}", ctx_.aor.empty() ? ctx_.id.impu : ctx_.aor)); + } + + // USIM AKA via qmicli UIM logical channel. + std::optional Authenticate(std::span rand16, std::span autn16) { + auto open = RunCapture({"qmicli", "-d", "qrtr://0", + std::format("--uim-open-logical-channel={},{}", reg_.slot, reg_.aid)}); + auto ch = CompletedInt(open.out); + if (!ch) return std::nullopt; + int channel = static_cast(*ch); + auto closeCh = [&] { + RunCapture({"qmicli", "-d", "qrtr://0", + std::format("--uim-close-logical-channel={},{}", reg_.slot, channel)}); + }; + std::string apdu = imsd::aka::BuildAkaApdu(channel, rand16, autn16); + auto r1 = RunCapture({"qmicli", "-d", "qrtr://0", + std::format("--uim-send-apdu={},{},{}", reg_.slot, channel, apdu)}); + auto b1 = CompletedHex(r1.out); + if (!b1) { closeCh(); return std::nullopt; } + if (b1->size() >= 2 && b1->substr(0, 2) == "61") { + std::string gr = imsd::aka::BuildGetResponseApdu(channel, std::stoi(b1->substr(2, 2), nullptr, 16)); + auto r2 = RunCapture({"qmicli", "-d", "qrtr://0", + std::format("--uim-send-apdu={},{},{}", reg_.slot, channel, gr)}); + b1 = CompletedHex(r2.out); + if (!b1) { closeCh(); return std::nullopt; } + } + closeCh(); + auto bytes = imsd::util::FromHex(*b1); + if (!bytes) return std::nullopt; + return imsd::aka::ParseAkaResponse(*bytes); + } + + // ---- keepalive refresh (only while idle) ------------------------------ + void MaybeRefresh() { + if (call_ || reg_.callid.empty()) return; + if (!sip_.Alive()) return; // MaybeReconnect owns dead-flow recovery + long exp = reg_.expiry ? reg_.expiry : 3600; + double interval; + if (const char* o = std::getenv("REFRESH_INTERVAL")) interval = std::atof(o); + else interval = std::max(120.0, std::min(exp * 0.5, 1800.0)); + if (Mono() - lastRefresh_ < interval) return; + try { + RefreshPani(); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + reg_.cseq = used; + if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; + PersistState(reg_, route_, ppi_); + Log(std::format("keepalive re-REGISTER ok (cseq {})", used)); + SubscribeRegEvent(); + if (!registered_) { registered_ = true; EmitStatus(true); } + else EmitStatus(false); + } catch (const std::exception& e) { + // Whatever failed (dead flow, silent P-CSCF, error status), the + // recovery is the same as a daemon restart: drop the flow and + // let MaybeReconnect re-run the connect + true-refresh sequence. + Log(std::format("keepalive re-REGISTER failed: {}", e.what())); + sip_.Close(); + } + lastRefresh_ = Mono(); + lastKa_ = Mono(); + } + + // The protected client flow can die under us while the SA and the + // registration stay valid — a CSFB excursion resets it, the P-CSCF drops + // idle flows — which is exactly the state a daemon restart resumes from + // (s51: "restart-to-recover"). Do what the restart does, in place: + // reconnect the TCP leg over the existing SA and true-refresh. Backoff + // doubles 5 s → 5 min so a dead PDN doesn't turn this into a hot loop. + void MaybeReconnect() { + if (call_ || sip_.Alive() || portPs_ == 0 || reg_.callid.empty()) return; + if (Mono() < nextReconnect_) return; + Log("client flow dead; reconnecting"); + sip_.Close(); + bool ok = sip_.Connect(local_, PortUc, pcscf_, portPs_, /*tries=*/2); + if (ok) { + try { + RefreshPani(); + auto [msg, used] = Reregister(reg_.callid, reg_.ftag, reg_.cseq + 1, reg_.spiUc, reg_.spiUs); + reg_.cseq = used; + if (auto e = imsd::sip::GrantedExpires(msg)) reg_.expiry = *e; + UpdateRouteAndPpi(msg); + LogBindings(msg); + PersistState(reg_, route_, ppi_); + Log("reconnected + re-registered"); + SubscribeRegEvent(); + if (!registered_) { registered_ = true; EmitStatus(true); } + else EmitStatus(false); + lastRefresh_ = Mono(); lastKa_ = Mono(); + reconnectDelay_ = 5; + nextReconnect_ = 0; + return; + } catch (const std::exception& e) { + Log(std::format("reconnect re-REGISTER failed: {}", e.what())); + sip_.Close(); + } + } + if (registered_) { registered_ = false; EmitStatus(true); } + nextReconnect_ = Mono() + reconnectDelay_; + reconnectDelay_ = std::min(reconnectDelay_ * 2, 300.0); + } + + // (Re-)subscribe to the reg event package (TS 24.229 5.1.1.3): a fresh + // dialog each time, sent over the registration flow; the S-CSCF's + // immediate NOTIFY (logged in DispatchRequest) is the network's own view + // of our bindings. Failure is non-fatal — registration is unaffected. + void SubscribeRegEvent() { + subCallid_ = std::format("{}@{}", rng_.Token(16), local_); + std::string sub = imsd::msg::BuildSubscribeReg(ctx_, subCallid_, rng_.Token(8), rng_.Token(16)); + if (sip_.Send(sub)) Log("reg-event SUBSCRIBE sent"); + else { Log("reg-event SUBSCRIBE send failed"); subCallid_.clear(); } + } + + // ---- command handling ------------------------------------------------- + void HandleCmd(const Cmd& c) { + if (c.kind == Cmd::Kind::Stop) { quit_.store(true); return; } + if (c.kind == Cmd::Kind::Dial) { + if (call_) { + Log(std::format("dial({}) refused: call in progress", c.number)); + CallInfo bad{c.uni, c.number, "terminated", "error", NowEpoch(), 0}; + PostAdded(bad); + PostState(c.uni, "terminated", "error"); + PostDeleted(c.uni); + return; + } + // a dead client flow fails the INVITE instantly — give the + // reconnect path one immediate shot first (user action beats + // the backoff timer) + if (!sip_.Alive()) { nextReconnect_ = 0; MaybeReconnect(); } + RefreshPani(); + call_.emplace(ctx_, rng_, c.uni, c.number, rtpPort_, precond_); + CallInfo info{c.uni, c.number, "dialing", "outgoing", NowEpoch(), 0}; + PostAdded(info); + bool ok = Execute(call_->Start()); + if (!ok && call_ && !call_->Terminated()) { Execute(call_->Fail("error")); } + MaybeDropCall(); + } else if (c.kind == Cmd::Kind::HangUp) { + if (call_ && call_->Uni() == c.uni) { Execute(call_->OnHangup()); MaybeDropCall(); } + } else if (c.kind == Cmd::Kind::Accept) { + if (call_ && call_->Uni() == c.uni) { Execute(call_->OnAccept()); MaybeDropCall(); } + else Log(std::format("Accept({}) ignored: no such ringing call", c.uni)); + } + } + + void DispatchRequest(const std::string& msg) { + std::string callid(imsd::sip::Header(msg, "Call-ID").value_or("")); + std::size_t sp = msg.find(' '); + std::string method = msg.substr(0, sp == std::string::npos ? 0 : sp); + if (call_ && callid == call_->CallId()) { + callReply_ = reply_; // freshest channel for this dialog + Execute(call_->OnRequest(msg)); + MaybeDropCall(); + return; + } + // ACK is never answered — e.g. the caller ACKing the 486/487 of a + // call machine we already dropped + if (method == "ACK") return; + // reg-event NOTIFY: log the S-CSCF's reginfo (every binding it holds + // for the implicit set, feature tags as stored), then 200 it below. + // MESSAGE (terminating SMS-over-IP while +g.3gpp.smsip is + // advertised): log the whole body — the 200 acks delivery, so the + // journal is the only place the payload survives until real SMSoIP + // handling exists. + if (method == "NOTIFY" || method == "MESSAGE") { + std::size_t at = msg.find("\r\n\r\n"); + std::string_view body = at == std::string::npos + ? std::string_view{} + : std::string_view(msg).substr(at + 4); + if (method == "MESSAGE") Log(std::format("MESSAGE from {} ({} bytes):\n{}", imsd::sip::CallerId(msg), body.size(), body)); + else if (auto ev = imsd::sip::Header(msg, "Event"); ev && ev->starts_with("reg")) Log(std::format("reg-event NOTIFY ({} bytes):\n{}", body.size(), body)); + if (reply_) reply_(imsd::msg::BuildResponse200(ctx_, msg)); + return; + } + if (method == "INVITE") { + OnIncomingInvite(msg); + return; + } + if (reply_) + reply_(imsd::msg::BuildResponse200(ctx_, msg)); // unsolicited (OPTIONS etc.) + } + + // An out-of-dialog INVITE: a terminating (incoming) call. + void OnIncomingInvite(const std::string& msg) { + if (call_) { + Log("incoming INVITE while a call exists: 486"); + if (reply_) reply_(imsd::msg::BuildInviteResponse(ctx_, msg, 486, "Busy Here", rng_.Token(10))); + return; + } + std::string uni = std::format("ims-call-{}", ++callSeq_); + DumpRaw("imsd-invite-in.raw", msg); + call_.emplace(ctx_, rng_, uni, imsd::engine::IncomingInvite{msg}, rtpPort_); + callReply_ = reply_; + Log(std::format("incoming call {} from {}", uni, call_->Number())); + CallInfo info{uni, call_->Number(), "incoming", "incoming", NowEpoch(), 0, + "incoming"}; + PostAdded(info); + Execute(call_->OnInvite()); + MaybeDropCall(); + } + + // ---- action execution ------------------------------------------------- + // Returns false if a client-flow send failed (used to fail a dial's INVITE). + bool Execute(const std::vector& actions) { + using T = imsd::engine::Action::Type; + bool sendOk = true; + for (const auto& a : actions) { + switch (a.type) { + case T::SendClient: + if (!sip_.Send(a.text)) { sendOk = false; Log("client send failed"); } + break; + case T::SendResponse: + if (callReply_) callReply_(a.text); + else if (reply_) reply_(a.text); + break; + case T::StartMedia: StartMedia(a.media); break; + case T::StopMedia: StopMedia(); break; + case T::State: PostState(call_->Uni(), a.state, a.reason); break; + case T::Deleted: PostDeleted(call_->Uni()); dropCall_ = true; break; + case T::SetDeadline: deadline_ = Mono() + a.seconds; break; + case T::Log: Log(a.text); break; + } + } + return sendOk; + } + + void MaybeDropCall() { + if (dropCall_) { call_.reset(); callReply_ = nullptr; dropCall_ = false; } + } + + void StartMedia(const imsd::engine::MediaLeg& leg) { + if (!leg.Valid()) { Log("SDP answer missing media endpoint; no media leg"); return; } + Log(std::format("media {} pt={} octet={} -> [{}]:{}", leg.codec, leg.payloadType, leg.octetAlign, leg.remoteIp, leg.remotePort)); + if (Is6(leg.remoteIp)) RunCmd({"ip", "-6", "route", "replace", leg.remoteIp, "dev", dev_}); + else + RunCmd({"ip", "route", "replace", leg.remoteIp, "dev", dev_}); + std::string outBase = std::format("{}/dialer_{}", outDir_, call_ ? call_->Uni() : std::string("x")); + std::vector argv = { + mediaBin_, local_, std::to_string(rtpPort_), leg.remoteIp, + std::to_string(leg.remotePort), std::to_string(leg.payloadType), + "86400", outBase}; + pid_t pid = fork(); + if (pid == 0) { + setenv("MIC", EnvOr("MIC", "1").c_str(), 1); + setenv("PLAY", EnvOr("PLAY", "1").c_str(), 1); + setenv("GAIN", EnvOr("GAIN", "10").c_str(), 1); + setenv("PLAY_GAIN", EnvOr("PLAY_GAIN", "1.0").c_str(), 1); + setenv("AMR_MODE", EnvOr("AMR_MODE", "2").c_str(), 1); + setenv("DTX", EnvOr("DTX", "0").c_str(), 1); + setenv("OCTET_ALIGN", leg.octetAlign ? "1" : "0", 1); + std::vector c; + for (auto& s : argv) c.push_back(const_cast(s.c_str())); + c.push_back(nullptr); + execvp(c[0], c.data()); + _exit(127); + } + mediaPid_ = pid; + } + + void StopMedia() { + if (mediaPid_ <= 0) return; + kill(mediaPid_, SIGTERM); + for (int i = 0; i < 100; i++) { + if (waitpid(mediaPid_, nullptr, WNOHANG) != 0) { mediaPid_ = -1; return; } + usleep(100000); + } + kill(mediaPid_, SIGKILL); + waitpid(mediaPid_, nullptr, 0); + mediaPid_ = -1; + } + + void ReapMedia() { + if (!call_ || mediaPid_ <= 0) return; + if (call_->State() != imsd::engine::CallState::Active) return; + int status = 0; + pid_t r = waitpid(mediaPid_, &status, WNOHANG); + if (r == 0) return; // still running + int code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + mediaPid_ = -1; // reaped; don't re-terminate + Execute(call_->OnMediaExit(code)); + MaybeDropCall(); + } + + // ---- events ----------------------------------------------------------- + void EmitStatus(bool registrationSignal) { + auto ev = std::make_unique(); + ev->type = registrationSignal ? Event::Type::Registration : Event::Type::Status; + ev->status = StatusSnapshot{registered_, local_, pcscf_, ppi_, reg_.expiry, reg_.cseq}; + PostEvent(std::move(ev)); + } + void PostAdded(const CallInfo& info) { + auto ev = std::make_unique(); ev->type = Event::Type::Added; ev->info = info; + PostEvent(std::move(ev)); + } + void PostState(const std::string& uni, const std::string& state, const std::string& reason) { + auto ev = std::make_unique(); ev->type = Event::Type::State; + ev->uni = uni; ev->state = state; ev->reason = reason; + PostEvent(std::move(ev)); + } + void PostDeleted(const std::string& uni) { + auto ev = std::make_unique(); ev->type = Event::Type::Deleted; ev->uni = uni; + PostEvent(std::move(ev)); + } +}; + +Engine* TheEngine = nullptr; + +// ================= GDBus =================================================== +constexpr const char* IntrospectionXml = R"xml( + + + + + + + + + + + + + + + + + + + + + +)xml"; + +void HandleMethodCall(GDBusConnection*, const gchar*, const gchar*, const gchar*, const gchar* method, GVariant* params, GDBusMethodInvocation* inv, gpointer) { + std::string_view m = method; + if (m == "Dial") { + const gchar* number = nullptr; + g_variant_get(params, "(&s)", &number); + std::string uni = TheEngine->Dial(number ? number : ""); + g_dbus_method_invocation_return_value(inv, g_variant_new("(s)", uni.c_str())); + return; + } + if (m == "HangUp") { + const gchar* uni = nullptr; + g_variant_get(params, "(&s)", &uni); + TheEngine->HangUp(uni ? uni : ""); + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + if (m == "Accept") { + const gchar* uni = nullptr; + g_variant_get(params, "(&s)", &uni); + TheEngine->Accept(uni ? uni : ""); + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + if (m == "SendDtmf") { + Log("SendDtmf accepted but not yet sent (TODO RFC4733)"); + g_dbus_method_invocation_return_value(inv, nullptr); + return; + } + if (m == "GetCalls") { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("aa{sv}")); + for (auto& [uni, info] : Calls) + g_variant_builder_add_value(&b, CallVariant(info)); + g_dbus_method_invocation_return_value(inv, g_variant_new("(aa{sv})", &b)); + return; + } + if (m == "GetStatus") { + GVariantBuilder b; + g_variant_builder_init(&b, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&b, "{sv}", "registered", g_variant_new_boolean(StatusSnap.registered)); + g_variant_builder_add(&b, "{sv}", "local", g_variant_new_string(StatusSnap.local.c_str())); + g_variant_builder_add(&b, "{sv}", "pcscf", g_variant_new_string(StatusSnap.pcscf.c_str())); + g_variant_builder_add(&b, "{sv}", "expiry", g_variant_new_int64(StatusSnap.expiry)); + g_variant_builder_add(&b, "{sv}", "cseq", g_variant_new_int64(StatusSnap.cseq)); + g_variant_builder_add(&b, "{sv}", "ppi", g_variant_new_string(StatusSnap.ppi.c_str())); + g_dbus_method_invocation_return_value(inv, g_variant_new("(a{sv})", &b)); + return; + } + g_dbus_method_invocation_return_dbus_error(inv, "org.freedesktop.DBus.Error.UnknownMethod", "no such method"); +} + +const GDBusInterfaceVTable Vtable = { HandleMethodCall, nullptr, nullptr, {} }; + +void OnBusAcquired(GDBusConnection* conn, const gchar*, gpointer) { + DbusConn = conn; + GDBusNodeInfo* node = g_dbus_node_info_new_for_xml(IntrospectionXml, nullptr); + g_dbus_connection_register_object(conn, ObjPath, node->interfaces[0], &Vtable, nullptr, nullptr, nullptr); + g_dbus_node_info_unref(node); + Log(std::format("object registered at {}", ObjPath)); +} + +gboolean OnTerm(gpointer loop) { + Log("SIGTERM — stopping engine (SA kept)"); + if (TheEngine) TheEngine->Stop(); + g_timeout_add_seconds(2, [](gpointer l) -> gboolean { + g_main_loop_quit(static_cast(l)); return G_SOURCE_REMOVE; }, loop); + return G_SOURCE_REMOVE; +} + +} // namespace + +int main(int argc, char** argv) { + bool session = false; + for (int i = 1; i < argc; i++) + if (std::string_view(argv[i]) == "--session") session = true; + + if (argv[0]) { + std::string p = argv[0]; + auto slash = p.find_last_of('/'); + if (slash != std::string::npos) SelfDir = p.substr(0, slash); + } + + if (!session && geteuid() != 0) { + std::println(std::cerr, "imsd: must run as root (xfrm / qmicli)"); + return 1; + } + + std::string dev = EnvOr("DEV", "qmapmux0.0"); + std::string local = EnvOr("LOCAL", ""); + if (local.empty()) { + if (auto l = DetectLocal(dev)) local = *l; + } + if (local.empty() && !session) { + std::println(std::cerr, "imsd: no global IPv6 on {} — is the ims PDN up?", dev); + return 1; + } + Log(std::format("LOCAL={} P-CSCF=[{}]:{}", local, EnvOr("PCSCF", "(unset)"), EnvOr("PCSCF_PORT", "5060"))); + + Engine engine; + engine.SetLocal(local); + engine.SetDevMode(session); + TheEngine = &engine; + + MainLoop = g_main_loop_new(nullptr, FALSE); + guint owner = g_bus_own_name( + session ? G_BUS_TYPE_SESSION : G_BUS_TYPE_SYSTEM, BusName, + G_BUS_NAME_OWNER_FLAGS_NONE, OnBusAcquired, + [](GDBusConnection*, const gchar* name, gpointer) { Log(std::format("owning {}", name)); }, + [](GDBusConnection*, const gchar* name, gpointer lp) { + Log(std::format("lost {} — exiting", name)); + g_main_loop_quit(static_cast(lp)); + }, + MainLoop, nullptr); + + g_unix_signal_add(SIGTERM, OnTerm, MainLoop); + g_unix_signal_add(SIGINT, OnTerm, MainLoop); + + // Run the engine on a pthread with an explicit 8 MiB stack: musl's default + // thread stack is only 128 KiB, and the registration paths hold 64 KiB SIP + // recv buffers on the stack (a std::thread here segfaults on entry). The + // registration is bus-independent, so start it immediately. + pthread_t engineTid{}; + pthread_attr_t attr; + pthread_attr_init(&attr); + pthread_attr_setstacksize(&attr, 8 * 1024 * 1024); + pthread_create(&engineTid, &attr, [](void*) -> void* { TheEngine->Run(); return nullptr; }, nullptr); + pthread_attr_destroy(&attr); + + Log(std::format("starting on the {} bus", session ? "session" : "system")); + g_main_loop_run(MainLoop); + + engine.Stop(); + pthread_join(engineTid, nullptr); + g_bus_unown_name(owner); + g_main_loop_unref(MainLoop); + return 0; +} diff --git a/implementations/media.cpp b/implementations/media.cpp new file mode 100644 index 0000000..14bf2e3 --- /dev/null +++ b/implementations/media.cpp @@ -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 + 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 +#include +#include +#include +#include +#include +#include +#include +#include + +import std; + +namespace { + +using Clock = std::chrono::steady_clock; +double Now() { + return std::chrono::duration(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(&ss); + a->sin6_family = AF_INET6; + a->sin6_port = htons(static_cast(port)); + inet_pton(AF_INET6, s.c_str(), &a->sin6_addr); + return sizeof(sockaddr_in6); + } + auto* a = reinterpret_cast(&ss); + a->sin_family = AF_INET; + a->sin_port = htons(static_cast(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(dlsym(lib_, "E_IF_init")); + enc_ = reinterpret_cast(dlsym(lib_, "E_IF_encode")); + exit_ = reinterpret_cast(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 Encode(std::int16_t* samples, int mode, int dtx) { + std::uint8_t out[128]; + int n = enc_(st_, static_cast(mode), samples, out, static_cast(dtx)); + if (n <= 0) return {}; + return std::vector(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(dlsym(lib_, "D_IF_init")); + dec_ = reinterpret_cast(dlsym(lib_, "D_IF_decode")); + exit_ = reinterpret_cast(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 Decode(const std::uint8_t* frame, int len) { + std::array out{}; + std::vector 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 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>> +DepayBe(std::span pl) { + std::vector>> out; + BitReader br{pl}; + if (!br.Ok(4)) return out; + br.Take(4); // CMR + struct Toc { int ft; int q; }; + std::vector tocs; + for (;;) { + if (!br.Ok(6)) return out; + int f = static_cast(br.Take(1)); + int ft = static_cast(br.Take(4)); + int q = static_cast(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(bits))) break; + std::vector 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((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 PayloadFromFrame(std::span frame, bool octetAlign) { + if (octetAlign) { + std::vector 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 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(ft)); + put(9, 1, static_cast(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>> +DepayOctet(std::span pl) { + std::vector>> out; + if (pl.empty()) return out; + std::size_t i = 1; // skip CMR + std::vector 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 speech; + if (n > 0 && i + n <= pl.size()) speech.assign(pl.begin() + i, pl.begin() + i + n); + out.emplace_back(static_cast(toc & 0x7C), std::move(speech)); + i += n; + } + return out; +} + +std::size_t RtpPayloadOffset(std::span 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 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 cargv; + for (auto& s : argv) cargv.push_back(const_cast(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(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 micOn{false}; +}; + +void RtpSend(int sock, TxState& st, std::span 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(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 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(&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>> +Depay(std::span pl, bool octetAlign) { + return octetAlign ? DepayOctet(pl) : DepayBe(pl); +} + +std::vector SilenceFrame(int ft, bool octetAlign) { + // storage frame: ToC(F=0,FT,Q=1) + zeroed speech, payloaded per mode. + std::vector f = {static_cast((ft << 3) | 0x04)}; + f.resize(1 + AmrwbBytes(ft), 0); + return PayloadFromFrame(f, octetAlign); +} + +std::atomic Quit{false}; +void OnSig(int) { Quit.store(true); } + +// playout queue item +struct PktItem { std::uint32_t ts; std::vector 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 frame = { + static_cast((ft << 3) | 0x04)}; + int bits = AmrwbBits(ft); + for (int i = 0; i < AmrwbBytes(ft); i++) + frame.push_back(static_cast(0xA5 + i * 31)); + if (bits % 8) frame.back() &= static_cast(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(&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 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(samples[i] * gain); + samples[i] = static_cast(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); + } + std::vector 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 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 pcm) { + std::size_t bytes = pcm.size() * 2; + const char* p = reinterpret_cast(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(w); + } + return true; + }; + auto applyGain = [&](std::array& pcm) { + if (playGain == 1.0) return; + for (auto& s : pcm) { + int v = static_cast(s * playGain); + s = static_cast(v < -32768 ? -32768 : (v > 32767 ? 32767 : v)); + } + }; + std::array zero{}; + std::optional 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(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 f = {hdr}; + f.insert(f.end(), speech.begin(), speech.end()); + auto pcm = dec.Decode(f.data(), static_cast(f.size())); + applyGain(pcm); + if (!writePcm(pcm)) return; + } + expect = (item.ts + 320 * static_cast(frames.size())) & 0xFFFFFFFF; + } + }); + } + + // ---- main recv loop + std::vector 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(&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(&src); + inet_ntop(AF_INET6, &a->sin6_addr, host, sizeof host); + port = ntohs(a->sin6_port); + } else { + auto* a = reinterpret_cast(&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(n); + if (dump) { + std::uint32_t len = static_cast(n); + std::uint8_t lb[4] = {static_cast((len >> 24) & 0xFF), + static_cast((len >> 16) & 0xFF), + static_cast((len >> 8) & 0xFF), + static_cast(len & 0xFF)}; + std::fwrite(lb, 1, 4, dump); + std::fwrite(buf, 1, static_cast(n), dump); + } + if (playThread.joinable() && n >= 12 && (buf[1] & 0x7F) == pt) { + std::size_t off = RtpPayloadOffset(std::span(buf, static_cast(n))); + std::uint32_t pktTs = (static_cast(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(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; +} diff --git a/interfaces/Imsd-Aka.cppm b/interfaces/Imsd-Aka.cppm new file mode 100644 index 0000000..736c820 --- /dev/null +++ b/interfaces/Imsd-Aka.cppm @@ -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; // @ + std::string impu; // sip:@ + std::string regUri; // sip: + }; + + // 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 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 ParseCardStatus(std::string_view text) { + int curSlot = 0; + bool inUsim = false; + bool ready = false; + std::optional 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(std::toupper(static_cast(c)))); + out = SimSelection{curSlot, aid}; + } + inUsim = false; + } + if (nl == std::string_view::npos) break; + pos = nl + 1; + } + return out; + } + + // "primary sim path: " from `mmcli -m any`. + std::optional 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 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: " (15 digits) from `mmcli -m any`. + std::optional 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 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 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=" + // (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 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::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(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= 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 rand16, std::span 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 + 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 res; + std::vector ck; + std::vector 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 ParseAkaResponse(std::span resp) { + if (resp.size() < 3) return std::nullopt; + std::span 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& 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; + } +} diff --git a/interfaces/Imsd-Engine.cppm b/interfaces/Imsd-Engine.cppm new file mode 100644 index 0000000..ea31cf4 --- /dev/null +++ b/interfaces/Imsd-Engine.cppm @@ -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 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 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 OnInvite() { + std::vector 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 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 OnAccept() { + std::vector 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 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 OnInviteResponse(std::string_view msg) { + std::vector 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 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 OnRequest(std::string_view msg) { + std::vector 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 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 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(*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 OnHangup() { + std::vector 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 OnMediaExit(int code) { + std::vector 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 OnDeadline() { + std::vector 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 Fail(std::string_view reason) { + std::vector 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 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 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 Finish(std::string_view reason, std::string_view detail = "") { + state_ = CallState::Terminated; + reason_ = std::string(reason); + std::vector 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& into, std::vector more) { + for (auto& m : more) + into.push_back(std::move(m)); + } + }; +} diff --git a/interfaces/Imsd-Ipsec.cppm b/interfaces/Imsd-Ipsec.cppm new file mode 100644 index 0000000..6aa7d40 --- /dev/null +++ b/interfaces/Imsd-Ipsec.cppm @@ -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 ik; // integrity key (auth) + std::vector 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 ... `, 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 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 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 StateAdd(std::string_view src, std::string_view dst, std::uint32_t spi, int reqid, std::string_view ikx, const std::vector& enc) { + std::vector 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 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> 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 enc = detail::EncArgs(p); + int plen = imsd::util::Is6(local) ? 128 : 32; + std::vector> 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> 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 SrcBlocks(std::string_view text) { + std::vector blocks; + std::size_t i = 0; + std::optional 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 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 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 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; + } +} diff --git a/interfaces/Imsd-Messages.cppm b/interfaces/Imsd-Messages.cppm new file mode 100644 index 0000000..38cfbc4 --- /dev/null +++ b/interfaces/Imsd-Messages.cppm @@ -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/ [local]:;branch=z9hG4bK;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 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 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: {};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 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: {};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 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: ", 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 dialogTo; // remote To/From incl. tag, once known + std::vector recordRoute; // route set, UAC order (see DialogRoute) + std::optional 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 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: ;+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 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 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 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 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 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 sdp = std::nullopt, std::span extra = {}) { + std::vector 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: ", 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 sdp = std::nullopt, std::span extra = {}) { + std::vector 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: ", 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)); + } +} diff --git a/interfaces/Imsd-Sdp.cppm b/interfaces/Imsd-Sdp.cppm new file mode 100644 index 0000000..ed99cd2 --- /dev/null +++ b/interfaces/Imsd-Sdp.cppm @@ -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 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 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 + }; + + // 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 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/` in a body's rtpmap lines + // (DTMF, RFC 4733); nullopt when the offer doesn't carry one. + std::optional 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 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; + } +} diff --git a/interfaces/Imsd-Sip.cppm b/interfaces/Imsd-Sip.cppm new file mode 100644 index 0000000..cd2e152 --- /dev/null +++ b/interfaces/Imsd-Sip.cppm @@ -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(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 + 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 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 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 Header(std::string_view msg, std::string_view name) { + std::optional 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 Headers(std::string_view msg, std::string_view name) { + std::vector 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 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 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(*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 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 "", "" (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_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 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(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_; + }; +} diff --git a/interfaces/Imsd-Util.cppm b/interfaces/Imsd-Util.cppm new file mode 100644 index 0000000..e6f1e13 --- /dev/null +++ b/interfaces/Imsd-Util.cppm @@ -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(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 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 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 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> 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 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((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> 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 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((acc >> bits) & 0xFF)); + } + } + return out; + } + + // ---- MD5 (RFC 1321), streaming over arbitrary bytes. ------------------ + class Md5 { + public: + Md5() { Reset(); } + + void Update(std::span data) { + std::size_t i = 0; + std::size_t bufFill = static_cast((count_ >> 3) & 0x3F); + count_ += static_cast(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(reinterpret_cast(s.data()), s.size())); + } + + std::array Digest() { + std::array lenBytes{}; + for (int i = 0; i < 8; i++) + lenBytes[i] = static_cast((count_ >> (8 * i)) & 0xFF); + static constexpr std::uint8_t pad[64] = {0x80}; + std::size_t bufFill = static_cast((count_ >> 3) & 0x3F); + std::size_t padLen = (bufFill < 56) ? (56 - bufFill) : (120 - bufFill); + Update(std::span(pad, padLen)); + Update(std::span(lenBytes.data(), 8)); + std::array out{}; + for (int i = 0; i < 4; i++) + for (int j = 0; j < 4; j++) + out[i * 4 + j] = static_cast((state_[i] >> (8 * j)) & 0xFF); + return out; + } + + // Convenience: hex digest of a single buffer. + static std::string HexDigest(std::span 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(reinterpret_cast(s.data()), s.size())); + } + + private: + std::array state_; + std::uint64_t count_; // bit count + std::array 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(block[i * 4]) | + (static_cast(block[i * 4 + 1]) << 8) | + (static_cast(block[i * 4 + 2]) << 16) | + (static_cast(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; + } + }; +} diff --git a/interfaces/Imsd.cppm b/interfaces/Imsd.cppm new file mode 100644 index 0000000..a21c778 --- /dev/null +++ b/interfaces/Imsd.cppm @@ -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; diff --git a/lint-rules.h b/lint-rules.h new file mode 100644 index 0000000..12461fd --- /dev/null +++ b/lint-rules.h @@ -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 Lines(std::string_view text) { + std::vector 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 lines = Lines(ctx.CommentStripped()); + + enum class Scope { Namespace, Type, Function, Other }; + std::vector 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 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(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(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 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 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 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 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 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 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 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 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 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 stripped = Lines(ctx.CommentStripped()); + std::vector 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 stripped = Lines(ctx.CommentStripped()); + static const std::regex ifHeader(R"(^\s*(?:\}?\s*else\s+)?if\s*\(.*\)\s*$)"); + std::vector 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 stripped = Lines(ctx.CommentStripped()); + std::vector 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> 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 diff --git a/packaging/APKBUILD b/packaging/APKBUILD new file mode 100644 index 0000000..a65902f --- /dev/null +++ b/packaging/APKBUILD @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# Maintainer: Jorijn van der Graaf +# 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 +} diff --git a/packaging/APKBUILD.binary b/packaging/APKBUILD.binary new file mode 100644 index 0000000..c440f53 --- /dev/null +++ b/packaging/APKBUILD.binary @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: GPL-3.0-only +# SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® +# Maintainer: Jorijn van der Graaf +# 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 +} diff --git a/packaging/ims-pdn-up.sh b/packaging/ims-pdn-up.sh new file mode 100644 index 0000000..e09066e --- /dev/null +++ b/packaging/ims-pdn-up.sh @@ -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 diff --git a/packaging/imsd-dialerd.desktop b/packaging/imsd-dialerd.desktop new file mode 100644 index 0000000..9904d01 --- /dev/null +++ b/packaging/imsd-dialerd.desktop @@ -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 diff --git a/packaging/imsd.service b/packaging/imsd.service new file mode 100644 index 0000000..3ce9f51 --- /dev/null +++ b/packaging/imsd.service @@ -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 diff --git a/packaging/make-bin-tarball.sh b/packaging/make-bin-tarball.sh new file mode 100755 index 0000000..03f7186 --- /dev/null +++ b/packaging/make-bin-tarball.sh @@ -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))" diff --git a/packaging/make-sysroot.sh b/packaging/make-sysroot.sh new file mode 100755 index 0000000..fdca8d8 --- /dev/null +++ b/packaging/make-sysroot.sh @@ -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= +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 +# /usr/lib/gcc// +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" diff --git a/packaging/net.catcrafts.IMS1.conf b/packaging/net.catcrafts.IMS1.conf new file mode 100644 index 0000000..c365a8a --- /dev/null +++ b/packaging/net.catcrafts.IMS1.conf @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/project.cpp b/project.cpp new file mode 100644 index 0000000..d38bcac --- /dev/null +++ b/project.cpp @@ -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 PkgConfig(std::string_view args) { + fs::path tmp = fs::temp_directory_path() / + std::format("imsd-pkgconfig-{}.txt", std::hash{}(args)); + std::string cmd = std::format("pkg-config {} > {} 2>/dev/null", args, tmp.string()); + std::vector 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=): 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 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 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 ifaces = {}; + std::array 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 ifaces = {}; + std::array 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(); + Core->path = "./"; + Core->name = "imsd-core"; + Core->outputName = "imsd-core"; + ApplyStandardArgs(*Core, args); + Core->type = ConfigurationType::LibraryStatic; + { + std::array 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 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 ifaces = {}; + std::array 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; +} diff --git a/tests/Aka/main.cpp b/tests/Aka/main.cpp new file mode 100644 index 0000000..73c3616 --- /dev/null +++ b/tests/Aka/main.cpp @@ -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 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 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 10 + 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 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 dc = {0xDC, 0x0E, 0x90, 0x00}; + Check(!ParseAkaResponse(dc).has_value(), "non-DB tag rejected"); + std::vector 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; +} diff --git a/tests/Engine/main.cpp b/tests/Engine/main.cpp new file mode 100644 index 0000000..72234ee --- /dev/null +++ b/tests/Engine/main.cpp @@ -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 = ""; + 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& a, Action::Type t) { + for (const auto& x : a) if (x.type == t) return true; + return false; + } + const Action* Find(const std::vector& 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& 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: ;tag=it\r\n" + "To: ;tag=remote7\r\n" + "Call-ID: {}\r\n" + "CSeq: 1 INVITE\r\n" + "Record-Route: \r\n" + "Require: 100rel\r\n" + "RSeq: 1\r\n" + "Contact: \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: ;tag=it\r\n" + "To: ;tag=remote7\r\n" + "Call-ID: {}\r\n" + "CSeq: 1 INVITE\r\n" + "Contact: \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: ;tag=it\r\n" + "To: ;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& 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: \r\n" + "Record-Route: \r\n" + "Max-Forwards: 68\r\n" + "From: ;tag=caller1\r\n" + "To: \r\n" + "Call-ID: mt-call-abc123\r\n" + "CSeq: 1 INVITE\r\n" + "Contact: \r\n" + "P-Asserted-Identity: \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: \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: ;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: ;tag=remote7\r\nTo: ;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>{ + {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: \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: "), "180 returns the Record-Route set"); + Check(ringing && ringing->text.contains("To: ;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: 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: ;tag=caller1\r\n" + "To: ;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: ;tag=caller1\r\n" + "To: ;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: ;tag=caller1\r\n" + "To: ;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: ;tag=caller1\r\n" + "To: ;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: ;tag=caller1\r\n" + "To: ;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: ;tag=caller1\r\n" + "To: \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: ;tag=caller1\r\n" + "To: \r\n" + "Call-ID: mt-pcma\r\nCSeq: 1 INVITE\r\n" + "Contact: \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: " ";tag=caller1\r\n"), "BYE To is the caller (their tag)"); + Check(bye && bye->text.contains("From: ;tag="), "BYE From is us (our tag)"); + // UAS route set: Record-Route in RECEIVED order + Check(bye && bye->text.contains("Route: , " "\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; +} diff --git a/tests/Ipsec/main.cpp b/tests/Ipsec/main.cpp new file mode 100644 index 0000000..92f85d6 --- /dev/null +++ b/tests/Ipsec/main.cpp @@ -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& 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(16, 0xAB); + p.ck = std::vector(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(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; +} diff --git a/tests/Messages/main.cpp b/tests/Messages/main.cpp new file mode 100644 index 0000000..0a06640 --- /dev/null +++ b/tests/Messages/main.cpp @@ -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 = ""; + 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: ;tag=FTAG5678\r\n" + "To: \r\n" + "Call-ID: REGCALLID@2001:db8::db43\r\n" + "CSeq: 1 REGISTER\r\n" + "Contact: ;+sip.instance=\"\";+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: ;tag=FTAG5678\r\n" + "To: \r\n" + "Call-ID: REGCALLID@2001:db8::db43\r\n" + "CSeq: 2 REGISTER\r\n" + "Contact: ;+sip.instance=\"\";+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: \r\n" + "From: ;tag=ITAG123456\r\n" + "To: \r\n" + "Call-ID: CALLID789@2001:db8::db43\r\n" + "CSeq: 1 INVITE\r\n" + "Contact: ;+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: \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: \r\n" + "From: ;tag=STAG1234\r\n" + "To: \r\n" + "Call-ID: SUBCALLID@2001:db8::db43\r\n" + "CSeq: 1 SUBSCRIBE\r\n" + "Contact: \r\n" + "Event: reg\r\n" + "Accept: application/reginfo+xml\r\n" + "Expires: 600000\r\n" + "P-Preferred-Identity: \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: ;tag=STAG1234\r\n"), "reg-event SUBSCRIBE aor From"); + Check(sub.contains("\r\nTo: \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 = ";tag=remote99"; + d.remoteTarget = "sip:termgw@2001:db8::9"; + d.recordRoute = {"", ""}; + + // dialog route = reversed record-route + Check(DialogRoute(c, d) == ", ", "dialog route reversed"); + + std::array 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: , \r\n"), "PRACK dialog route"); + Check(prack.contains("To: ;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", ""); + Check(ack.starts_with("ACK sip:termgw@2001:db8::9 SIP/2.0\r\n"), "ACK 2xx target"); + Check(ack.contains("To: ;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, ";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: ;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: ;tag=T\r\n" + "To: ;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: \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: "), "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: "), "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; +} diff --git a/tests/Sdp/main.cpp b/tests/Sdp/main.cpp new file mode 100644 index 0000000..2770c16 --- /dev/null +++ b/tests/Sdp/main.cpp @@ -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; +} diff --git a/tests/Sip/main.cpp b/tests/Sip/main.cpp new file mode 100644 index 0000000..dd91b0f --- /dev/null +++ b/tests/Sip/main.cpp @@ -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: ;tag=abc\r\n" + "To: ;tag=def\r\n" + "Call-ID: 12345@host\r\n" + "CSeq: 2 REGISTER\r\n" + "contact: ;expires=600000\r\n" + "P-Associated-URI: \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") == ";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: \r\n" "Contact: ;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\" ;tag=f\r\n" + "P-Asserted-Identity: \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: \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: ;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: ;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; +} diff --git a/tests/Util/main.cpp b/tests/Util/main.cpp new file mode 100644 index 0000000..174c691 --- /dev/null +++ b/tests/Util/main.cpp @@ -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 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; +}