From d19696dec3c59da254cdf86c60a08b78096fe1df Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 13 Sep 2026 22:02:40 -0700 Subject: [PATCH] Deploy a fresh Stalwart and ihasmail, linked, in one command deploy stands up Stalwart 0.16, ihasmail and (for a mail host) Caddy as a compose project: completes Stalwart's bootstrap over x:Bootstrap, links ihasmail over the private network, requests certificates for both Caddy (TLS-ALPN-01) and Stalwart (HTTP-01 through Caddy), makes the auto-ban safe behind the proxy, and proves the link by signing in through the webmail. --local gives a loopback-only pair. certs retries Stalwart's certificate; destroy removes a deployment. e2e/public.sh runs the whole mail-host path against Pebble with no internet involved. --- .gitignore | 17 + LICENSE | 674 ++++++++++++++++++++ README.md | 244 +++++++ cmd/ihasmail-oneshot/main.go | 265 ++++++++ e2e/public.sh | 218 +++++++ go.mod | 3 + internal/config/config.go | 310 +++++++++ internal/config/config_test.go | 100 +++ internal/deploy/deploy.go | 600 +++++++++++++++++ internal/docker/docker.go | 140 ++++ internal/render/render.go | 120 ++++ internal/render/render_test.go | 122 ++++ internal/render/templates/Caddyfile.tmpl | 59 ++ internal/render/templates/compose.yaml.tmpl | 94 +++ internal/stalwart/client.go | 230 +++++++ internal/stalwart/client_test.go | 124 ++++ internal/stalwart/setup.go | 289 +++++++++ internal/webmail/webmail.go | 102 +++ 18 files changed, 3711 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/ihasmail-oneshot/main.go create mode 100755 e2e/public.sh create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/deploy/deploy.go create mode 100644 internal/docker/docker.go create mode 100644 internal/render/render.go create mode 100644 internal/render/render_test.go create mode 100644 internal/render/templates/Caddyfile.tmpl create mode 100644 internal/render/templates/compose.yaml.tmpl create mode 100644 internal/stalwart/client.go create mode 100644 internal/stalwart/client_test.go create mode 100644 internal/stalwart/setup.go create mode 100644 internal/webmail/webmail.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d50315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Binaries +/bin/ +/dist/ +/ihasmail-oneshot + +# Test / coverage +*.out +*.test + +# A deployment directory written by a local run: it holds secrets. +/ihasmail-*/ +/e2e/work/ + +# Editor / OS +.DS_Store +.idea/ +.vscode/ 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..c0fc059 --- /dev/null +++ b/README.md @@ -0,0 +1,244 @@ +# ihasmail-oneshot + +One command that stands up a fresh [Stalwart](https://stalw.art) mail server +and a fresh [ihasmail](https://github.com/Coffey-Labs/ihasmail) webmail on a +Docker host, already linked to each other: + +```bash +ihasmail-oneshot deploy --domain example.com --user alice +``` + +It writes a deployment directory, starts Stalwart, completes Stalwart's setup +wizard over its API, brings up ihasmail and Caddy, wires them together, requests +certificates, signs in through the webmail to prove the link, and hands you the +administrator password and the DNS records to publish. Afterwards it is an +ordinary `docker compose` project you manage with the usual commands. + +It only ever deploys **fresh**: it refuses a directory with files in it, and a +compose project that already has containers or volumes. + +## Two shapes + +| | `deploy --domain example.com` | `deploy --local` | +| --- | --- | --- | +| For | A mail host on the internet | Trying ihasmail against a real Stalwart | +| Containers | Stalwart, ihasmail, Caddy | Stalwart, ihasmail | +| Published | 25, 465, 993, 995, 4190, 80, 443 on every interface | Nothing but two loopback ports | +| Certificates | Let's Encrypt, for Caddy and for Stalwart | None | +| Webmail | `https://webmail.example.com` | `http://127.0.0.1:8080` | +| Stalwart admin | `https://mail.example.com/admin` | `http://127.0.0.1:8081/admin` | + +Both also bind ihasmail to `127.0.0.1:8080` and Stalwart's plain-HTTP port to +`127.0.0.1:8081`, for looking at either directly from the host. + +## Requirements + +- Linux with Docker Engine and the compose plugin, as a user allowed to run + `docker`. +- Go 1.26 or newer to build it (there are no release binaries yet). + +For a mail host, also: + +- A domain whose DNS you control. +- Ports 25, 80, 443, 465, 993, 995 and 4190 free on the host and open in any + firewall in front of it. +- **Outbound port 25.** Many hosting providers block it until you ask. +- **Reverse DNS** (a PTR record, set at the hosting provider) for the host's + address, naming the mail host. + +## Install + +```bash +git clone https://github.com/Coffey-Labs/ihasmail-oneshot.git +cd ihasmail-oneshot +go build -o ihasmail-oneshot ./cmd/ihasmail-oneshot +``` + +Copy the binary to the Docker host and run it there. It needs no other files. + +## Deploying a mail host + +Point DNS at the host first, so certificates can be issued on the first run: + +``` +mail.example.com. A +webmail.example.com. A +``` + +(AAAA records too, if the host has IPv6.) Then: + +```bash +ihasmail-oneshot deploy --domain example.com --email you@example.net --user alice --user bob +``` + +It lists what it will do, checks the host, and asks before changing anything; +`--yes` skips the question, and is required when there is no terminal to ask +on. At the end: + +- **`credentials.txt`** holds the Stalwart administrator (`admin@example.com`) + and a generated password for each `--user`. Readable only by you. The + administrator can sign in to the webmail, where it gets the Administration + menu, and to Stalwart's own admin UI. +- **`dns-records.zone`** holds every record Stalwart wants published: MX, SPF, + DKIM, DMARC, the SRV records, MTA-STS, and the autoconfig names. Publish all + of them. + +If DNS did not point at the host yet, the webmail still comes up and Caddy +keeps retrying its certificates by itself. Stalwart does not retry a failed +order, so once DNS is right: + +```bash +ihasmail-oneshot certs --dir ihasmail-example-com +``` + +## Trying it locally + +```bash +ihasmail-oneshot deploy --local --user alice +``` + +Open `http://127.0.0.1:8080` and sign in with a mailbox from +`ihasmail-example-test/credentials.txt`. Nothing outside the host can reach +it, and no mail can be delivered to it. + +## What it sets up, and why + +``` + ┌──────────── private network (172.31.253.0/24) ────────────┐ + 443, 80 ──► Caddy .10 ─┼─► ihasmail .11 ──http://stalwart:8080──► Stalwart .12 │ + │ ▲ │ + └─── Stalwart's web names ─────────────────────┘ │ + 25, 465, 993, 995, 4190 ────────────────────────────────────────────► Stalwart │ + └───────────────────────────────────────────────────────────┘ +``` + +**ihasmail reaches Stalwart over plain HTTP on the private network.** The leg +never leaves the host, and the private route is about a third of the memory per +signed-in tab of going back out through HTTPS. Stalwart's HTTP port is published +on loopback only. + +**ihasmail runs immutable**: read-only root filesystem, no volume, sessions in +memory. A restart signs everyone out and loses nothing else, because everything +durable, including each user's settings, lives in Stalwart. + +**Stalwart is set up through its bootstrap API, not a template.** Stalwart 0.16 +keeps its configuration in its data store, and a server with an empty +configuration starts in bootstrap mode. The tool starts it with a one-off +recovery administrator, completes setup with `x:Bootstrap/set`, and restarts it +*without* that credential, so no fixed administrator password outlives the +setup. Logging goes to stdout rather than the default log directory, which does +not exist in the container. + +**Caddy and Stalwart share ports 80 and 443 without competing.** Both need +certificates for Stalwart's names: Caddy to serve its web side over HTTPS, +Stalwart for IMAP and SMTP. They are separated by challenge type. Caddy uses +TLS-ALPN-01 on 443 for those names and never port 80; Stalwart uses HTTP-01, +and Caddy forwards `/.well-known/acme-challenge/` on port 80 to it untouched. +Stalwart's certificate covers the mail host plus `autoconfig`, `autodiscover`, +`mta-sts` and `ua-auto-config` under the domain, and Caddy fronts all five. + +**Stalwart's auto-ban is made safe for a proxy in front of it.** Stalwart bans +an address that probes scanner paths such as `/wp-admin.php`, and counts other +abuse per address too. Behind a proxy, that address is the proxy's. So: + +- Stalwart takes client addresses from Caddy's `X-Forwarded-For`. Without it, + one scanner bans Caddy, and with it every autoconfig lookup, calendar client + and certificate renewal. With it, the scanner is banned and nobody else is. + This is safe only because nothing untrusted can reach Stalwart's HTTP port. +- Every request the webmail makes arrives from ihasmail's fixed address, which + is added to Stalwart's allowed addresses, so no ban can take the webmail down + for everybody. ihasmail rate-limits sign-ins per real client itself. + +Neither setting applies to a running Stalwart, so the tool restarts it after +making them. + +**Push by subscription.** ihasmail is given `PUSH_URL`, so Stalwart posts +changes to it instead of holding a connection open per browser tab. If Stalwart +cannot reach that URL, every tab falls back to the relay and nothing breaks; +`/api/health` shows which is in use. + +## Afterwards + +The deployment directory is a compose project: + +```bash +cd ihasmail-example-com +docker compose ps +docker compose logs -f stalwart +docker compose restart ihasmail +``` + +To upgrade, change an image tag in `compose.yaml` and run `docker compose up -d`. +Read ihasmail's release notes for the Stalwart version it supports before +moving Stalwart. + +Data lives in the named volumes `stalwart-etc` and `stalwart-data` (mail, +accounts, configuration) and `caddy-data` (certificates and the ACME account). +Back those up. `APP_SECRET` in `.env` seals ihasmail's sessions; changing it +signs everyone out. + +To remove a deployment and **everything in it**, mail included: + +```bash +ihasmail-oneshot destroy --dir ihasmail-example-com +``` + +It removes the containers, network and volumes, and the files the tool wrote. A +file you added to the directory yourself is left, and so is the directory. + +## Flags + +`ihasmail-oneshot deploy -h` lists them all. + +| Flag | Default | | +| --- | --- | --- | +| `--domain` | required; `example.test` with `--local` | The mail domain | +| `--mail-host` | `mail.DOMAIN` | Stalwart's hostname: one label under the domain | +| `--webmail-host` | `webmail.DOMAIN` | The webmail's hostname | +| `--email` | `postmaster@DOMAIN` | ACME contact address. Use one that does not depend on this server | +| `--user` | none | Create a mailbox with a generated password. Repeat for more | +| `--local` | off | The loopback-only shape | +| `--dir` | `./PROJECT` | Deployment directory: new or empty | +| `--project` | `ihasmail-DOMAIN` | Compose project name, dots as dashes | +| `--stalwart-image` | `stalwartlabs/stalwart:v0.16.22` | | +| `--ihasmail-image` | `ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328` | | +| `--caddy-image` | `caddy:2.11.4` | | +| `--webmail-bind` | `127.0.0.1:8080` | Host address for ihasmail's own port | +| `--stalwart-bind` | `127.0.0.1:8081` | Host address for Stalwart's plain-HTTP port | +| `--subnet` | `172.31.253.0/24` | Private network. Change it if it overlaps one you have | +| `--acme-directory` | Let's Encrypt | ACME directory of a private CA | +| `--acme-ca-root` | none | PEM root the private CA's own HTTPS is signed by | +| `--yes` | off | Do not ask for confirmation | + +## Known limits + +- **One domain.** More can be added afterwards in Stalwart or in ihasmail's + Administration; their certificates and DNS are then yours to arrange. +- **IPv4 on the private network.** Ports are published on IPv6 too wherever + Docker does so on the host. +- **Stalwart does not retry a certificate order that fails**, including on a + transient error from the CA. `certs` starts a new one. +- **Push by subscription is not covered by the end-to-end test**, which has no + public DNS for Stalwart to resolve the webmail's name with. Without it the + relay is used, which is the documented fallback. + +## Testing + +```bash +go test ./... +e2e/public.sh +``` + +`e2e/public.sh` deploys a full mail host on the machine it runs on with no +internet involved: [Pebble](https://github.com/letsencrypt/pebble) stands in for +Let's Encrypt and a DNS stub answers every name with the host's own address. It +checks that Stalwart's IMAPS and submissions ports and Caddy's HTTPS all present +verified certificates, that users sign in through the webmail over HTTPS, that +autoconfig is served, that a scan through Caddy bans the scanner while other +clients still get through, and that ihasmail is exempt from bans. It +publishes the mail ports while it runs and removes everything when it ends; +`KEEP=1` leaves it up to look at. + +## License + +GPL-3.0-or-later. See [LICENSE](LICENSE). diff --git a/cmd/ihasmail-oneshot/main.go b/cmd/ihasmail-oneshot/main.go new file mode 100644 index 0000000..075131b --- /dev/null +++ b/cmd/ihasmail-oneshot/main.go @@ -0,0 +1,265 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +// Command ihasmail-oneshot deploys a fresh Stalwart and a fresh ihasmail on +// one Docker host, linked, in one command. +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/Coffey-Labs/ihasmail-oneshot/internal/config" + "github.com/Coffey-Labs/ihasmail-oneshot/internal/deploy" +) + +// version is set at build time: -ldflags "-X main.version=...". +var version = "dev" + +const usageText = `ihasmail-oneshot deploys a fresh Stalwart mail server and a fresh ihasmail +webmail on this Docker host, linked together, in one command. + +Usage: + ihasmail-oneshot deploy --domain example.com [flags] a mail host on the internet + ihasmail-oneshot deploy --local [flags] a loopback-only pair to try it + ihasmail-oneshot certs --dir DIR retry Stalwart's certificate after fixing DNS + ihasmail-oneshot destroy --dir DIR [--yes] remove a deployment and all its data + ihasmail-oneshot version + +Run a command with -h for its flags. +` + +func main() { + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, usageText) + os.Exit(2) + } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + var err error + switch os.Args[1] { + case "deploy": + err = runDeploy(ctx, os.Args[2:]) + case "certs": + err = runCerts(ctx, os.Args[2:]) + case "destroy": + err = runDestroy(ctx, os.Args[2:]) + case "version", "--version": + fmt.Println("ihasmail-oneshot", version) + case "help", "-h", "--help": + fmt.Print(usageText) + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usageText) + os.Exit(2) + } + if err != nil { + if !errors.Is(err, flag.ErrHelp) { + fmt.Fprintf(os.Stderr, "!! %v\n", err) + } + os.Exit(1) + } +} + +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } +func (s *stringList) Set(v string) error { *s = append(*s, v); return nil } + +func runDeploy(ctx context.Context, args []string) error { + var o config.Options + var yes bool + var users stringList + fs := flag.NewFlagSet("deploy", flag.ContinueOnError) + fs.Usage = func() { + fmt.Fprint(fs.Output(), `Usage: ihasmail-oneshot deploy --domain example.com [flags] + ihasmail-oneshot deploy --local [flags] + +Without --local this publishes SMTP (25), submissions (465), IMAPS (993), +POP3S (995), ManageSieve (4190) and HTTP/HTTPS (80, 443) on every interface, +and asks Let's Encrypt for certificates. With --local it publishes nothing +but the two loopback addresses below and asks for no certificates. + +Flags: +`) + fs.PrintDefaults() + } + fs.BoolVar(&o.Local, "local", false, "loopback-only evaluation pair: no mail ports, no Caddy, no certificates") + fs.StringVar(&o.Domain, "domain", "", "mail domain, e.g. example.com (default example.test with --local)") + fs.StringVar(&o.MailHost, "mail-host", "", "Stalwart's hostname, one label under the domain (default mail.DOMAIN)") + fs.StringVar(&o.WebmailHost, "webmail-host", "", "the webmail's hostname (default webmail.DOMAIN)") + fs.StringVar(&o.Email, "email", "", "ACME contact address (default postmaster@DOMAIN)") + fs.Var(&users, "user", "create a mailbox with a generated password; repeat for more (e.g. --user alice)") + fs.StringVar(&o.Dir, "dir", "", "deployment directory to write, new or empty (default ./PROJECT)") + fs.StringVar(&o.Project, "project", "", "compose project name (default ihasmail-DOMAIN, dots as dashes)") + fs.StringVar(&o.StalwartImage, "stalwart-image", config.DefaultStalwartImage, "Stalwart image") + fs.StringVar(&o.IhasmailImage, "ihasmail-image", config.DefaultIhasmailImage, "ihasmail image") + fs.StringVar(&o.CaddyImage, "caddy-image", config.DefaultCaddyImage, "Caddy image") + fs.StringVar(&o.WebmailBind, "webmail-bind", "127.0.0.1:8080", "host address for ihasmail's own port") + fs.StringVar(&o.StalwartBind, "stalwart-bind", "127.0.0.1:8081", "host address for Stalwart's plain-HTTP port (admin UI)") + fs.StringVar(&o.Subnet, "subnet", "172.31.253.0/24", "private network for the stack") + fs.StringVar(&o.ACMEDirectory, "acme-directory", "", "ACME directory of a private CA, instead of Let's Encrypt") + fs.StringVar(&o.ACMECARoot, "acme-ca-root", "", "PEM root that the private ACME directory's HTTPS is signed by") + fs.BoolVar(&yes, "yes", false, "do not ask for confirmation") + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() > 0 { + return fmt.Errorf("unexpected argument %q", fs.Arg(0)) + } + o.Users = users + + plan, err := o.Validate() + if err != nil { + return err + } + log := deploy.Log{W: os.Stdout} + + describe(plan, log) + log.Step("preflight") + warnings, err := deploy.Preflight(ctx, plan, log) + if err != nil { + return err + } + for _, w := range warnings { + log.Warn("%s", w) + } + if !yes { + if err := confirm("deploy this?"); err != nil { + return err + } + } + + res, err := deploy.Deploy(ctx, plan, version, log) + if err != nil { + return err + } + summarise(plan, res, log) + return nil +} + +func describe(p config.Plan, log deploy.Log) { + if p.Local { + log.Step("a local pair for %s, in %s", p.Domain, p.Dir) + log.Info("ihasmail http://%s", p.WebmailBind) + log.Info("Stalwart http://%s (admin UI; no mail ports published)", p.StalwartBind) + } else { + log.Step("a mail host for %s, in %s", p.Domain, p.Dir) + log.Info("webmail https://%s", p.WebmailHost) + log.Info("Stalwart %s (admin UI at https://%s/admin)", p.MailHost, p.MailHost) + log.Info("ports %s on every interface", joinInts(p.PublishedPorts())) + if p.ACMEDirectory != "" { + log.Info("ACME %s", p.ACMEDirectory) + } else { + log.Info("ACME Let's Encrypt, contact %s", p.Email) + } + } + log.Info("images %s, %s", p.StalwartImage, p.IhasmailImage) + if !p.Local { + log.Info(" %s", p.CaddyImage) + } + if len(p.Users) > 0 { + log.Info("mailboxes %s", strings.Join(p.Users, ", ")) + } +} + +func summarise(p config.Plan, r *deploy.Result, log deploy.Log) { + log.Step("done") + if p.Local { + log.Info("webmail http://%s", p.WebmailBind) + log.Info("Stalwart http://%s/admin", p.StalwartBind) + } else { + log.Info("webmail https://%s", p.WebmailHost) + log.Info("Stalwart https://%s/admin", p.MailHost) + } + log.Info("sign in as %s (password in %s/credentials.txt)", r.Admin.Username, p.Dir) + if r.AdminInWebmail { + log.Info(" the administrator gets the webmail's Administration menu") + } + for addr := range r.Mailboxes { + log.Info("mailbox %s", addr) + } + if p.Local { + log.Info("") + log.Info("Nothing can reach this pair from outside the host, and no mail can be") + log.Info("delivered to it. Remove it with: ihasmail-oneshot destroy --dir %s", p.Dir) + return + } + log.Info("DNS %s/dns-records.zone -- publish every record in it", p.Dir) + if r.Certificate != nil { + log.Info("certificate issued by %s, valid until %s", r.Certificate.Issuer, r.Certificate.NotValidAfter) + } else { + log.Warn("Stalwart has no certificate yet, so IMAP and SMTP clients will refuse it.") + log.Info(" Once %s resolves to this host: ihasmail-oneshot certs --dir %s", p.MailHost, p.Dir) + } + for _, host := range []string{p.WebmailHost, p.MailHost} { + if issuer, ok := r.CaddyCertificates[host]; ok { + log.Info("https %s: issued by %s", host, issuer) + } else { + log.Warn("Caddy has no certificate for %s yet; it keeps retrying by itself once DNS points here.", host) + } + } + log.Info("") + log.Info("Before mail flows: reverse DNS for this host's address should name %s,", p.MailHost) + log.Info("and outbound port 25 must be open -- many providers block it until asked.") +} + +func runCerts(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("certs", flag.ContinueOnError) + dir := fs.String("dir", "", "deployment directory") + if err := fs.Parse(args); err != nil { + return err + } + if *dir == "" { + return errors.New("--dir is required") + } + return deploy.Certs(ctx, *dir, deploy.Log{W: os.Stdout}) +} + +func runDestroy(ctx context.Context, args []string) error { + fs := flag.NewFlagSet("destroy", flag.ContinueOnError) + dir := fs.String("dir", "", "deployment directory") + yes := fs.Bool("yes", false, "do not ask for confirmation") + if err := fs.Parse(args); err != nil { + return err + } + if *dir == "" { + return errors.New("--dir is required") + } + if !*yes { + if err := confirm(fmt.Sprintf("destroy the deployment in %s, with every mailbox and message in it?", *dir)); err != nil { + return err + } + } + return deploy.Destroy(ctx, *dir, deploy.Log{W: os.Stdout}) +} + +// confirm asks on the terminal, and refuses outright without one: a script +// that forgot --yes should stop, not hang or guess. +func confirm(question string) error { + if fi, err := os.Stdin.Stat(); err != nil || fi.Mode()&os.ModeCharDevice == 0 { + return errors.New("no terminal to confirm on; pass --yes if this is what you mean") + } + fmt.Printf("%s [y/N] ", question) + answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + return nil + } + return errors.New("aborted") +} + +func joinInts(ns []int) string { + s := make([]string, len(ns)) + for i, n := range ns { + s[i] = fmt.Sprint(n) + } + return strings.Join(s, ", ") +} diff --git a/e2e/public.sh b/e2e/public.sh new file mode 100755 index 0000000..5ad90a6 --- /dev/null +++ b/e2e/public.sh @@ -0,0 +1,218 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: GPL-3.0-or-later +# +# End-to-end test of a public deployment, with no internet involved: Pebble +# stands in for Let's Encrypt and a DNS stub answers every name with this +# host's address, so both Caddy and Stalwart really get certificates, over the +# real ports, through the real Caddyfile. +# +# It publishes 25, 80, 443, 465, 993, 995 and 4190 on this machine while it +# runs, and removes everything it created when it ends, pass or fail. +# +# Usage: e2e/public.sh (from the repository root; needs docker, curl, openssl, python3) +# KEEP=1 e2e/public.sh leave the stack up afterwards, to look at +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORK="$ROOT/e2e/work" +BIN="$WORK/ihasmail-oneshot" +DOMAIN=lab.test +MAIL=mx.lab.test # not "mail": proves the names follow --mail-host +WEBMAIL=webmail.lab.test +LABNET=ihasmail-oneshot-e2e +LABSUBNET=172.31.254.0/24 +HOSTIP=172.31.254.1 # this host, as seen from the lab network +DEPLOY="$WORK/deployment" + +pass=0 +ok() { echo " ok $*"; pass=$((pass + 1)); } +die() { echo " FAIL $*" >&2; exit 1; } + +cleanup() { + status=$? + if [ "${KEEP:-}" = 1 ]; then + echo "==> KEEP=1: leaving the stack and the lab up" + return + fi + echo "==> cleaning up" + [ -f "$DEPLOY/compose.yaml" ] && "$BIN" destroy --dir "$DEPLOY" --yes >/dev/null 2>&1 || true + docker rm -f oneshot-e2e-pebble oneshot-e2e-dns >/dev/null 2>&1 || true + docker network rm "$LABNET" >/dev/null 2>&1 || true + rm -rf "$WORK" + [ "$status" -eq 0 ] && echo "==> passed: $pass checks" || echo "==> failed after $pass checks" >&2 +} +trap cleanup EXIT + +rm -rf "$WORK" && mkdir -p "$WORK" +echo "==> building" +(cd "$ROOT" && go build -o "$BIN" ./cmd/ihasmail-oneshot) + +# --- the lab: an ACME CA and a DNS stub --------------------------------------- +echo "==> starting Pebble and the DNS stub" +docker network create --subnet "$LABSUBNET" "$LABNET" >/dev/null +# Pebble's own HTTPS certificate names only localhost and "pebble". Stalwart +# and Caddy reach it at this host's address, from another network, so it gets +# a certificate for that address, signed by the test root that ships with it. +docker create --name oneshot-e2e-extract ghcr.io/letsencrypt/pebble:latest >/dev/null +docker cp -q oneshot-e2e-extract:/test/certs "$WORK/pebble-certs" +docker cp -q oneshot-e2e-extract:/test/config/pebble-config.json "$WORK/pebble-config.json" +docker rm oneshot-e2e-extract >/dev/null +openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=pebble" \ + -keyout "$WORK/pebble-key.pem" -out "$WORK/pebble.csr" 2>/dev/null +openssl x509 -req -in "$WORK/pebble.csr" -days 2 -CA "$WORK/pebble-certs/pebble.minica.pem" \ + -CAkey "$WORK/pebble-certs/pebble.minica.key.pem" -CAcreateserial \ + -extfile <(printf 'subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n' "$HOSTIP") -out "$WORK/pebble-cert.pem" 2>/dev/null +python3 - "$WORK/pebble-config.json" <<'EOF' +import json, sys +path = sys.argv[1] +c = json.load(open(path)) +c["pebble"].update(httpPort=80, tlsPort=443, certificate="/work/pebble-cert.pem", privateKey="/work/pebble-key.pem") +json.dump(c, open(path, "w")) +EOF +docker run -d --name oneshot-e2e-dns --network "$LABNET" --ip 172.31.254.3 \ + ghcr.io/letsencrypt/pebble-challtestsrv:latest \ + -defaultIPv4 "$HOSTIP" -defaultIPv6 "" -http01 "" -https01 "" -tlsalpn01 "" -doh "" >/dev/null +# Nonce rejection off: Pebble refuses 5% of nonces on purpose, and Stalwart +# 0.16.22 gives up an order on the first one instead of retrying. +docker run -d --name oneshot-e2e-pebble --network "$LABNET" --ip 172.31.254.2 \ + -p 14000:14000 -p 15000:15000 -v "$WORK:/work:ro" \ + -e PEBBLE_VA_NOSLEEP=1 -e PEBBLE_WFE_NONCEREJECT=0 \ + ghcr.io/letsencrypt/pebble:latest -config /work/pebble-config.json -dnsserver 172.31.254.3:8053 >/dev/null +for _ in $(seq 1 30); do + curl -sf --cacert "$WORK/pebble-certs/pebble.minica.pem" "https://$HOSTIP:14000/dir" >/dev/null && break + sleep 1 +done +curl -sf --cacert "$WORK/pebble-certs/pebble.minica.pem" "https://$HOSTIP:14000/dir" >/dev/null || die "Pebble did not come up" +ok "Pebble answers at https://$HOSTIP:14000/dir" + +# --- the deployment ------------------------------------------------------------- +echo "==> deploying" +"$BIN" deploy --domain "$DOMAIN" --mail-host "$MAIL" --webmail-host "$WEBMAIL" --user alice \ + --acme-directory "https://$HOSTIP:14000/dir" --acme-ca-root "$WORK/pebble-certs/pebble.minica.pem" \ + --dir "$DEPLOY" --yes | tee "$WORK/deploy.log" +grep -q "linked" "$WORK/deploy.log" || die "deploy did not report the link" +ok "deploy completed and signed in through ihasmail" +grep -q "certificate issued by CN=Pebble" "$WORK/deploy.log" || die "deploy did not report Stalwart's certificate" +ok "deploy reported Stalwart's certificate" + +# expect CODE curl-args...: retry for up to 30s until curl gets CODE. Caddy +# obtains certificates for its names in parallel and in the background, so the +# first handshake for any one of them can come a few seconds after deploy. +expect() { + local want=$1 got=; shift + for _ in $(seq 1 30); do + got=$(curl -s -o /dev/null -w '%{http_code}' "$@" 2>/dev/null || true) + [ "$got" = "$want" ] && return 0 + sleep 1 + done + echo " got HTTP ${got:-nothing}, wanted $want" >&2 + return 1 +} + +cred() { sed -n "s/^$1 = //p" "$DEPLOY/credentials.txt"; } +ADMIN=$(cred admin); ADMIN_PW=$(cred admin_password); ALICE_PW=$(cred "mailbox alice@$DOMAIN") +[ "$(stat -c %a "$DEPLOY/credentials.txt")" = 600 ] || die "credentials.txt is not 0600" +[ "$(stat -c %a "$DEPLOY/.env")" = 600 ] || die ".env is not 0600" +ok "credentials.txt and .env are private" + +stalwart_env=$(docker compose --project-directory "$DEPLOY" ps -q stalwart | xargs docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}') +grep -q STALWART_RECOVERY_ADMIN <<<"$stalwart_env" && die "the bootstrap credential outlived the setup" +ok "no bootstrap credential left on the Stalwart container" + +# Pebble issues from a root it generates at startup. +curl -sf --cacert "$WORK/pebble-certs/pebble.minica.pem" "https://$HOSTIP:15000/roots/0" > "$WORK/issuer-root.pem" +curl -sf --cacert "$WORK/pebble-certs/pebble.minica.pem" "https://$HOSTIP:15000/intermediates/0" > "$WORK/issuer-int.pem" +cat "$WORK/issuer-int.pem" "$WORK/issuer-root.pem" > "$WORK/issuer-chain.pem" + +# --- Stalwart's own TLS: IMAPS and submissions -------------------------------- +for port in 993 465; do + out=$(openssl s_client -connect "127.0.0.1:$port" -servername "$MAIL" -verify_hostname "$MAIL" \ + -CAfile "$WORK/issuer-chain.pem" &1 || true) + grep -q "Verify return code: 0 (ok)" <<<"$out" || { echo "$out" | tail -20; die "port $port does not present a valid certificate for $MAIL"; } + ok "port $port presents a verified certificate for $MAIL" +done +# The Pebble CA's own HTTPS cert is signed by minica, but what Stalwart holds +# came through the ACME order -- so the issuer check above is the real test. + +# --- Caddy: the webmail and Stalwart's web side -------------------------------- +resolve=(--resolve "$WEBMAIL:443:127.0.0.1" --resolve "$MAIL:443:127.0.0.1" --resolve "autoconfig.$DOMAIN:443:127.0.0.1") +# Captured rather than piped: grep -q exits at the first match, curl takes a +# SIGPIPE, and pipefail reports a pass as a failure. +# Retried: deploy waits for Stalwart's certificate, not Caddy's, and Caddy +# obtains its own in the background. +for i in $(seq 1 60); do + health=$(curl -sS -f "${resolve[@]}" --cacert "$WORK/issuer-chain.pem" "https://$WEBMAIL/api/health" 2>&1 || true) + grep -q '"ok":true' <<<"$health" && break + sleep 1 +done +grep -q '"ok":true' <<<"$health" || die "the webmail is not served over verified HTTPS: $health" +[ "$i" -gt 1 ] && echo " (Caddy's certificate took ${i}s after deploy finished)" +ok "https://$WEBMAIL serves ihasmail with a verified certificate" + +expect 200 "${resolve[@]}" --cacert "$WORK/issuer-chain.pem" -H 'Content-Type: application/json' -H 'X-Requested-With: ihasmail' \ + "https://$WEBMAIL/api/auth/login" -d "{\"username\":\"alice@$DOMAIN\",\"password\":\"$ALICE_PW\"}" \ + || die "alice cannot sign in through https://$WEBMAIL" +ok "alice signs in through https://$WEBMAIL" + +expect 200 "${resolve[@]}" --cacert "$WORK/issuer-chain.pem" -u "$ADMIN:$ADMIN_PW" "https://$MAIL/jmap/session" \ + || die "Stalwart's JMAP is not reachable at https://$MAIL" +ok "https://$MAIL reaches Stalwart with a verified certificate" + +expect 200 "${resolve[@]}" --cacert "$WORK/issuer-chain.pem" "https://autoconfig.$DOMAIN/mail/config-v1.1.xml?emailaddress=alice@$DOMAIN" \ + || die "autoconfig is not served at https://autoconfig.$DOMAIN" +ok "https://autoconfig.$DOMAIN serves Thunderbird autoconfig" + +expect 308 --resolve "$MAIL:80:127.0.0.1" "http://$MAIL/" || die "port 80 for $MAIL does not redirect" +ok "http://$MAIL redirects to HTTPS" + +# --- DNS records and the certs command ---------------------------------------- +grep -q "^$DOMAIN\. IN MX 10 $MAIL\." "$DEPLOY/dns-records.zone" || die "dns-records.zone has no MX for $MAIL" +grep -q "_domainkey\.$DOMAIN\. IN TXT" "$DEPLOY/dns-records.zone" || die "dns-records.zone has no DKIM record" +ok "dns-records.zone has the MX and DKIM records" + +certs_out=$("$BIN" certs --dir "$DEPLOY" 2>&1 || true) +grep -q "already holds a certificate for $MAIL" <<<"$certs_out" || die "certs did not see the existing certificate: $certs_out" +ok "certs recognises the certificate already issued" + +# --- the auto-ban ------------------------------------------------------------- +# Last, so nothing earlier can be affected by a ban. Scans come from throwaway +# containers at fixed addresses; queries go through the Stalwart container +# itself, which no ban applies to. +# Called from inside the Stalwart container, so a ban on any outside address +# cannot lock the test out of checking it. +jmap() { + docker compose --project-directory "$DEPLOY" exec -T stalwart curl -s -u "$ADMIN:$ADMIN_PW" \ + -H 'Content-Type: application/json' http://127.0.0.1:8080/jmap/ \ + -d "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[$1]}" +} +blocked() { jmap '["x:BlockedIp/get",{"ids":null,"properties":["address"]},"0"]'; } +SUBNET_PREFIX=172.31.253 + +# A scanner through Caddy is banned by its own address, not Caddy's. The path +# has to be a scanner path that Stalwart answers 404 -- it redirects +# /wp-login.php, which is never counted -- and it takes 30 of them by default. +scanner=172.31.253.81 +docker run --rm --network ihasmail-lab-test_stack --ip "$scanner" curlimages/curl -s -o /dev/null \ + --resolve "$MAIL:443:$SUBNET_PREFIX.10" -k "https://$MAIL/probe/wp-admin.php?[1-35]" || true +sleep 1 +bl=$(blocked) +grep -q "\"$SUBNET_PREFIX.10\"" <<<"$bl" && die "a scan through Caddy banned Caddy itself: $bl" +grep -q "\"$scanner\"" <<<"$bl" || die "a scan through Caddy did not ban the scanner: $bl" +ok "a scan through Caddy bans the scanner ($scanner), not Caddy" +code=$(docker run --rm --network ihasmail-lab-test_stack --ip 172.31.253.82 curlimages/curl -s -o /dev/null -w '%{http_code}' \ + --resolve "autoconfig.$DOMAIN:443:$SUBNET_PREFIX.10" -k "https://autoconfig.$DOMAIN/mail/config-v1.1.xml?emailaddress=alice@$DOMAIN" || true) +[ "$code" = 200 ] || die "another client is refused through Caddy after the scan (HTTP $code)" +ok "other clients still reach Stalwart through Caddy" + +allowed=$(jmap '["x:AllowedIp/get",{"ids":null,"properties":["address"]},"0"]') +grep -q "\"$SUBNET_PREFIX.11\"" <<<"$allowed" || die "ihasmail is not exempt from the auto-ban: $allowed" +ok "ihasmail's address is exempt from the auto-ban" +for _ in 1 2 3 4 5; do + curl -s -o /dev/null -H 'Content-Type: application/json' -H 'X-Requested-With: ihasmail' \ + http://127.0.0.1:8080/api/auth/login -d "{\"username\":\"alice@$DOMAIN\",\"password\":\"wrong\"}" +done +expect 200 -H 'Content-Type: application/json' -H 'X-Requested-With: ihasmail' \ + http://127.0.0.1:8080/api/auth/login -d "{\"username\":\"alice@$DOMAIN\",\"password\":\"$ALICE_PW\"}" \ + || die "alice cannot sign in through ihasmail after failed attempts" +ok "alice still signs in through ihasmail after failed attempts" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a962c50 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Coffey-Labs/ihasmail-oneshot + +go 1.26.5 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1833386 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,310 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package config turns the command line into a Plan: every name, address and +// image the deployment uses, validated once, before anything touches Docker. +// +// Nothing downstream re-checks what is here. A plan that validates is one the +// templates can render without quoting surprises, so the rules are strict on +// purpose: a hostname is a hostname, a bind is host:port, a user name is the +// local part of an address and nothing else. +package config + +import ( + "errors" + "fmt" + "net" + "net/mail" + "net/netip" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +// Versions this release was tested with, end to end. Stalwart is pinned +// because ihasmail validates against one Stalwart release at a time; the +// ihasmail tag is the newest release at the time; Caddy is pinned so that a +// redeploy months from now renders the same proxy. +const ( + DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22" + DefaultIhasmailImage = "ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328" + DefaultCaddyImage = "caddy:2.11.4" +) + +// Stalwart's ACME order covers these next to the mail host, all under the mail +// domain: it is what its own DNS zone points at the mail host as CNAMEs, and +// Caddy has to answer for every one of them on port 80 or the order fails. +var stalwartServiceLabels = []string{"autoconfig", "autodiscover", "mta-sts", "ua-auto-config"} + +// Options is the command line, as given. +type Options struct { + Local bool + + Domain string + MailHost string + WebmailHost string + Email string + + Dir string + Project string + Users []string + + StalwartImage string + IhasmailImage string + CaddyImage string + + WebmailBind string + StalwartBind string + Subnet string + + // A private ACME CA, instead of Let's Encrypt. Both are for an internal CA + // (and for the end-to-end test, which runs one); neither is needed on the + // open internet. + ACMEDirectory string + ACMECARoot string +} + +// Plan is Options after defaults and validation. +type Plan struct { + Local bool + + Domain string + MailHost string + WebmailHost string + Email string + + Dir string + Project string + Users []string // local parts, lower-case, without the domain + + StalwartImage string + IhasmailImage string + CaddyImage string + + WebmailBind string + StalwartBind string + + Subnet netip.Prefix + CaddyIP netip.Addr + IhasmailIP netip.Addr + StalwartIP netip.Addr + + ACMEDirectory string + ACMECARoot string // absolute path, or empty +} + +// StalwartNames is every hostname Caddy fronts for Stalwart: the mail host +// first, then the service names its ACME order includes. +func (p Plan) StalwartNames() []string { + names := []string{p.MailHost} + for _, l := range stalwartServiceLabels { + names = append(names, l+"."+p.Domain) + } + return names +} + +// PublishedPorts is every host port the stack binds on all interfaces. Local +// mode binds nothing but the two loopback addresses. +func (p Plan) PublishedPorts() []int { + if p.Local { + return nil + } + // No 587 or 143: Stalwart 0.16 opens no listener on either by default, and + // its own DNS zone advertises 465 and 993. 995 is advertised too, so it is + // published rather than left as an SRV record that points at nothing. + return []int{25, 80, 443, 465, 993, 995, 4190} +} + +var ( + hostnameRE = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$`) + localRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$`) + projectRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + imageRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*(?::[A-Za-z0-9._-]+)?(?:@sha256:[a-f0-9]{64})?$`) +) + +func normaliseHost(s string) string { + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".") +} + +// Validate applies defaults and checks everything, returning every problem at +// once rather than the first: a one-shot tool that makes you run it five times +// to find five typos is not one shot. +func (o Options) Validate() (Plan, error) { + var errs []error + fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) } + + p := Plan{Local: o.Local} + + p.Domain = normaliseHost(o.Domain) + switch { + case p.Domain == "" && o.Local: + p.Domain = "example.test" + case p.Domain == "": + fail("--domain is required: the mail domain this server receives for, e.g. example.com") + case !hostnameRE.MatchString(p.Domain): + fail("--domain %q is not a domain name", o.Domain) + } + // Defaults below are built from the domain. Past a bad one, report only + // what was typed: "webmail.bad domain is not a hostname" is the same + // mistake again, not a second one. + domainOK := hostnameRE.MatchString(p.Domain) + if !domainOK { + p.Domain = "domain.invalid" + } + + p.MailHost = normaliseHost(o.MailHost) + if p.MailHost == "" { + p.MailHost = "mail." + p.Domain + } + // Stalwart's ACME certificate and its DNS zone are both built per domain, + // so a mail host outside the domain would get a certificate that does not + // name it. One label under the domain is the shape both are sure to cover. + if label, ok := strings.CutSuffix(p.MailHost, "."+p.Domain); (domainOK && (!ok || strings.Contains(label, "."))) || !hostnameRE.MatchString(p.MailHost) { + fail("--mail-host %q must be one label under the domain, e.g. mail.%s", o.MailHost, p.Domain) + } + + p.WebmailHost = normaliseHost(o.WebmailHost) + if p.WebmailHost == "" { + p.WebmailHost = "webmail." + p.Domain + } + if !hostnameRE.MatchString(p.WebmailHost) { + fail("--webmail-host %q is not a hostname", o.WebmailHost) + } + for _, n := range p.StalwartNames() { + if n == p.WebmailHost { + fail("--webmail-host %q is already one of Stalwart's names; give the webmail a name of its own", p.WebmailHost) + } + } + + p.Email = strings.TrimSpace(o.Email) + if p.Email == "" { + p.Email = "postmaster@" + p.Domain + } + if a, err := mail.ParseAddress(p.Email); err != nil || a.Address != p.Email { + fail("--email %q is not a plain email address", o.Email) + } + + p.Project = strings.TrimSpace(o.Project) + if p.Project == "" { + p.Project = "ihasmail-" + strings.ReplaceAll(p.Domain, ".", "-") + } + if !projectRE.MatchString(p.Project) { + fail("--project %q may hold only lower-case letters, digits, '-' and '_'", p.Project) + } + + p.Dir = o.Dir + if p.Dir == "" { + p.Dir = p.Project + } + if abs, err := filepath.Abs(p.Dir); err != nil { + fail("--dir %q: %v", o.Dir, err) + } else { + p.Dir = abs + } + + seen := map[string]bool{"admin": true} + for _, u := range o.Users { + local := strings.ToLower(strings.TrimSpace(u)) + if at := strings.LastIndexByte(local, '@'); at >= 0 { + if domainOK && local[at+1:] != p.Domain { + fail("--user %q is not in %s, the only domain this deploys", u, p.Domain) + continue + } + local = local[:at] + } + switch { + case !localRE.MatchString(local): + fail("--user %q is not a valid mailbox name", u) + case seen[local]: + fail("--user %q is given twice, or is the administrator", u) + default: + seen[local] = true + p.Users = append(p.Users, local) + } + } + + p.StalwartImage = orDefault(o.StalwartImage, DefaultStalwartImage) + p.IhasmailImage = orDefault(o.IhasmailImage, DefaultIhasmailImage) + p.CaddyImage = orDefault(o.CaddyImage, DefaultCaddyImage) + for flag, img := range map[string]string{"--stalwart-image": p.StalwartImage, "--ihasmail-image": p.IhasmailImage, "--caddy-image": p.CaddyImage} { + if !imageRE.MatchString(img) { + fail("%s %q is not an image reference", flag, img) + } + } + + p.WebmailBind = orDefault(o.WebmailBind, "127.0.0.1:8080") + p.StalwartBind = orDefault(o.StalwartBind, "127.0.0.1:8081") + for flag, b := range map[string]string{"--webmail-bind": p.WebmailBind, "--stalwart-bind": p.StalwartBind} { + if err := checkBind(b); err != nil { + fail("%s %q: %v", flag, b, err) + } + } + if p.WebmailBind == p.StalwartBind { + fail("--webmail-bind and --stalwart-bind are both %s", p.WebmailBind) + } + if !p.Local { + for _, port := range p.PublishedPorts() { + for flag, b := range map[string]string{"--webmail-bind": p.WebmailBind, "--stalwart-bind": p.StalwartBind} { + if _, bp, _ := net.SplitHostPort(b); bp == strconv.Itoa(port) { + fail("%s %q collides with port %d, which the mail host publishes", flag, b, port) + } + } + } + } + + subnet := orDefault(o.Subnet, "172.31.253.0/24") + if pfx, err := netip.ParsePrefix(subnet); err != nil || !pfx.Addr().Is4() || pfx.Bits() > 27 || pfx.Masked() != pfx { + fail("--subnet %q must be an IPv4 network no smaller than a /27, e.g. 172.31.253.0/24", subnet) + } else { + p.Subnet = pfx + // Fixed addresses, because Stalwart is told about two of them: Caddy's + // forwarded-for header is believed, and ihasmail is exempt from the + // auto-ban. An address Docker picks afresh on every recreate cannot be + // written into either. + base := pfx.Addr().As4() + at := func(n byte) netip.Addr { b := base; b[3] += n; return netip.AddrFrom4(b) } + p.CaddyIP, p.IhasmailIP, p.StalwartIP = at(10), at(11), at(12) + } + + p.ACMEDirectory = strings.TrimSpace(o.ACMEDirectory) + if o.ACMECARoot != "" { + if abs, err := filepath.Abs(o.ACMECARoot); err != nil { + fail("--acme-ca-root %q: %v", o.ACMECARoot, err) + } else { + p.ACMECARoot = abs + } + } + if p.Local && (p.ACMEDirectory != "" || p.ACMECARoot != "" || o.Email != "") { + fail("--acme-directory, --acme-ca-root and --email have no effect with --local, which requests no certificates") + } + if p.ACMEDirectory != "" && !strings.HasPrefix(p.ACMEDirectory, "https://") { + fail("--acme-directory %q must be an https URL", p.ACMEDirectory) + } + + if len(errs) > 0 { + return Plan{}, errors.Join(errs...) + } + return p, nil +} + +func orDefault(s, def string) string { + if s = strings.TrimSpace(s); s == "" { + return def + } + return s +} + +func checkBind(b string) error { + host, port, err := net.SplitHostPort(b) + if err != nil { + return errors.New("must be host:port, e.g. 127.0.0.1:8080") + } + if _, err := netip.ParseAddr(host); err != nil { + return errors.New("the host part must be an IP address") + } + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return errors.New("the port must be 1-65535") + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..bb5a0bf --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +package config + +import ( + "slices" + "strings" + "testing" +) + +func TestDefaultsFollowTheDomain(t *testing.T) { + p, err := Options{Domain: "Example.COM.", Dir: "/srv/mail"}.Validate() + if err != nil { + t.Fatal(err) + } + for got, want := range map[string]string{ + p.Domain: "example.com", + p.MailHost: "mail.example.com", + p.WebmailHost: "webmail.example.com", + p.Email: "postmaster@example.com", + p.Project: "ihasmail-example-com", + p.Dir: "/srv/mail", + } { + if got != want { + t.Errorf("got %q, want %q", got, want) + } + } + if p.CaddyIP.String() != "172.31.253.10" || p.IhasmailIP.String() != "172.31.253.11" || p.StalwartIP.String() != "172.31.253.12" { + t.Errorf("addresses %s %s %s", p.CaddyIP, p.IhasmailIP, p.StalwartIP) + } + want := []string{"mail.example.com", "autoconfig.example.com", "autodiscover.example.com", "mta-sts.example.com", "ua-auto-config.example.com"} + if !slices.Equal(p.StalwartNames(), want) { + t.Errorf("StalwartNames = %v", p.StalwartNames()) + } +} + +func TestLocalNeedsNoDomainAndPublishesNothing(t *testing.T) { + p, err := Options{Local: true}.Validate() + if err != nil { + t.Fatal(err) + } + if p.Domain != "example.test" || len(p.PublishedPorts()) != 0 { + t.Errorf("domain %q, ports %v", p.Domain, p.PublishedPorts()) + } +} + +func TestRejects(t *testing.T) { + for name, tc := range map[string]struct { + o Options + want string + }{ + "no domain": {Options{}, "--domain is required"}, + "bad domain": {Options{Domain: "not a domain"}, `--domain "not a domain"`}, + "mail host elsewhere": {Options{Domain: "example.com", MailHost: "mx.other.net"}, "one label under the domain"}, + "mail host two deep": {Options{Domain: "example.com", MailHost: "a.b.example.com"}, "one label under the domain"}, + "webmail is autoconfig": {Options{Domain: "example.com", WebmailHost: "autoconfig.example.com"}, "already one of Stalwart's names"}, + "webmail is mail host": {Options{Domain: "example.com", WebmailHost: "mail.example.com"}, "already one of Stalwart's names"}, + "user in another domain": {Options{Domain: "example.com", Users: []string{"bob@example.net"}}, "not in example.com"}, + "user is admin": {Options{Domain: "example.com", Users: []string{"admin"}}, "or is the administrator"}, + "duplicate user": {Options{Domain: "example.com", Users: []string{"bob", "BOB@example.com"}}, "given twice"}, + "bad user": {Options{Domain: "example.com", Users: []string{"bob smith"}}, "not a valid mailbox name"}, + "bind not host:port": {Options{Domain: "example.com", WebmailBind: "8080"}, "--webmail-bind"}, + "bind on a mail port": {Options{Domain: "example.com", StalwartBind: "127.0.0.1:443"}, "collides with port 443"}, + "same binds": {Options{Domain: "example.com", WebmailBind: "127.0.0.1:9000", StalwartBind: "127.0.0.1:9000"}, "are both"}, + "subnet too small": {Options{Domain: "example.com", Subnet: "10.0.0.0/29"}, "--subnet"}, + "subnet not a network": {Options{Domain: "example.com", Subnet: "10.0.0.5/24"}, "--subnet"}, + "acme flags with local": {Options{Local: true, ACMEDirectory: "https://ca.internal/dir"}, "no effect with --local"}, + "acme over http": {Options{Domain: "example.com", ACMEDirectory: "http://ca.internal/dir"}, "must be an https URL"}, + "image with a space": {Options{Domain: "example.com", CaddyImage: "caddy 2"}, "--caddy-image"}, + } { + t.Run(name, func(t *testing.T) { + _, err := tc.o.Validate() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want it to mention %q", err, tc.want) + } + }) + } +} + +// A bad domain is one mistake, not a cascade of defaults built from it. +func TestBadDomainIsReportedOnce(t *testing.T) { + _, err := Options{Domain: "bad domain"}.Validate() + if err == nil { + t.Fatal("no error") + } + if lines := strings.Split(err.Error(), "\n"); len(lines) != 1 { + t.Errorf("got %d errors: %v", len(lines), err) + } +} + +func TestEveryProblemAtOnce(t *testing.T) { + _, err := Options{Domain: "example.com", MailHost: "mx.other.net", Users: []string{"bob smith"}, WebmailBind: "x"}.Validate() + if err == nil { + t.Fatal("no error") + } + if lines := strings.Split(err.Error(), "\n"); len(lines) != 3 { + t.Errorf("got %d errors, want 3: %v", len(lines), err) + } +} diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go new file mode 100644 index 0000000..e8c4b98 --- /dev/null +++ b/internal/deploy/deploy.go @@ -0,0 +1,600 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package deploy is the one shot: preflight, write the directory, bootstrap +// Stalwart, bring the stack up, link and verify it. +package deploy + +import ( + "context" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/Coffey-Labs/ihasmail-oneshot/internal/config" + "github.com/Coffey-Labs/ihasmail-oneshot/internal/docker" + "github.com/Coffey-Labs/ihasmail-oneshot/internal/render" + "github.com/Coffey-Labs/ihasmail-oneshot/internal/stalwart" + "github.com/Coffey-Labs/ihasmail-oneshot/internal/webmail" +) + +// Log is where progress goes. Each step says what it is doing before it does +// it, and every wait longer than a few seconds says so while it waits. +type Log struct{ W io.Writer } + +func (l Log) Step(format string, a ...any) { fmt.Fprintf(l.W, "==> "+format+"\n", a...) } +func (l Log) Info(format string, a ...any) { fmt.Fprintf(l.W, " "+format+"\n", a...) } +func (l Log) Warn(format string, a ...any) { fmt.Fprintf(l.W, "!! "+format+"\n", a...) } + +// Preflight checks everything that can be checked without changing anything. +// Warnings are things that will not stop the deployment but will stop it +// being useful until they are fixed, like DNS that does not point here yet. +func Preflight(ctx context.Context, p config.Plan, log Log) (warnings []string, err error) { + engine, compose, err := docker.Versions(ctx) + if err != nil { + return nil, err + } + log.Info("docker %s, compose %s", engine, compose) + + var problems []error + if leftovers, err := docker.ProjectLeftovers(ctx, p.Project); err != nil { + problems = append(problems, err) + } else if len(leftovers) > 0 { + problems = append(problems, fmt.Errorf("project %s already exists in Docker (%s); destroy it first or choose another --project", p.Project, strings.Join(leftovers, ", "))) + } + + if entries, err := os.ReadDir(p.Dir); err == nil && len(entries) > 0 { + problems = append(problems, fmt.Errorf("%s already has files in it; give --dir a new or empty directory", p.Dir)) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + problems = append(problems, err) + } + + if p.ACMECARoot != "" { + if _, err := os.Stat(p.ACMECARoot); err != nil { + problems = append(problems, fmt.Errorf("--acme-ca-root: %w", err)) + } + } + + addrs := []string{p.WebmailBind, p.StalwartBind} + for _, port := range p.PublishedPorts() { + addrs = append(addrs, fmt.Sprintf(":%d", port)) + } + for _, a := range addrs { + if err := portFree(a); err != nil { + problems = append(problems, err) + } + } + + if !p.Local { + for _, host := range []string{p.WebmailHost, p.MailHost} { + if ips, err := net.DefaultResolver.LookupHost(ctx, host); err != nil || len(ips) == 0 { + warnings = append(warnings, fmt.Sprintf("%s does not resolve yet: its certificate cannot be issued until it points at this host", host)) + } + } + } + return warnings, errors.Join(problems...) +} + +// portFree tries to bind an address. A permission error means an unprivileged +// user asking about a low port, which says nothing about whether Docker can +// have it, so it is not reported. +func portFree(addr string) error { + l, err := net.Listen("tcp", addr) + if err == nil { + return l.Close() + } + if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) { + return nil + } + if errors.Is(err, syscall.EADDRINUSE) { + return fmt.Errorf("port %s is already in use on this host", strings.TrimPrefix(addr, ":")) + } + return fmt.Errorf("cannot bind %s: %w", addr, err) +} + +// Result is what a successful deployment reports. +type Result struct { + Admin stalwart.Admin + Mailboxes map[string]string + IhasmailVersion string + AdminInWebmail bool + Certificate *stalwart.Certificate + // Caddy's certificates, by hostname, as the issuer's name. A name missing + // here had none by the time the tool stopped waiting. + CaddyCertificates map[string]string +} + +// Deploy runs the whole thing. On an error after containers exist, it leaves +// them as they are for inspection and says how to start over. +func Deploy(ctx context.Context, p config.Plan, version string, log Log) (*Result, error) { + log.Step("writing %s", p.Dir) + if err := render.PrepareDir(p.Dir); err != nil { + return nil, err + } + appSecret, err := randomBase64(48) + if err != nil { + return nil, err + } + bootPassword := randomPassword(32) + + caBundle := p.ACMECARoot != "" + composeYAML, err := render.Compose(p, version, caBundle) + if err != nil { + return nil, err + } + if err := render.WriteFile(p.Dir, render.ComposeFile, composeYAML, false); err != nil { + return nil, err + } + if err := render.WriteFile(p.Dir, render.EnvFile, render.Env(appSecret), true); err != nil { + return nil, err + } + if !p.Local { + caddyfile, err := render.Caddy(p, version) + if err != nil { + return nil, err + } + if err := render.WriteFile(p.Dir, render.Caddyfile, caddyfile, false); err != nil { + return nil, err + } + } + + compose := docker.Compose{Dir: p.Dir, Out: indent(log.W)} + + log.Step("pulling images") + if err := compose.Run(ctx, "pull", "--quiet"); err != nil { + return nil, err + } + + if caBundle { + root, err := os.ReadFile(p.ACMECARoot) + if err != nil { + return nil, err + } + system, err := docker.SystemCABundle(ctx, p.StalwartImage) + if err != nil { + return nil, fmt.Errorf("reading the CA bundle out of %s: %w", p.StalwartImage, err) + } + if err := render.WriteFile(p.Dir, render.CARootFile, root, false); err != nil { + return nil, err + } + if err := render.WriteFile(p.Dir, render.CABundleFile, append(system, root...), false); err != nil { + return nil, err + } + } + + // From here on there are containers, so a failure says how to clear them. + res, err := bringUp(ctx, p, compose, bootPassword, log) + if err != nil { + return res, fmt.Errorf("%w\n\nThe stack is left as it is, to look at. To start again from nothing:\n ihasmail-oneshot destroy --dir %s --yes", err, p.Dir) + } + return res, nil +} + +func bringUp(ctx context.Context, p config.Plan, compose docker.Compose, bootPassword string, log Log) (*Result, error) { + stalwartURL := "http://" + p.StalwartBind + res := &Result{Mailboxes: map[string]string{}, CaddyCertificates: map[string]string{}} + + // --- bootstrap ----------------------------------------------------------- + // The bootstrap account comes from an override file that lives only for + // this step, and its password only in this process's environment. Bringing + // the stack up afterwards without the override recreates Stalwart without + // the variable, so no fixed recovery credential outlives the setup. + override, err := os.CreateTemp("", "ihasmail-oneshot-bootstrap-*.yaml") + if err != nil { + return nil, err + } + defer os.Remove(override.Name()) + if _, err := override.WriteString("services:\n stalwart:\n environment:\n STALWART_RECOVERY_ADMIN: ${ONESHOT_BOOTSTRAP_ADMIN:?}\n"); err != nil { + return nil, err + } + override.Close() + + log.Step("starting Stalwart in bootstrap mode") + boot := compose + boot.Files = []string{override.Name()} + boot.Env = []string{"ONESHOT_BOOTSTRAP_ADMIN=admin:" + bootPassword} + if err := boot.Run(ctx, "up", "-d", "stalwart"); err != nil { + return nil, err + } + if err := waitFor(ctx, log, "Stalwart", 90*time.Second, func(ctx context.Context) error { + return stalwart.Live(ctx, stalwartURL) + }); err != nil { + return nil, withLogs(ctx, err, compose, "stalwart") + } + + bootClient := &stalwart.Client{BaseURL: stalwartURL, Username: "admin", Password: bootPassword} + if err := bootClient.CheckBootstrapMode(ctx); err != nil { + return nil, err + } + log.Step("setting up Stalwart for %s (hostname %s)", p.Domain, p.MailHost) + admin, err := bootClient.Bootstrap(ctx, p.MailHost, p.Domain) + if err != nil { + return nil, fmt.Errorf("bootstrap: %w", err) + } + res.Admin = admin + // Written now, not at the end: from this moment the password exists nowhere + // else, and a failure in a later step must not lose it. + if err := writeCredentials(p, admin); err != nil { + return res, err + } + log.Info("administrator %s, password in %s", admin.Username, render.CredentialsFile) + + // --- the whole stack ----------------------------------------------------- + log.Step("starting the stack") + if err := compose.Run(ctx, "up", "-d"); err != nil { + return res, err + } + sw := &stalwart.Client{BaseURL: stalwartURL, Username: admin.Username, Password: admin.Secret} + if err := waitFor(ctx, log, "Stalwart to restart configured", 90*time.Second, func(ctx context.Context) error { + _, err := sw.DomainID(ctx, p.Domain) + return err + }); err != nil { + return res, withLogs(ctx, err, compose, "stalwart") + } + domainID, err := sw.DomainID(ctx, p.Domain) + if err != nil { + return res, err + } + + // --- linking ------------------------------------------------------------- + log.Step("linking ihasmail and Stalwart") + // Every request the webmail makes reaches Stalwart from ihasmail's one + // address -- every sign-in, every push stream opened and dropped as tabs + // come and go. Stalwart bans per address, so a ban on that one would be a + // ban on everybody's webmail. ihasmail rate-limits sign-ins per real client + // itself. (Checked on 0.16.22: failed sign-ins were refused per session but + // never became an address ban; scans and dropped connections are counted, + // and this is not a limit worth finding by losing the webmail to it.) + if err := sw.AllowIP(ctx, p.IhasmailIP.String(), "ihasmail: every webmail request arrives from this address"); err != nil { + return res, fmt.Errorf("exempting ihasmail from the auto-ban: %w", err) + } + log.Info("ihasmail (%s) exempt from Stalwart's auto-ban", p.IhasmailIP) + if !p.Local { + if err := sw.TrustForwardedFor(ctx); err != nil { + return res, fmt.Errorf("trusting Caddy's X-Forwarded-For: %w", err) + } + log.Info("Stalwart takes client addresses from Caddy's X-Forwarded-For") + } + // Neither setting takes effect on a running Stalwart: on 0.16.22 a scan + // through Caddy straight after setting them still banned Caddy, and the + // same scan after a restart banned the scanner. Nor does lifting a ban. + log.Info("restarting Stalwart to apply them") + if err := compose.Run(ctx, "restart", "stalwart"); err != nil { + return res, err + } + if err := waitFor(ctx, log, "Stalwart to restart", 90*time.Second, func(ctx context.Context) error { + _, err := sw.DomainID(ctx, p.Domain) + return err + }); err != nil { + return res, withLogs(ctx, err, compose, "stalwart") + } + + for _, name := range p.Users { + password := randomPassword(24) + if _, err := sw.CreateUser(ctx, name, domainID, password); err != nil { + return res, fmt.Errorf("creating mailbox %s@%s: %w", name, p.Domain, err) + } + address := name + "@" + p.Domain + res.Mailboxes[address] = password + if err := render.AppendFile(p.Dir, render.CredentialsFile, []byte(fmt.Sprintf("mailbox %s = %s\n", address, password))); err != nil { + return res, err + } + log.Info("mailbox %s created", address) + } + + webmailURL := "http://" + p.WebmailBind + if err := waitFor(ctx, log, "ihasmail", 60*time.Second, func(ctx context.Context) error { + h, err := webmail.CheckHealth(ctx, webmailURL) + res.IhasmailVersion = h.Version + return err + }); err != nil { + return res, withLogs(ctx, err, compose, "ihasmail") + } + // Retried briefly: ihasmail can report healthy a moment before its first + // session discovery against a Stalwart that has only just restarted. + if err := waitFor(ctx, log, "a sign-in through ihasmail", 30*time.Second, func(ctx context.Context) error { + ok, err := webmail.SignIn(ctx, webmailURL, admin.Username, admin.Secret) + res.AdminInWebmail = ok + return err + }); err != nil { + return res, withLogs(ctx, err, compose, "ihasmail") + } + log.Info("signed in to ihasmail %s as %s: linked", res.IhasmailVersion, admin.Username) + + if p.Local { + return res, nil + } + + // --- certificates and DNS ----------------------------------------------- + log.Step("requesting Stalwart's certificate") + if err := waitFor(ctx, log, "Caddy", 30*time.Second, func(ctx context.Context) error { + if !compose.Running(ctx, "caddy") { + return errors.New("not running") + } + return nil + }); err != nil { + return res, withLogs(ctx, err, compose, "caddy") + } + if _, err := sw.EnableACME(ctx, domainID, p.ACMEDirectory, p.Email); err != nil { + return res, fmt.Errorf("enabling ACME: %w", err) + } + // Not an error if it does not arrive: DNS that does not point here yet is + // the usual reason, and the fix is DNS and then `certs`, not a redeploy. + _ = waitFor(ctx, log, "the certificate", 90*time.Second, func(ctx context.Context) error { + certs, err := sw.Certificates(ctx) + if err != nil { + return err + } + for _, c := range certs { + if c.SubjectAlternativeNames[p.MailHost] { + res.Certificate = &c + return nil + } + } + return errors.New("not issued yet") + }) + + // Caddy obtains its own in the background. Waited for, so that "done" + // means the HTTPS it prints works -- and, like Stalwart's, not an error + // when it does not arrive. + for _, host := range []string{p.WebmailHost, p.MailHost} { + _ = waitFor(ctx, log, "Caddy's certificate for "+host, 60*time.Second, func(ctx context.Context) error { + issuer, err := servedCertificate(ctx, host) + if err == nil { + res.CaddyCertificates[host] = issuer + } + return err + }) + } + + zone, err := sw.DNSZone(ctx, domainID) + if err != nil { + return res, fmt.Errorf("reading the DNS records: %w", err) + } + if err := render.WriteFile(p.Dir, render.DNSFile, []byte(dnsFile(p, zone)), false); err != nil { + return res, err + } + return res, nil +} + +// servedCertificate reports the issuer of the certificate Caddy presents for +// host on this machine's port 443, without trusting it: the question is +// whether Caddy has one yet, not whether this host trusts the CA. Until it has +// one the handshake fails outright. +func servedCertificate(ctx context.Context, host string) (string, error) { + d := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}} + conn, err := d.DialContext(ctx, "tcp", "127.0.0.1:443") + if err != nil { + return "", err + } + defer conn.Close() + certs := conn.(*tls.Conn).ConnectionState().PeerCertificates + if len(certs) == 0 { + return "", errors.New("no certificate presented") + } + if err := certs[0].VerifyHostname(host); err != nil { + return "", err + } + return certs[0].Issuer.String(), nil +} + +// waitFor retries check until it passes or timeout passes, saying every ten +// seconds that it is still waiting and what the last answer was. +func waitFor(ctx context.Context, log Log, what string, timeout time.Duration, check func(context.Context) error) error { + start := time.Now() + deadline := start.Add(timeout) + lastReport := start + for { + attempt, cancel := context.WithTimeout(ctx, 10*time.Second) + err := check(attempt) + cancel() + if err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("gave up waiting for %s after %s: %w", what, timeout, err) + } + if time.Since(lastReport) >= 10*time.Second { + log.Info("still waiting for %s (%s): %v", what, time.Since(start).Round(time.Second), err) + lastReport = time.Now() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(time.Second): + } + } +} + +func withLogs(ctx context.Context, err error, compose docker.Compose, service string) error { + return fmt.Errorf("%w\n\nlast lines of %s's log:\n%s", err, service, compose.Logs(ctx, service, 25)) +} + +func writeCredentials(p config.Plan, a stalwart.Admin) error { + var b strings.Builder + fmt.Fprintf(&b, "# ihasmail-oneshot credentials for %s, written %s.\n", p.Domain, time.Now().UTC().Format(time.RFC3339)) + b.WriteString("# Keep this file private. The administrator can sign in to the webmail too,\n") + b.WriteString("# and to Stalwart's own admin UI. Change the passwords after first sign-in.\n") + fmt.Fprintf(&b, "domain = %s\n", p.Domain) + fmt.Fprintf(&b, "mail_host = %s\n", p.MailHost) + fmt.Fprintf(&b, "stalwart_url = http://%s\n", p.StalwartBind) + fmt.Fprintf(&b, "admin = %s\n", a.Username) + fmt.Fprintf(&b, "admin_password = %s\n", a.Secret) + return render.WriteFile(p.Dir, render.CredentialsFile, []byte(b.String()), true) +} + +func dnsFile(p config.Plan, zone string) string { + var b strings.Builder + fmt.Fprintf(&b, "; DNS records for %s, from Stalwart. Publish all of them.\n", p.Domain) + b.WriteString(";\n; Two more that Stalwart cannot know, because they are this host's address:\n") + for _, h := range []string{p.MailHost, p.WebmailHost} { + fmt.Fprintf(&b, "; %s. IN A \n", h) + fmt.Fprintf(&b, "; %s. IN AAAA \n", h) + } + fmt.Fprintf(&b, ";\n; And one at your hosting provider rather than in this zone: reverse DNS (PTR)\n; for this host's address, pointing at %s.\n\n", p.MailHost) + b.WriteString(zone) + if !strings.HasSuffix(zone, "\n") { + b.WriteString("\n") + } + return b.String() +} + +// Destroy removes the stack, its volumes, and the files the tool wrote. Mail, +// accounts and certificates go with the volumes; that is the point of it. +func Destroy(ctx context.Context, dir string, log Log) error { + if _, err := os.Stat(filepath.Join(dir, render.ComposeFile)); err != nil { + return fmt.Errorf("%s is not a deployment directory: %w", dir, err) + } + log.Step("removing containers, networks and volumes") + compose := docker.Compose{Dir: dir, Out: indent(log.W)} + // APP_SECRET is required by compose.yaml's interpolation, and a missing + // .env must not make the stack impossible to remove. + compose.Env = []string{"APP_SECRET=unused-by-down"} + if err := compose.Run(ctx, "down", "--volumes", "--remove-orphans"); err != nil { + return err + } + log.Step("removing the files it wrote") + for _, name := range render.Written { + if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + if err := os.Remove(dir); err != nil { + log.Info("left %s in place: it holds files this tool did not write", dir) + } + return nil +} + +// Certs starts a new certificate order, for after DNS has been fixed. +func Certs(ctx context.Context, dir string, log Log) error { + creds, err := ReadCredentials(filepath.Join(dir, render.CredentialsFile)) + if err != nil { + return err + } + sw := &stalwart.Client{BaseURL: creds["stalwart_url"], Username: creds["admin"], Password: creds["admin_password"]} + domainID, err := sw.DomainID(ctx, creds["domain"]) + if err != nil { + return err + } + covering := func(ctx context.Context) (*stalwart.Certificate, error) { + certs, err := sw.Certificates(ctx) + if err != nil { + return nil, err + } + for _, c := range certs { + if c.SubjectAlternativeNames[creds["mail_host"]] { + return &c, nil + } + } + return nil, nil + } + // A new order while a certificate is still valid is refused by Stalwart as + // "renewal not due", so asking would only look like it had done something. + if c, err := covering(ctx); err != nil { + return err + } else if c != nil { + log.Info("Stalwart already holds a certificate for %s, issued by %s, valid until %s", creds["mail_host"], c.Issuer, c.NotValidAfter) + return nil + } + log.Step("starting a new certificate order for %s", creds["domain"]) + if err := sw.RetryCertificates(ctx, domainID); err != nil { + return err + } + var got *stalwart.Certificate + err = waitFor(ctx, log, "the certificate", 90*time.Second, func(ctx context.Context) error { + c, err := covering(ctx) + if err == nil && c == nil { + err = errors.New("not issued yet") + } + got = c + return err + }) + if err != nil { + return fmt.Errorf("%w\n Stalwart's log says why: docker compose --project-directory %s logs stalwart | grep -i acme", err, dir) + } + log.Info("issued by %s, valid until %s", got.Issuer, got.NotValidAfter) + return nil +} + +// ReadCredentials parses credentials.txt's "key = value" lines. +func ReadCredentials(path string) (map[string]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + out := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + if line = strings.TrimSpace(line); line == "" || strings.HasPrefix(line, "#") { + continue + } + if k, v, ok := strings.Cut(line, " = "); ok { + out[k] = v + } + } + for _, k := range []string{"domain", "mail_host", "stalwart_url", "admin", "admin_password"} { + if out[k] == "" { + return nil, fmt.Errorf("%s has no %s", path, k) + } + } + return out, nil +} + +func randomBase64(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// randomPassword is letters and digits only, so it survives YAML, an env file, +// a shell and being read aloud without any quoting. +func randomPassword(n int) string { + const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" + out := make([]byte, n) + buf := make([]byte, 1) + for i := 0; i < n; { + if _, err := rand.Read(buf); err != nil { + panic(err) // crypto/rand.Read does not fail on supported platforms + } + // Rejection sampling keeps every character equally likely. + if int(buf[0]) < 256-256%len(alphabet) { + out[i] = alphabet[int(buf[0])%len(alphabet)] + i++ + } + } + return string(out) +} + +type indentWriter struct { + w io.Writer + bol bool +} + +func (iw *indentWriter) Write(p []byte) (int, error) { + for _, c := range p { + if iw.bol { + if _, err := iw.w.Write([]byte(" ")); err != nil { + return 0, err + } + } + if _, err := iw.w.Write([]byte{c}); err != nil { + return 0, err + } + iw.bol = c == '\n' + } + return len(p), nil +} + +// indent passes docker's own output through, indented under the step it +// belongs to. +func indent(w io.Writer) io.Writer { return &indentWriter{w: w, bol: true} } diff --git a/internal/docker/docker.go b/internal/docker/docker.go new file mode 100644 index 0000000..a27a16e --- /dev/null +++ b/internal/docker/docker.go @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package docker drives the docker CLI. The CLI rather than the Engine API, +// because compose is the thing being driven and the CLI is how it ships: the +// deployment the tool leaves behind is one an operator manages with the same +// commands. +package docker + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// Output runs docker with args and returns its standard output. +func Output(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "docker", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("docker %s: %s", strings.Join(args, " "), msg) + } + return strings.TrimSpace(stdout.String()), nil +} + +// Versions returns the engine and compose versions, which is also the check +// that both are installed and this user may use them. +func Versions(ctx context.Context) (engine, compose string, err error) { + if _, err := exec.LookPath("docker"); err != nil { + return "", "", errors.New("docker is not installed, or not on PATH") + } + if engine, err = Output(ctx, "version", "--format", "{{.Server.Version}}"); err != nil { + return "", "", fmt.Errorf("cannot talk to the Docker daemon -- is it running, and may this user use it? (%w)", err) + } + if compose, err = Output(ctx, "compose", "version", "--short"); err != nil { + return engine, "", fmt.Errorf("the docker compose plugin is not installed (%w)", err) + } + return engine, compose, nil +} + +// ProjectLeftovers lists containers, volumes and networks already labelled +// with a compose project name. Any at all means an earlier run of the same +// project, and its volumes would hand a "fresh" deployment an old server. +func ProjectLeftovers(ctx context.Context, project string) ([]string, error) { + filter := "label=com.docker.compose.project=" + project + var found []string + for _, kind := range []struct{ name, format string }{ + {"container", "{{.Names}}"}, + {"volume", "{{.Name}}"}, + {"network", "{{.Name}}"}, + } { + args := []string{kind.name, "ls", "--filter", filter, "--format", kind.format} + if kind.name == "container" { + args = []string{"ps", "-a", "--filter", filter, "--format", kind.format} + } + out, err := Output(ctx, args...) + if err != nil { + return nil, err + } + for _, line := range strings.Fields(out) { + found = append(found, kind.name+" "+line) + } + } + return found, nil +} + +// Compose runs docker compose against one deployment directory. +type Compose struct { + Dir string + Files []string // extra -f files after compose.yaml, e.g. the bootstrap override + Env []string // added to the environment compose interpolates from + Out io.Writer +} + +// Run runs a compose command with its output passed through: pulling images +// takes long enough that silence would look like a hang. Everything but a pull +// is quiet, because without a terminal compose prints each container's every +// state change twice and the tool already says what step it is on. +func (c Compose) Run(ctx context.Context, args ...string) error { + full := []string{"compose", "--project-directory", c.Dir, "-f", c.Dir + "/compose.yaml"} + if len(args) > 0 && args[0] != "pull" { + full = append(full, "--progress", "quiet") + } + for _, f := range c.Files { + full = append(full, "-f", f) + } + full = append(full, args...) + cmd := exec.CommandContext(ctx, "docker", full...) + cmd.Env = append(os.Environ(), c.Env...) + cmd.Stdout, cmd.Stderr = c.Out, c.Out + if err := cmd.Run(); err != nil { + return fmt.Errorf("docker compose %s: %w", strings.Join(args, " "), err) + } + return nil +} + +// Running reports whether a service has a running container. +func (c Compose) Running(ctx context.Context, service string) bool { + out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml", + "ps", "--status", "running", "--services") + if err != nil { + return false + } + for _, s := range strings.Fields(out) { + if s == service { + return true + } + } + return false +} + +// Logs returns the last lines of one service's log, for a failure report. +func (c Compose) Logs(ctx context.Context, service string, lines int) string { + out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml", + "logs", "--no-color", "--tail", fmt.Sprint(lines), service) + if err != nil { + return err.Error() + } + return out +} + +// SystemCABundle reads the CA bundle out of an image, so a private CA can be +// added to the roots the image already trusts rather than replacing them. +func SystemCABundle(ctx context.Context, image string) ([]byte, error) { + out, err := Output(ctx, "run", "--rm", "--entrypoint", "cat", image, "/etc/ssl/certs/ca-certificates.crt") + if err != nil { + return nil, err + } + return []byte(out + "\n"), nil +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..2e2d8d5 --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package render writes the deployment directory: compose.yaml, the Caddyfile, +// and the files holding secrets. +package render + +import ( + "bytes" + "embed" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/Coffey-Labs/ihasmail-oneshot/internal/config" +) + +//go:embed templates/*.tmpl +var templates embed.FS + +var tmpl = template.Must(template.New("").Funcs(template.FuncMap{ + "join": strings.Join, +}).ParseFS(templates, "templates/*.tmpl")) + +// Files the tool writes into a deployment directory, and nothing else. Destroy +// removes exactly these, so a file the operator added survives it. +const ( + ComposeFile = "compose.yaml" + Caddyfile = "Caddyfile" + EnvFile = ".env" + CredentialsFile = "credentials.txt" + DNSFile = "dns-records.zone" + CARootFile = "acme-ca-root.pem" + CABundleFile = "ca-bundle.crt" +) + +// Written lists every file name Destroy may remove. +var Written = []string{ComposeFile, Caddyfile, EnvFile, CredentialsFile, DNSFile, CARootFile, CABundleFile} + +type data struct { + Version string + Plan config.Plan + CABundle bool +} + +// Compose renders compose.yaml. +func Compose(p config.Plan, version string, caBundle bool) ([]byte, error) { + return execute("compose.yaml.tmpl", data{Version: version, Plan: p, CABundle: caBundle}) +} + +// Caddy renders the Caddyfile. +func Caddy(p config.Plan, version string) ([]byte, error) { + return execute("Caddyfile.tmpl", data{Version: version, Plan: p}) +} + +func execute(name string, d data) ([]byte, error) { + var b bytes.Buffer + if err := tmpl.ExecuteTemplate(&b, name, d); err != nil { + return nil, fmt.Errorf("render %s: %w", name, err) + } + return b.Bytes(), nil +} + +// Env renders .env. Only the app secret lives here: compose.yaml reads it by +// interpolation, so the file compose.yaml sits in can be shown to someone +// without showing them the key every session is sealed with. +func Env(appSecret string) []byte { + return []byte("# Read by docker compose. Changing APP_SECRET signs everyone out.\nAPP_SECRET=" + appSecret + "\n") +} + +// PrepareDir creates dir, or accepts it if it exists and is empty. Anything in +// it already is refused: the tool writes a fresh deployment, and silently +// replacing someone's compose.yaml is not a fresh deployment. +func PrepareDir(dir string) error { + entries, err := os.ReadDir(dir) + switch { + case errors.Is(err, os.ErrNotExist): + return os.MkdirAll(dir, 0o750) + case err != nil: + return err + case len(entries) > 0: + return fmt.Errorf("%s already has files in it; give --dir a new or empty directory", dir) + } + return nil +} + +// WriteFile writes one file into dir, private if it holds a secret. It refuses +// to replace an existing file, for the same reason PrepareDir refuses a full +// directory. +func WriteFile(dir, name string, content []byte, secret bool) error { + mode := os.FileMode(0o644) + if secret { + mode = 0o600 + } + f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + if _, err := f.Write(content); err != nil { + f.Close() + return err + } + return f.Close() +} + +// AppendFile adds to a file WriteFile created, keeping its mode. +func AppendFile(dir, name string, content []byte) error { + f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_APPEND, 0) + if err != nil { + return err + } + if _, err := f.Write(content); err != nil { + f.Close() + return err + } + return f.Close() +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..629c29d --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +package render + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Coffey-Labs/ihasmail-oneshot/internal/config" +) + +func plan(t *testing.T, o config.Options) config.Plan { + t.Helper() + p, err := o.Validate() + if err != nil { + t.Fatal(err) + } + return p +} + +func TestPublicCompose(t *testing.T) { + p := plan(t, config.Options{Domain: "example.com"}) + out, err := Compose(p, "test", false) + if err != nil { + t.Fatal(err) + } + s := string(out) + for _, want := range []string{ + "name: ihasmail-example-com", + "hostname: mail.example.com", + `- "127.0.0.1:8081:8080"`, + `- "25:25"`, `- "465:465"`, `- "993:993"`, `- "995:995"`, `- "4190:4190"`, + `- "80:80"`, `- "443:443"`, `- "443:443/udp"`, + "STALWART_URL: http://stalwart:8080", + "APP_SECRET: ${APP_SECRET:?", + "PUSH_URL: https://webmail.example.com", + "read_only: true", + "ipv4_address: 172.31.253.11", + "caddy-data:", + } { + if !strings.Contains(s, want) { + t.Errorf("compose.yaml lacks %q:\n%s", want, s) + } + } + // Caddy's ports belong to Caddy; Stalwart must not also claim them. + stalwart := s[strings.Index(s, " stalwart:"):strings.Index(s, " ihasmail:")] + if strings.Contains(stalwart, `"80:80"`) || strings.Contains(stalwart, `"443:443"`) { + t.Errorf("Stalwart publishes 80 or 443:\n%s", stalwart) + } + if strings.Contains(s, "ca-bundle.crt") || strings.Contains(s, "acme-ca-root.pem") { + t.Error("CA files mounted with no private CA") + } +} + +func TestLocalComposeHasNoCaddyAndNoMailPorts(t *testing.T) { + p := plan(t, config.Options{Local: true}) + out, err := Compose(p, "test", false) + if err != nil { + t.Fatal(err) + } + s := string(out) + for _, unwanted := range []string{"caddy", `"25:25"`, "PUSH_URL"} { + if strings.Contains(s, unwanted) { + t.Errorf("local compose.yaml has %q:\n%s", unwanted, s) + } + } +} + +func TestCaddyfile(t *testing.T) { + p := plan(t, config.Options{Domain: "example.com", MailHost: "mx.example.com", Email: "ops@example.net"}) + out, err := Caddy(p, "test") + if err != nil { + t.Fatal(err) + } + s := string(out) + for _, want := range []string{ + "email ops@example.net", + "webmail.example.com {", + "mx.example.com, autoconfig.example.com, autodiscover.example.com, mta-sts.example.com, ua-auto-config.example.com {", + "disable_http_challenge", + "http://mx.example.com, http://autoconfig.example.com, http://autodiscover.example.com, http://mta-sts.example.com, http://ua-auto-config.example.com {", + "handle /.well-known/acme-challenge/* {", + "flush_interval -1", + } { + if !strings.Contains(s, want) { + t.Errorf("Caddyfile lacks %q:\n%s", want, s) + } + } + if strings.Contains(s, "acme_ca") { + t.Error("Caddyfile names a CA without --acme-directory") + } + + p = plan(t, config.Options{Domain: "example.com", ACMEDirectory: "https://ca.internal/dir", ACMECARoot: "/tmp/root.pem"}) + out, _ = Caddy(p, "test") + for _, want := range []string{"acme_ca https://ca.internal/dir", "dir https://ca.internal/dir", "trusted_roots /etc/caddy/acme-ca-root.pem"} { + if !strings.Contains(string(out), want) { + t.Errorf("Caddyfile with a private CA lacks %q", want) + } + } +} + +func TestDirectoryIsNeverOverwritten(t *testing.T) { + dir := filepath.Join(t.TempDir(), "d") + if err := PrepareDir(dir); err != nil { + t.Fatal(err) + } + if err := WriteFile(dir, EnvFile, []byte("x"), true); err != nil { + t.Fatal(err) + } + if fi, _ := os.Stat(filepath.Join(dir, EnvFile)); fi.Mode().Perm() != 0o600 { + t.Errorf("secret file mode %v", fi.Mode().Perm()) + } + if err := WriteFile(dir, EnvFile, []byte("y"), true); err == nil { + t.Error("WriteFile replaced an existing file") + } + if err := PrepareDir(dir); err == nil { + t.Error("PrepareDir accepted a directory with files in it") + } +} diff --git a/internal/render/templates/Caddyfile.tmpl b/internal/render/templates/Caddyfile.tmpl new file mode 100644 index 0000000..c2af990 --- /dev/null +++ b/internal/render/templates/Caddyfile.tmpl @@ -0,0 +1,59 @@ +# Written by ihasmail-oneshot {{.Version}} for {{.Plan.Domain}}. +# +# Caddy holds ports 80 and 443 for two things that both want certificates for +# some of the same names: Caddy itself, to serve HTTPS, and Stalwart, whose +# IMAP and SMTP listeners need a certificate of their own. They are kept apart +# by challenge type rather than by name: +# +# Caddy TLS-ALPN-01 on 443 -- for Stalwart's names it never uses port 80. +# Stalwart HTTP-01 on 80, which Caddy forwards to it untouched. +# +# So neither answers the other's challenge, and neither needs the other's key. + +{ + email {{.Plan.Email}} +{{- if .Plan.ACMEDirectory}} + acme_ca {{.Plan.ACMEDirectory}} +{{- end}} +{{- if .Plan.ACMECARoot}} + acme_ca_root /etc/caddy/acme-ca-root.pem +{{- end}} +} + +# The webmail. Push arrives as Server-Sent Events, so responses are flushed as +# they are written rather than buffered. +{{.Plan.WebmailHost}} { + encode zstd gzip + reverse_proxy ihasmail:8080 { + flush_interval -1 + } +} + +# Stalwart's web side: its admin UI, JMAP for other clients, CalDAV, CardDAV, +# autoconfig and MTA-STS. Stalwart is told to believe the X-Forwarded-For Caddy +# sets here, so a scanner is banned by its own address and not by Caddy's. +{{join .Plan.StalwartNames ", "}} { + tls { + issuer acme { +{{- if .Plan.ACMEDirectory}} + dir {{.Plan.ACMEDirectory}} +{{- end}} +{{- if .Plan.ACMECARoot}} + trusted_roots /etc/caddy/acme-ca-root.pem +{{- end}} + email {{.Plan.Email}} + disable_http_challenge + } + } + reverse_proxy stalwart:8080 +} + +# Port 80 for Stalwart's names is Stalwart's challenge path and a redirect. +{{range $i, $n := .Plan.StalwartNames}}{{if $i}}, {{end}}http://{{$n}}{{end}} { + handle /.well-known/acme-challenge/* { + reverse_proxy stalwart:8080 + } + handle { + redir https://{host}{uri} 308 + } +} diff --git a/internal/render/templates/compose.yaml.tmpl b/internal/render/templates/compose.yaml.tmpl new file mode 100644 index 0000000..64889e5 --- /dev/null +++ b/internal/render/templates/compose.yaml.tmpl @@ -0,0 +1,94 @@ +# Written by ihasmail-oneshot {{.Version}} for {{.Plan.Domain}}. +# +# This is the whole deployment: bring it up again with `docker compose up -d` +# from this directory. Secrets are in .env next to it, and the Stalwart +# administrator's password is in credentials.txt -- both readable only by you. +# +# Stalwart's plain-HTTP port is reachable only on the private network below and +# on {{.Plan.StalwartBind}}. ihasmail talks to it over that network, which is +# why STALWART_URL is http://: the leg never leaves this host. +name: {{.Plan.Project}} + +services: + stalwart: + image: {{.Plan.StalwartImage}} + hostname: {{.Plan.MailHost}} + restart: unless-stopped + ports: + - "{{.Plan.StalwartBind}}:8080" +{{- range .Plan.PublishedPorts}}{{if and (ne . 80) (ne . 443)}} + - "{{.}}:{{.}}" +{{- end}}{{end}} + volumes: + - stalwart-etc:/etc/stalwart + - stalwart-data:/var/lib/stalwart +{{- if .CABundle}} + # The system roots plus the private ACME CA, so Stalwart can reach it. + - ./ca-bundle.crt:/etc/ssl/certs/ca-certificates.crt:ro +{{- end}} + networks: + stack: + ipv4_address: {{.Plan.StalwartIP}} + + ihasmail: + image: {{.Plan.IhasmailImage}} + restart: unless-stopped + depends_on: [stalwart] + # Immutable: read-only root, no volume, sessions in memory. A restart signs + # everyone out; nothing else is lost, because nothing else is kept here. + read_only: true + tmpfs: [/tmp] + ports: + - "{{.Plan.WebmailBind}}:8080" + environment: + STALWART_URL: http://stalwart:8080 + APP_SECRET: ${APP_SECRET:?APP_SECRET is missing from .env} + IMMUTABLE: "1" + SESSION_FILE: "" + TRUST_PROXY: "1" + IMAGE_PROXY: "1" +{{- if not .Plan.Local}} + # Stalwart pushes changes to this URL instead of holding a connection per + # tab. If it cannot reach it, every tab uses the relay; nothing breaks. + PUSH_URL: https://{{.Plan.WebmailHost}} +{{- end}} + networks: + stack: + ipv4_address: {{.Plan.IhasmailIP}} +{{- if not .Plan.Local}} + + caddy: + image: {{.Plan.CaddyImage}} + restart: unless-stopped + depends_on: [ihasmail, stalwart] + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config +{{- if .Plan.ACMECARoot}} + - ./acme-ca-root.pem:/etc/caddy/acme-ca-root.pem:ro +{{- end}} + networks: + stack: + ipv4_address: {{.Plan.CaddyIP}} +{{- end}} + +networks: + stack: + ipam: + config: + - subnet: {{.Plan.Subnet}} + +volumes: + stalwart-etc: + stalwart-data: +{{- if not .Plan.Local}} + # Certificates and the ACME account. Losing this means asking for every + # certificate again, which is how rate limits are reached. + caddy-data: + caddy-config: +{{- end}} diff --git a/internal/stalwart/client.go b/internal/stalwart/client.go new file mode 100644 index 0000000..a7b07e6 --- /dev/null +++ b/internal/stalwart/client.go @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package stalwart configures a fresh Stalwart 0.16 over JMAP. +// +// 0.16 has no REST management API and no configuration file to template: a +// server with an empty /etc/stalwart starts in bootstrap mode, and everything +// from the first administrator to the ACME account is a registry object read +// and written with x: methods. Every call here was worked out against a real +// 0.16.22, not the documentation. +package stalwart + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +var using = []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"} + +// Client calls one Stalwart as one account. The zero HTTP uses a client with a +// timeout, so a hung server fails a step instead of hanging the tool. +type Client struct { + BaseURL string // e.g. http://127.0.0.1:8081, no trailing slash + Username string + Password string + HTTP *http.Client +} + +// Call is one method call in a request. +type Call struct { + Method string + Args any + ID string +} + +// Response is one method response. +type Response struct { + Method string + Args json.RawMessage + ID string +} + +// MethodError is a JMAP method-level error: the request was fine, the call was +// refused. +type MethodError struct { + Method string + Type string + Description string +} + +func (e *MethodError) Error() string { + if e.Description != "" { + return fmt.Sprintf("%s: %s: %s", e.Method, e.Type, e.Description) + } + return fmt.Sprintf("%s: %s", e.Method, e.Type) +} + +// HTTPError is a response that was not a JMAP response at all. +type HTTPError struct { + Status int + Body string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.Status, strings.TrimSpace(e.Body)) +} + +func (c *Client) httpClient() *http.Client { + if c.HTTP != nil { + return c.HTTP + } + return &http.Client{Timeout: 30 * time.Second} +} + +// Do sends calls in one request and returns their responses in order. A method +// error in any of them is returned as a *MethodError, after the responses +// before it -- JMAP stops nothing on an error, but every caller here needs +// all of its calls to have worked. +func (c *Client) Do(ctx context.Context, calls ...Call) ([]Response, error) { + mc := make([][3]any, len(calls)) + for i, call := range calls { + mc[i] = [3]any{call.Method, call.Args, call.ID} + } + body, err := json.Marshal(map[string]any{"using": using, "methodCalls": mc}) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/jmap/", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.SetBasicAuth(c.Username, c.Password) + req.Header.Set("Content-Type", "application/json") + res, err := c.httpClient().Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + raw, err := io.ReadAll(io.LimitReader(res.Body, 16<<20)) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, &HTTPError{Status: res.StatusCode, Body: string(raw)} + } + var envelope struct { + MethodResponses [][3]json.RawMessage `json:"methodResponses"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, fmt.Errorf("not a JMAP response: %w", err) + } + out := make([]Response, 0, len(envelope.MethodResponses)) + for _, mr := range envelope.MethodResponses { + var r Response + if err := json.Unmarshal(mr[0], &r.Method); err != nil { + return nil, fmt.Errorf("not a JMAP response: %w", err) + } + if err := json.Unmarshal(mr[2], &r.ID); err != nil { + return nil, fmt.Errorf("not a JMAP response: %w", err) + } + r.Args = mr[1] + if r.Method == "error" { + var e struct { + Type string `json:"type"` + Description string `json:"description"` + } + _ = json.Unmarshal(r.Args, &e) + method := r.ID + for _, call := range calls { + if call.ID == r.ID { + method = call.Method + } + } + return out, &MethodError{Method: method, Type: e.Type, Description: e.Description} + } + out = append(out, r) + } + if len(out) != len(calls) { + return out, fmt.Errorf("sent %d method calls and got %d responses", len(calls), len(out)) + } + return out, nil +} + +// SetError is one entry of notCreated, notUpdated or notDestroyed. +type SetError struct { + Type string `json:"type"` + Description string `json:"description"` + Properties []string `json:"properties"` +} + +func (e SetError) Error() string { + s := e.Type + if e.Description != "" { + s += ": " + e.Description + } + if len(e.Properties) > 0 { + s += " (" + strings.Join(e.Properties, ", ") + ")" + } + return s +} + +// SetResult is the part of a /set response every caller here reads. +type SetResult struct { + Created map[string]json.RawMessage `json:"created"` + Updated map[string]json.RawMessage `json:"updated"` + NotCreated map[string]SetError `json:"notCreated"` + NotUpdated map[string]SetError `json:"notUpdated"` + NotDestroyed map[string]SetError `json:"notDestroyed"` +} + +// Refused returns the first refusal in the result, if any. +func (r SetResult) Refused() error { + for _, m := range []map[string]SetError{r.NotCreated, r.NotUpdated, r.NotDestroyed} { + for id, e := range m { + return fmt.Errorf("%s: %w", id, e) + } + } + return nil +} + +func decodeSet(r Response) (SetResult, error) { + var s SetResult + if err := json.Unmarshal(r.Args, &s); err != nil { + return s, fmt.Errorf("%s: %w", r.Method, err) + } + if err := s.Refused(); err != nil { + return s, fmt.Errorf("%s refused %w", r.Method, err) + } + return s, nil +} + +// createdID reads the server-assigned id of a created object. +func createdID(s SetResult, key string) (string, error) { + raw, ok := s.Created[key] + if !ok { + return "", errors.New("the server did not confirm the create") + } + var obj struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &obj); err != nil || obj.ID == "" { + return "", errors.New("the server confirmed the create without an id") + } + return obj.ID, nil +} + +// Live reports whether Stalwart answers its liveness probe, which it does in +// bootstrap mode too. +func Live(ctx context.Context, baseURL string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz/live", nil) + if err != nil { + return err + } + res, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + return err + } + res.Body.Close() + if res.StatusCode != http.StatusOK { + return &HTTPError{Status: res.StatusCode} + } + return nil +} diff --git a/internal/stalwart/client_test.go b/internal/stalwart/client_test.go new file mode 100644 index 0000000..0d5eafb --- /dev/null +++ b/internal/stalwart/client_test.go @@ -0,0 +1,124 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +package stalwart + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// fake answers each request with the response registered for its first +// method, and records what it was sent. +func fake(t *testing.T, responses map[string]string) (*Client, *[]map[string]any) { + t.Helper() + var seen []map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if u, p, _ := r.BasicAuth(); u != "admin" || p != "pw" { + w.WriteHeader(http.StatusUnauthorized) + return + } + var req struct { + Using []string `json:"using"` + MethodCalls [][3]json.RawMessage `json:"methodCalls"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("bad request body: %v", err) + } + var method string + _ = json.Unmarshal(req.MethodCalls[0][0], &method) + var args map[string]any + _ = json.Unmarshal(req.MethodCalls[0][1], &args) + seen = append(seen, map[string]any{"method": method, "args": args, "using": req.Using}) + body, ok := responses[method] + if !ok { + t.Errorf("unexpected method %s", method) + } + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return &Client{BaseURL: srv.URL, Username: "admin", Password: "pw"}, &seen +} + +func TestBootstrapReturnsTheAdministrator(t *testing.T) { + c, seen := fake(t, map[string]string{ + "x:Bootstrap/set": `{"methodResponses":[["x:Bootstrap/set",{"updated":{"singleton":{"username":"admin@example.com","secret":"s3cret"}}},"0"]]}`, + }) + a, err := c.Bootstrap(context.Background(), "mail.example.com", "example.com") + if err != nil { + t.Fatal(err) + } + if a.Username != "admin@example.com" || a.Secret != "s3cret" { + t.Errorf("admin = %+v", a) + } + update := (*seen)[0]["args"].(map[string]any)["update"].(map[string]any)["singleton"].(map[string]any) + if update["requestTlsCertificate"] != false || update["tracer"].(map[string]any)["@type"] != "Stdout" { + t.Errorf("bootstrap sent %v", update) + } + if using := (*seen)[0]["using"].([]string); len(using) != 2 || using[1] != "urn:stalwart:jmap" { + t.Errorf("using = %v", using) + } +} + +func TestSetRefusalIsAnError(t *testing.T) { + c, _ := fake(t, map[string]string{ + "x:Account/set": `{"methodResponses":[["x:Account/set",{"notCreated":{"user":{"type":"invalidPatch","description":"Missing or invalid '@type' property in object","properties":["roles"]}}},"0"]]}`, + }) + _, err := c.CreateUser(context.Background(), "alice", "b", "pw") + if err == nil || !strings.Contains(err.Error(), "invalidPatch") || !strings.Contains(err.Error(), "roles") { + t.Fatalf("err = %v", err) + } +} + +func TestMethodErrorNamesTheMethod(t *testing.T) { + c, _ := fake(t, map[string]string{ + "x:Bootstrap/get": `{"methodResponses":[["error",{"type":"unknownMethod"},"0"]]}`, + }) + err := c.CheckBootstrapMode(context.Background()) + var me *MethodError + if !errors.As(err, &me) || me.Method != "x:Bootstrap/get" || me.Type != "unknownMethod" { + t.Fatalf("err = %v", err) + } + if !strings.Contains(err.Error(), "not in bootstrap mode") { + t.Errorf("err = %v", err) + } +} + +func TestConfiguredServerRefusesBootstrapCredentials(t *testing.T) { + c, _ := fake(t, nil) + c.Password = "the-bootstrap-password" + if err := c.CheckBootstrapMode(context.Background()); err == nil || !strings.Contains(err.Error(), "not in bootstrap mode") { + t.Fatalf("err = %v", err) + } +} + +func TestEnableACMEUsesHTTP01AndTheBackReference(t *testing.T) { + c, seen := fake(t, map[string]string{ + "x:AcmeProvider/set": `{"methodResponses":[["x:AcmeProvider/set",{"created":{"acme":{"id":"p1"}}},"0"],["x:Domain/set",{"updated":{"b":null}},"1"]]}`, + }) + id, err := c.EnableACME(context.Background(), "b", "", "postmaster@example.com") + if err != nil || id != "p1" { + t.Fatalf("id %q, err %v", id, err) + } + provider := (*seen)[0]["args"].(map[string]any)["create"].(map[string]any)["acme"].(map[string]any) + if provider["challengeType"] != "Http01" { + t.Errorf("challengeType = %v", provider["challengeType"]) + } + if _, set := provider["directory"]; set { + t.Error("directory sent without --acme-directory; Stalwart's default is Let's Encrypt") + } +} + +func TestDomainIDNotFound(t *testing.T) { + c, _ := fake(t, map[string]string{ + "x:Domain/query": `{"methodResponses":[["x:Domain/query",{"ids":["b"]},"0"],["x:Domain/get",{"list":[{"id":"b","name":"other.test"}]},"1"]]}`, + }) + if _, err := c.DomainID(context.Background(), "example.com"); err == nil { + t.Fatal("found a domain that is not there") + } +} diff --git a/internal/stalwart/setup.go b/internal/stalwart/setup.go new file mode 100644 index 0000000..b4fac5d --- /dev/null +++ b/internal/stalwart/setup.go @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +package stalwart + +import ( + "context" + "encoding/json" + "errors" + "fmt" +) + +// Admin is the permanent administrator bootstrap provisions. +type Admin struct { + Username string `json:"username"` + Secret string `json:"secret"` +} + +// CheckBootstrapMode confirms the server is fresh. x:Bootstrap exists only in +// bootstrap mode, so a server that has been set up before -- a volume left +// over from an earlier run, say -- refuses the call, and the tool stops before +// writing anything into someone's configured mail server. +func (c *Client) CheckBootstrapMode(ctx context.Context) error { + _, err := c.Do(ctx, Call{"x:Bootstrap/get", map[string]any{"ids": []string{"singleton"}, "properties": []string{"id"}}, "0"}) + var me *MethodError + var he *HTTPError + // A configured server either refuses the method or, having no bootstrap + // account any more, the credentials. + if errors.As(err, &me) || (errors.As(err, &he) && he.Status == 401) { + return fmt.Errorf("this Stalwart is not in bootstrap mode, so it has been configured before (%w)", err) + } + return err +} + +// Bootstrap completes the setup wizard the web UI would otherwise walk someone +// through, and returns the administrator it creates. The temporary bootstrap +// account stops working once the server restarts out of bootstrap mode. +func (c *Client) Bootstrap(ctx context.Context, hostname, domain string) (Admin, error) { + update := map[string]any{ + "serverHostname": hostname, + "defaultDomain": domain, + // Off, and done explicitly afterwards (see EnableACME): on 0.16.22 this + // flag creates no ACME provider and leaves the domain on manual + // certificates, so turning it on would only look like it had worked. + "requestTlsCertificate": false, + "generateDkimKeys": true, + // The default logs to /var/log/stalwart, which does not exist in the + // image and is not a volume. A container logs to stdout. + "tracer": map[string]any{ + "@type": "Stdout", "enable": true, "level": "info", + "ansi": false, "multiline": false, "lossy": false, + "events": map[string]any{}, "eventsPolicy": "exclude", + }, + } + rs, err := c.Do(ctx, Call{"x:Bootstrap/set", map[string]any{"update": map[string]any{"singleton": update}}, "0"}) + if err != nil { + return Admin{}, err + } + s, err := decodeSet(rs[0]) + if err != nil { + return Admin{}, err + } + var a Admin + if err := json.Unmarshal(s.Updated["singleton"], &a); err != nil || a.Username == "" || a.Secret == "" { + return Admin{}, errors.New("x:Bootstrap/set did not return the administrator it created") + } + return a, nil +} + +// DomainID finds a domain by name. +func (c *Client) DomainID(ctx context.Context, name string) (string, error) { + rs, err := c.Do(ctx, + Call{"x:Domain/query", map[string]any{}, "0"}, + Call{"x:Domain/get", map[string]any{ + "#ids": map[string]any{"resultOf": "0", "name": "x:Domain/query", "path": "/ids"}, + "properties": []string{"name"}, + }, "1"}, + ) + if err != nil { + return "", err + } + var got struct { + List []struct{ ID, Name string } `json:"list"` + } + if err := json.Unmarshal(rs[1].Args, &got); err != nil { + return "", err + } + for _, d := range got.List { + if d.Name == name { + return d.ID, nil + } + } + return "", fmt.Errorf("domain %s does not exist on this server", name) +} + +// EnableACME creates an ACME account using HTTP-01 and moves the domain's +// certificates onto it. Stalwart starts the order at once, with no restart. +// +// HTTP-01 rather than Stalwart's default TLS-ALPN-01, because Caddy holds 443. +// Caddy forwards /.well-known/acme-challenge/ on port 80 for Stalwart's names +// to Stalwart and uses TLS-ALPN-01 itself, so the two never compete. +func (c *Client) EnableACME(ctx context.Context, domainID, directory, contact string) (string, error) { + provider := map[string]any{ + "challengeType": "Http01", + "contact": map[string]bool{contact: true}, + "renewBefore": "R23", + "maxRetries": 10, + "reuseKey": false, + } + if directory != "" { + provider["directory"] = directory + } + rs, err := c.Do(ctx, + Call{"x:AcmeProvider/set", map[string]any{"create": map[string]any{"acme": provider}}, "0"}, + Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: automaticCertificates("#acme")}}, "1"}, + ) + if err != nil { + return "", err + } + s, err := decodeSet(rs[0]) + if err != nil { + return "", err + } + id, err := createdID(s, "acme") + if err != nil { + return "", fmt.Errorf("x:AcmeProvider/set: %w", err) + } + if _, err := decodeSet(rs[1]); err != nil { + return id, err + } + return id, nil +} + +// RetryCertificates starts a fresh ACME order for the domain. A failed order +// is not retried on a restart; moving the domain to manual and straight back +// is what starts a new one. +func (c *Client) RetryCertificates(ctx context.Context, domainID string) error { + var got struct { + List []struct { + CertificateManagement struct { + Type string `json:"@type"` + AcmeProviderID string `json:"acmeProviderId"` + } `json:"certificateManagement"` + } `json:"list"` + } + rs, err := c.Do(ctx, Call{"x:Domain/get", map[string]any{"ids": []string{domainID}, "properties": []string{"certificateManagement"}}, "0"}) + if err != nil { + return err + } + if err := json.Unmarshal(rs[0].Args, &got); err != nil || len(got.List) != 1 { + return errors.New("x:Domain/get did not return the domain") + } + cm := got.List[0].CertificateManagement + if cm.Type != "Automatic" || cm.AcmeProviderID == "" { + return fmt.Errorf("the domain's certificates are %q, not managed by ACME", cm.Type) + } + rs, err = c.Do(ctx, + Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: map[string]any{"certificateManagement": map[string]any{"@type": "Manual"}}}}, "0"}, + Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: automaticCertificates(cm.AcmeProviderID)}}, "1"}, + ) + if err != nil { + return err + } + for _, r := range rs { + if _, err := decodeSet(r); err != nil { + return err + } + } + return nil +} + +func automaticCertificates(providerID string) map[string]any { + return map[string]any{"certificateManagement": map[string]any{ + "@type": "Automatic", + "acmeProviderId": providerID, + // Empty is Stalwart's default set: the mail host plus autoconfig, + // autodiscover, mta-sts and ua-auto-config under the domain. + "subjectAlternativeNames": map[string]bool{}, + }} +} + +// Certificate is what the tool reports about an issued certificate. +type Certificate struct { + Issuer string `json:"issuer"` + NotValidAfter string `json:"notValidAfter"` + SubjectAlternativeNames map[string]bool `json:"subjectAlternativeNames"` +} + +// Certificates lists the certificates Stalwart holds. +func (c *Client) Certificates(ctx context.Context) ([]Certificate, error) { + rs, err := c.Do(ctx, + Call{"x:Certificate/query", map[string]any{}, "0"}, + Call{"x:Certificate/get", map[string]any{ + "#ids": map[string]any{"resultOf": "0", "name": "x:Certificate/query", "path": "/ids"}, + "properties": []string{"issuer", "notValidAfter", "subjectAlternativeNames"}, + }, "1"}, + ) + if err != nil { + return nil, err + } + var got struct { + List []Certificate `json:"list"` + } + return got.List, json.Unmarshal(rs[1].Args, &got) +} + +// TrustForwardedFor makes Stalwart take a client's address from +// X-Forwarded-For. Its auto-ban works per address: behind Caddy, without this, +// one scanner probing for WordPress bans Caddy -- and with it every autoconfig +// lookup, DAV client and certificate renewal that comes through it. Seen on +// 0.16.22, as was the fix. It applies only once Stalwart restarts. +// +// Safe here because nothing untrusted reaches Stalwart's HTTP port: it is +// published on loopback only, and on the private network the only peers are +// Caddy, which sets the header itself, and ihasmail, which sends none. +func (c *Client) TrustForwardedFor(ctx context.Context) error { + rs, err := c.Do(ctx, Call{"x:Http/set", map[string]any{"update": map[string]any{"singleton": map[string]any{"useXForwarded": true}}}, "0"}) + if err != nil { + return err + } + _, err = decodeSet(rs[0]) + return err +} + +// AllowIP exempts an address from the auto-ban. It applies only once Stalwart +// restarts. +func (c *Client) AllowIP(ctx context.Context, address, reason string) error { + rs, err := c.Do(ctx, Call{"x:AllowedIp/set", map[string]any{"create": map[string]any{ + "allow": map[string]any{"address": address, "reason": reason}, + }}, "0"}) + if err != nil { + return err + } + s, err := decodeSet(rs[0]) + if err != nil { + return err + } + _, err = createdID(s, "allow") + return err +} + +// CreateUser creates an ordinary mailbox, in the shape ihasmail's own +// Administration creates one. +func (c *Client) CreateUser(ctx context.Context, name, domainID, password string) (string, error) { + rs, err := c.Do(ctx, Call{"x:Account/set", map[string]any{"create": map[string]any{ + "user": map[string]any{ + "@type": "User", + "name": name, + "domainId": domainID, + "description": nil, + "credentials": map[string]any{"0": map[string]any{"@type": "Password", "secret": password}}, + "roles": map[string]any{"@type": "User"}, + "permissions": map[string]any{"@type": "Inherit"}, + "quotas": map[string]any{}, + "aliases": map[string]any{}, + "memberGroupIds": map[string]any{}, + // Required on create. Turning it on cannot be undone, which is not a + // decision for a deploy tool to make on anyone's behalf. + "encryptionAtRest": map[string]any{"@type": "Disabled"}, + }, + }}, "0"}) + if err != nil { + return "", err + } + s, err := decodeSet(rs[0]) + if err != nil { + return "", err + } + return createdID(s, "user") +} + +// DNSZone returns the records Stalwart wants published for the domain, as a +// zone file fragment: MX, SPF, DKIM, DMARC, the SRV records, MTA-STS and the +// autoconfig names. It has no A or AAAA records; those depend on the host. +func (c *Client) DNSZone(ctx context.Context, domainID string) (string, error) { + rs, err := c.Do(ctx, Call{"x:Domain/get", map[string]any{"ids": []string{domainID}, "properties": []string{"dnsZoneFile"}}, "0"}) + if err != nil { + return "", err + } + var got struct { + List []struct { + DNSZoneFile string `json:"dnsZoneFile"` + } `json:"list"` + } + if err := json.Unmarshal(rs[0].Args, &got); err != nil || len(got.List) != 1 { + return "", errors.New("x:Domain/get did not return the domain") + } + return got.List[0].DNSZoneFile, nil +} diff --git a/internal/webmail/webmail.go b/internal/webmail/webmail.go new file mode 100644 index 0000000..52a39ff --- /dev/null +++ b/internal/webmail/webmail.go @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: 2026 Coffey Labs +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package webmail checks a running ihasmail from the outside, the way a +// browser would reach it. +package webmail + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "time" +) + +// Health is the part of /api/health the tool reports. +type Health struct { + OK bool `json:"ok"` + Version string `json:"version"` +} + +// CheckHealth reads /api/health once. +func CheckHealth(ctx context.Context, baseURL string) (Health, error) { + var h Health + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/health", nil) + if err != nil { + return h, err + } + res, err := (&http.Client{Timeout: 5 * time.Second}).Do(req) + if err != nil { + return h, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return h, fmt.Errorf("/api/health answered HTTP %d", res.StatusCode) + } + if err := json.NewDecoder(res.Body).Decode(&h); err != nil { + return h, fmt.Errorf("/api/health: %w", err) + } + if !h.OK { + return h, fmt.Errorf("/api/health reports not ok") + } + return h, nil +} + +// SignIn signs in through ihasmail and straight back out. It is the proof the +// two are linked: ihasmail can only accept the credentials by presenting them +// to Stalwart over STALWART_URL and getting a JMAP session back. +// +// It returns whether the session carries Stalwart's own capability, which is +// what ihasmail keys its administration features on. +func SignIn(ctx context.Context, baseURL, username, password string) (stalwartCapability bool, err error) { + jar, _ := cookiejar.New(nil) + client := &http.Client{Timeout: 30 * time.Second, Jar: jar} + + body, _ := json.Marshal(map[string]string{"username": username, "password": password}) + res, err := post(ctx, client, baseURL+"/api/auth/login", body) + if err != nil { + return false, err + } + raw, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20)) + res.Body.Close() + if res.StatusCode != http.StatusOK { + return false, fmt.Errorf("sign-in as %s answered HTTP %d: %s", username, res.StatusCode, strings.TrimSpace(string(raw))) + } + var session struct { + Accounts map[string]struct { + AccountCapabilities map[string]json.RawMessage `json:"accountCapabilities"` + } `json:"accounts"` + } + if err := json.Unmarshal(raw, &session); err != nil || len(session.Accounts) == 0 { + return false, fmt.Errorf("sign-in as %s did not return a JMAP session", username) + } + for _, a := range session.Accounts { + if _, ok := a.AccountCapabilities["urn:stalwart:jmap"]; ok { + stalwartCapability = true + } + } + + // Not leaving a session behind matters little with sessions in memory, but + // it costs one request. + if res, err := post(ctx, client, baseURL+"/api/auth/logout", []byte("{}")); err == nil { + res.Body.Close() + } + return stalwartCapability, nil +} + +func post(ctx context.Context, client *http.Client, url string, body []byte) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + // ihasmail's CSRF check for a request that is not same-origin by + // Sec-Fetch-Site. + req.Header.Set("X-Requested-With", "ihasmail") + return client.Do(req) +}