commit 43c50c593e5fe43f75e69bb7ee96755aca1a17d0 Author: Eric Ireland Date: Sun Jul 26 18:47:38 2026 +1000 Initial public release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8fe6d3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +*.swp +*.tmp +.coverage +build/ +dist/ +/vmailctl.conf diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..038eed0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to `vmailctl` will be documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and releases use [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [0.1.0] - 2026-07-26 + +### Added + +- Mailbox creation with secure interactive or generated passwords +- Dovecot password changes using a configured hashing scheme +- Mailbox and alias listing +- Alias creation to real local mailboxes +- Configuration and lookup audit +- Dry-run support for modifying commands +- Exclusive operation locking +- Root-only transaction backups +- Validation and rollback after failed changes +- Rollback on terminal interruption +- Maildir quarantine after failed mailbox creation +- Root ownership, permission and symbolic-link checks for trusted files, + commands, lock and backup paths +- Strict domain and standard-folder validation +- Controlled errors for malformed configuration files +- Standard subscribed IMAP folder creation +- `vmailctl(8)` manual page +- Standalone test suite + +[Unreleased]: https://git.sdf.org/erici/vmailctl/compare/v0.1.0...main +[0.1.0]: https://git.sdf.org/erici/vmailctl/src/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c1cc9d9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +Bug reports, documentation corrections, portability findings, tests and focused +patches are welcome through +. + +Before proposing a code change: + +1. Explain the Postfix/Dovecot layout it addresses. +2. Avoid broadening the parser without fixtures for the new format. +3. Preserve the non-destructive and fail-closed behaviour. +4. Add or update tests. +5. Run `make check` and, when available, `make man-check`. +6. Do not include real domains, addresses, passwords, hashes, logs or private + server configuration. + +Changes affecting transactions, rollback, password handling, ownership, path +validation or command execution require particular care and should include +failure-path tests. + +The project was developed with AI assistance. Contributions produced with +substantial automated assistance are acceptable when that assistance is +disclosed, the contributor understands the change, and the result has been +carefully reviewed and tested. Raw, unreviewed generated patches are not useful. + +By contributing, you agree that your contribution may be distributed under +GPL-3.0-or-later. diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/COPYING @@ -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/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..a4c0344 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,162 @@ +# Installing vmailctl + +`vmailctl` manages an existing Postfix/Dovecot flat-file virtual-mail setup. Do +not install it until the live configuration matches the documented assumptions. + +## Requirements + +- Python 3.10 or newer +- Postfix with `postmap` and `hash:` map support +- Dovecot with `doveadm`, `doveconf`, passwd-file authentication and Maildir +- Root access +- GNU `make`, `install`, `gzip` and `mandb` for the provided installation target + +The example paths suit many Debian-family systems. Other systems may place the +commands or manual hierarchy elsewhere. + +## Verify the existing mail layout + +The Dovecot users file must contain exactly eight colon-separated fields per +account: + +```text +user@example.com:{SCHEME}HASH:5000:5000::/var/mail/vhosts/example.com/user::userdb_mail_path=~/Maildir +``` + +The Postfix mailbox source map must use two whitespace-separated fields: + +```text +user@example.com example.com/user/Maildir/ +``` + +The Postfix alias source map must likewise use two fields: + +```text +alias@example.com user@example.com +``` + +Both Postfix maps must be configured as `hash:` maps. The corresponding `.db` +files should already exist and match their sources. + +Before installation, make an independent backup of: + +```text +/etc/dovecot/virtual-users +/etc/postfix/vmailbox +/etc/postfix/vmailbox.db +/etc/postfix/virtual +/etc/postfix/virtual.db +/var/mail/vhosts +``` + +Adapt that list if your paths differ. + +## Configure + +Review `vmailctl.conf.example`. At minimum, set: + +- `domain` +- `dovecot_users` +- `postfix_mailboxes` +- `postfix_aliases` +- `mail_root` +- `uid` and `gid` +- the paths to `postmap`, `postfix`, `doveadm` and `doveconf` + +The UID and GID must match the virtual-mail owner already used by Dovecot. +`hash_scheme` must appear in `doveadm pw -l`; ARGON2ID is recommended where +supported. + +The configuration, Dovecot users file, Postfix source maps and compiled +`.db` maps must be regular root-owned files, not symbolic links, and must not +be group- or world-writable. Their containing directories must be +root-owned and not writable by untrusted users. The configured command paths +must likewise name root-owned regular executable files in trusted +directories. If a distribution exposes a command through a symbolic link, +configure its resolved real path instead (for example, obtain it with +`readlink -f /path/to/command`). + +The configured domain directory beneath `mail_root` may be owned either by +root or by the configured virtual-mail UID/GID. It must be a real directory +and must not be world-writable. `backup_dir` is created as a root-only `0700` +directory when its parent is trusted; the lock is maintained as a root-only +regular file. + +## Install with make + +```sh +sudo make install +``` + +The installation target: + +- installs `vmailctl` as `/usr/local/sbin/vmailctl` +- installs the man page beneath `/usr/local/share/man/man8` +- creates `/var/backups/vmailctl` with mode `0700` +- installs `/etc/vmailctl.conf` only when that file does not already exist +- refreshes the manual database when `mandb` is available + +It deliberately does not overwrite an existing configuration. + +For packaging or staged installation, set `DESTDIR`. Installation locations may +also be adjusted with `PREFIX` and `SYSCONFDIR`: + +```sh +make DESTDIR=/tmp/package-root install +``` + +## Validate + +Inspect the installed configuration: + +```sh +sudo chown root:root /etc/vmailctl.conf +sudo chmod 0644 /etc/vmailctl.conf +sudoedit /etc/vmailctl.conf +sudo vmailctl audit +``` + +Do not proceed until the audit reports no errors. Warnings should be understood +before making changes. + +Then exercise dry-run paths: + +```sh +sudo vmailctl --dry-run mailbox add test-account +sudo vmailctl --dry-run alias add test-alias existing-mailbox +``` + +Read the complete manual: + +```sh +man 8 vmailctl +``` + +## Manual installation + +If `make install` is unsuitable, copy the files with equivalent ownership and +permissions: + +```sh +sudo install -o root -g root -m 0755 vmailctl /usr/local/sbin/vmailctl +sudo install -o root -g root -m 0644 vmailctl.conf.example /etc/vmailctl.conf +sudo install -d -o root -g root -m 0700 /var/backups/vmailctl +sudo install -d -o root -g root -m 0755 /usr/local/share/man/man8 +sudo install -o root -g root -m 0644 man/vmailctl.8 \ + /usr/local/share/man/man8/vmailctl.8 +sudo gzip -n -9 -f /usr/local/share/man/man8/vmailctl.8 +sudo mandb -q +``` + +Do not copy the example configuration over an existing customised +`/etc/vmailctl.conf`. + +## Updating + +Back up the installed program and configuration, run the new version's tests, +review `CHANGELOG.md`, and replace the executable and man page. Preserve and +compare `/etc/vmailctl.conf` rather than replacing it automatically. + +There is intentionally no automated uninstall target. Removing a root mail +administration tool should be a deliberate manual operation; transaction +backups and mail data are never removed automatically. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f2ed70f --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# SPDX-FileCopyrightText: 2026 Eric Ireland +# SPDX-License-Identifier: GPL-3.0-or-later + +PREFIX ?= /usr/local +SYSCONFDIR ?= /etc +DESTDIR ?= +PYTHON ?= python3 +INSTALL ?= install +GZIP ?= gzip + +SBINDIR := $(DESTDIR)$(PREFIX)/sbin +MANDIR := $(DESTDIR)$(PREFIX)/share/man/man8 +BACKUPDIR := $(DESTDIR)/var/backups/vmailctl +CONFIG := $(DESTDIR)$(SYSCONFDIR)/vmailctl.conf + +.PHONY: all check man-check install + +all: check + +check: + PYTHONDONTWRITEBYTECODE=1 $(PYTHON) -m unittest discover -v \ + -s tests -p 'test_*.py' + +man-check: + groff -man -z -ww man/vmailctl.8 + +install: + $(INSTALL) -d -m 0755 $(SBINDIR) + $(INSTALL) -m 0755 vmailctl $(SBINDIR)/vmailctl + $(INSTALL) -d -m 0755 $(DESTDIR)$(SYSCONFDIR) + @if test -e "$(CONFIG)"; then \ + echo "Preserving existing $(CONFIG)"; \ + else \ + $(INSTALL) -m 0644 vmailctl.conf.example "$(CONFIG)"; \ + fi + $(INSTALL) -d -m 0700 $(BACKUPDIR) + $(INSTALL) -d -m 0755 $(MANDIR) + $(INSTALL) -m 0644 man/vmailctl.8 $(MANDIR)/vmailctl.8 + $(GZIP) -n -9 -f $(MANDIR)/vmailctl.8 + @if test -z "$(DESTDIR)" && command -v mandb >/dev/null 2>&1; then \ + mandb -q; \ + fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1b1cea --- /dev/null +++ b/README.md @@ -0,0 +1,152 @@ +# vmailctl + +`vmailctl` is a small, root-only command for safely managing virtual +mailboxes and aliases in an existing Postfix and Dovecot installation that +uses flat files rather than SQL or LDAP. + +It is intended for personal and small-community mail servers where a complete +administration panel would add more machinery than it removes. + +> **Status:** `0.1.0` is a public beta. Review the assumptions and configuration +> carefully, retain independent backups, and test with `--dry-run` before using +> it on a production mail server. + +## Features + +- Creates Dovecot passwd-file mailbox logins +- Generates ARGON2ID hashes through `doveadm` +- Creates Maildir storage and standard subscribed folders +- Changes passwords without exposing plaintext in command arguments +- Creates aliases to real local mailboxes +- Audits files, permissions, maps, Maildirs, configurations and lookups +- Compiles Postfix hash maps before installing them +- Locks concurrent changes +- Creates root-only transaction backups before every write +- Rolls failed transactions back and quarantines a partially created Maildir +- Supports non-writing dry runs +- Deliberately provides no destructive deletion command + +## Scope and assumptions + +`vmailctl` does not install or configure a mail server. It manages one specific +kind of existing setup: + +- GNU/Linux or a closely compatible POSIX system +- Python 3.10 or newer +- One Postfix virtual-mail domain +- Postfix `hash:` mailbox and alias maps +- Dovecot passwd-file `passdb` and `userdb` +- Dovecot LMTP delivery into Maildir storage +- A shared numeric UID and GID for virtual mail +- Eight-field Dovecot passwd entries with `userdb_mail_path=~/Maildir` + +The paths, domain, UID/GID, password scheme and standard folders are explicit +configuration values. The example configuration follows common Debian-style +paths but is not a universal default. + +## Quick start + +Read [INSTALL.md](INSTALL.md), adapt `vmailctl.conf.example`, and install: + +```sh +sudo make install +sudoedit /etc/vmailctl.conf +sudo vmailctl audit +``` + +Preview a mailbox: + +```sh +sudo vmailctl --dry-run mailbox add alice +``` + +Create it and enter its password at the protected terminal prompts: + +```sh +sudo vmailctl mailbox add alice +``` + +Or generate a strong password, displayed once after successful creation: + +```sh +sudo vmailctl mailbox add alice --generate-password +``` + +Create an alias that delivers to Alice's real mailbox: + +```sh +sudo vmailctl alias add shopping alice +``` + +Other useful commands: + +```sh +sudo vmailctl mailbox list +sudo vmailctl alias list +sudo vmailctl mailbox passwd alice +sudo vmailctl audit +man 8 vmailctl +``` + +## Safety model + +Every modifying operation: + +1. Validates input, trusted-file metadata and address collisions. +2. Acquires an exclusive operation lock. +3. Re-reads the live state while holding the lock. +4. Creates a root-only backup of every file it may replace. +5. Prepares and compiles candidate Postfix maps. +6. Replaces files using same-filesystem temporary files. +7. Validates Postfix, Dovecot and the affected lookups. +8. Restores the previous files if validation fails. + +If a failed mailbox transaction has created a Maildir, it is moved into the +transaction backup rather than deleted. Routine service reloads are not +required. + +The configuration, mail data files, compiled maps and configured command +executables must be real, root-owned files and must not be group- or +world-writable. Their containing directories are also checked. The backup +directory and lock file are restricted to root. Unsafe metadata causes the +command to stop before it reads or changes the mail configuration. + +This does not replace independent system backups. See [SECURITY.md](SECURITY.md) +before installation. + +## Project links + +- Source: +- Issues: +- Releases: + +Clone the repository over HTTPS: + +```sh +git clone https://git.sdf.org/erici/vmailctl.git +``` + +## Tests + +The test suite uses temporary directories and fake mail commands; it does not +need root and does not touch the host's mail configuration: + +```sh +make check +``` + +If GNU `groff` is installed, `make man-check` also validates the manual page. + +## Development and provenance + +The initial version was designed and developed by Eric Ireland with assistance +from OpenAI Codex. All generated and suggested material was reviewed and tested +before inclusion. Substantial automated assistance in future contributions +should likewise be disclosed and human-reviewed. + +## Licence + +Copyright © 2026 Eric Ireland. + +`vmailctl` is free software licensed under the GNU General Public License, +version 3 or (at your option) any later version. See [COPYING](COPYING). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..330941d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,51 @@ +# Security policy + +`vmailctl` runs as root and modifies authentication and mail-routing data. Treat +its executable, configuration, source maps and backups as security-sensitive. + +## Supported versions + +Until the first stable release, only the newest tagged public beta is supported. + +## Reporting a vulnerability + +Do not publish passwords, password hashes, mail configuration, account lists, +hostnames, IP addresses or exploit details in a public issue. + +Use the private contact mechanism provided by the project's repository host. If +none is available, open a minimal issue asking the maintainer for a private +reporting channel without including sensitive details. + +Reports should identify the affected version, describe the impact, and include +the smallest safe reproducer possible. + +Use the public issue tracker only for reports that contain no sensitive +information: . + +## Deployment precautions + +- Install the executable and configuration as root-owned regular files. +- Do not use symbolic links for the configuration, managed mail files, + compiled maps, or configured command executables. +- Keep those files and their containing directories unwritable by untrusted + users. `vmailctl` enforces these metadata checks before operation. +- Keep `/var/backups/vmailctl` accessible only to root. +- Run only reviewed releases from a trusted source. +- Compare the example configuration with the live Postfix and Dovecot setup. +- Retain independent, tested backups outside the transaction-backup directory. +- Run `vmailctl audit` before and after upgrades. +- Use `--dry-run` before each new kind of operation. +- Protect terminals and logs when using `--generate-password`. + +## Threat-model limits + +The transaction lock coordinates cooperative `vmailctl` processes; it cannot +prevent another administrator or program from editing the same files directly. +The utility does not protect a host where root is already compromised. + +The parser intentionally supports a narrow file schema. A valid but different +Postfix or Dovecot layout may be rejected rather than interpreted loosely. + +The rollback mechanism handles ordinary failures and terminal interruption, +but cannot recover from power loss, kernel failure, or an uncatchable process +termination such as `SIGKILL`. Independent backups remain necessary. diff --git a/man/vmailctl.8 b/man/vmailctl.8 new file mode 100644 index 0000000..c3b9d1f --- /dev/null +++ b/man/vmailctl.8 @@ -0,0 +1,320 @@ +.\" SPDX-FileCopyrightText: 2026 Eric Ireland +.\" SPDX-License-Identifier: GPL-3.0-or-later +.TH VMAILCTL 8 "26 July 2026" "vmailctl 0.1.0" "System Administration Commands" +.SH NAME +vmailctl \- manage flat-file Postfix and Dovecot virtual mailboxes +.SH SYNOPSIS +.B vmailctl +.RB [ \-\-config +.IR file ] +.B audit +.PP +.B vmailctl +.RB [ \-\-config +.IR file ] +.B mailbox list +.PP +.B vmailctl +.RB [ \-\-config +.IR file ] +.RB [ \-\-dry\-run ] +.B mailbox add +.I address +.RB [ \-\-generate\-password ] +.PP +.B vmailctl +.RB [ \-\-config +.IR file ] +.RB [ \-\-dry\-run ] +.B mailbox passwd +.I address +.RB [ \-\-generate\-password ] +.PP +.B vmailctl +.RB [ \-\-config +.IR file ] +.B alias list +.PP +.B vmailctl +.RB [ \-\-config +.IR file ] +.RB [ \-\-dry\-run ] +.B alias add +.I alias target +.PP +.B vmailctl +.RB [ \-\-help ] +.RB [ \-\-version ] +.SH DESCRIPTION +.B vmailctl +is a root-only administration command for a small Postfix and Dovecot +virtual-mail setup that uses flat text files and compiled Postfix hash maps. +It creates mailbox logins, changes mailbox passwords, creates aliases, lists +the current configuration, and audits the complete setup. +.PP +The domain is selected in the configuration file. +An address may be supplied either as a local part, such as +.BR alice , +or as a complete address in that domain, such as +.BR alice@example.com . +Input is normalised to lowercase. +.PP +Mailbox passwords are hashed by +.BR doveadm (1) +using ARGON2ID. +Plaintext passwords are passed to +.B doveadm +through standard input and are never placed in command arguments or written +to configuration files. +.PP +The command does not provide mailbox or alias deletion. +.SH GLOBAL OPTIONS +.TP +.BI \-\-config " file" +Read configuration from +.I file +instead of +.IR /etc/vmailctl.conf . +.TP +.B \-\-dry\-run +Validate a proposed mailbox or alias change and describe the resulting +actions without prompting for a password or writing any files. +This option is meaningful only with +.BR "mailbox add" , +.BR "mailbox passwd" , +and +.BR "alias add" . +.TP +.B \-\-version +Print the program version and exit. +.TP +.BR \-h ", " \-\-help +Print usage information and exit. +Help is also available after the +.B mailbox +and +.B alias +subcommands. +.SH COMMANDS +.SS audit +Parse the Dovecot passwd file and both Postfix maps, check ownership and +permissions, detect duplicate or conflicting entries, inspect Maildir paths, +compare source and compiled map timestamps, validate the Postfix and Dovecot +configurations, and test every configured lookup. +.PP +Consistent compatibility entries that occur in both the alias map and the +direct-delivery map are reported as information rather than errors. +The audit makes no changes. +.SS mailbox list +List all addresses that have a Dovecot mailbox login and print the total. +.SS mailbox add address +Create a mailbox login and its matching Postfix delivery mapping. +The command creates the Maildir and its +.BR cur , +.BR new , +and +.B tmp +directories, then creates and subscribes the standard +.BR Archive , +.BR Drafts , +.BR Sent , +.BR Trash , +and +.B Junk +folders. +.PP +Unless +.B \-\-generate\-password +is specified, the new password is read twice from the controlling terminal. +It must contain at least 12 characters. +.TP +.B \-\-generate\-password +Generate a 24-character password with the system cryptographic random-number +generator. +The password is displayed once, and only after the transaction succeeds. +.SS mailbox passwd address +Replace the password hash for an existing Dovecot mailbox login without +altering its user database fields or Maildir. +Password entry and +.B \-\-generate\-password +behave as described for +.BR "mailbox add" . +.SS alias list +List the entries in the Postfix virtual alias map and print the total. +.SS alias add alias target +Create +.I alias +as a new address delivering to +.IR target . +The target must be a real mailbox in the configured Postfix mailbox map; +alias-to-alias chains are not created. +.SH ADDRESS RULES +Local parts may contain lowercase ASCII letters, digits, dots, underscores, +and hyphens. +They must begin with a letter or digit, may not end with a dot, and may not +contain consecutive dots. +They are limited to 63 characters. +.PP +A plus sign is rejected because plus addressing is handled by Postfix's +recipient delimiter and does not represent a separate mailbox. +Addresses in domains other than the configured domain are rejected. +The configured domain must be a conventional dotted ASCII DNS name. +.PP +A new address must not already occur in the Dovecot user file, Postfix +mailbox map, or Postfix alias map. +.SH TRANSACTIONS AND RECOVERY +Every modifying command takes an exclusive lock before re-reading and +changing the live files. +Before a write, it creates a timestamped, root-only backup beneath +.IR /var/backups/vmailctl . +.PP +Text files are replaced using temporary files in the destination directory. +Postfix hash-map candidates are compiled before they replace the live source +and database files. +The command then validates the service configurations and the affected +lookups. +No routine Postfix or Dovecot reload is required. +.PP +If validation fails, the previous files and compiled maps are restored. +The same rollback is attempted if the operation is interrupted from the +terminal. +When a failed mailbox creation has already made a Maildir, that Maildir is +moved into the transaction backup as +.B rolled-back-mailhome +instead of being deleted. +.SH SECURITY +.B vmailctl +must run as root. +The installed program is root-owned, and its lock and transaction backups +are accessible only to root. +.PP +The configuration, Dovecot users file, Postfix source and compiled maps, and +configured command executables must be real, root-owned files. +Symbolic links and group- or world-writable files are rejected. +Their containing directories must be root-owned and must not be writable by +untrusted users. +The configured command paths must name regular executable files rather than +symbolic links. +.PP +The standard folder list rejects option-like names, path separators, control +characters, and duplicates before any invocation of +.BR doveadm (1). +.PP +Interactive passwords are not echoed. +Generated passwords are printed to standard output once; use them only from +a trusted terminal and take care when terminal output is recorded. +Transaction backups contain Dovecot password hashes and must remain +root-only. +.SH FILES +.TP +.I /etc/vmailctl.conf +Non-secret configuration: domain, data paths, mail UID and GID, password +scheme, standard folders, command paths, backup directory, lock path, and +Maildir mode. +It must be a root-owned regular file, must not be group- or world-writable, +and must not be a symbolic link. +.TP +.I /usr/local/sbin/vmailctl +Installed administration command. +.TP +.I /etc/dovecot/virtual-users +Dovecot passwd-file user and password database. +.TP +.I /etc/postfix/vmailbox +Postfix virtual mailbox source map. +.TP +.I /etc/postfix/vmailbox.db +Compiled Postfix virtual mailbox hash map. +.TP +.I /etc/postfix/virtual +Postfix virtual alias source map. +.TP +.I /etc/postfix/virtual.db +Compiled Postfix virtual alias hash map. +.TP +.I /var/mail/vhosts/domain +Mailbox home hierarchy, where +.I domain +is the configured virtual-mail domain. +.TP +.I /var/backups/vmailctl +Root-only transaction backups. +.TP +.I /run/lock/vmailctl.lock +Exclusive operation lock. +.SH EXIT STATUS +.TP +.B 0 +The command completed successfully. +.TP +.B 1 +An operational, validation, configuration, or transaction error occurred. +.TP +.B 2 +The command-line syntax was invalid, or +.B audit +found one or more configuration errors. +.TP +.B 130 +The operation was interrupted from the terminal. +.SH EXAMPLES +Audit the current mail configuration: +.PP +.RS +.EX +sudo vmailctl audit +.EE +.RE +.PP +Preview a new mailbox without prompting or writing: +.PP +.RS +.EX +sudo vmailctl \-\-dry\-run mailbox add alice +.EE +.RE +.PP +Create a mailbox and enter its password securely: +.PP +.RS +.EX +sudo vmailctl mailbox add alice +.EE +.RE +.PP +Create a service mailbox with a generated password: +.PP +.RS +.EX +sudo vmailctl mailbox add notifications \-\-generate\-password +.EE +.RE +.PP +Change a password: +.PP +.RS +.EX +sudo vmailctl mailbox passwd alice +.EE +.RE +.PP +Deliver another address into Alice's mailbox: +.PP +.RS +.EX +sudo vmailctl alias add shopping alice +.EE +.RE +.SH SEE ALSO +.BR doveadm (1), +.BR doveconf (1), +.BR postfix (1), +.BR postmap (1), +.BR virtual (5) +.SH COPYRIGHT +Copyright \(co 2026 Eric Ireland. +.PP +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. diff --git a/tests/test_vmailctl.py b/tests/test_vmailctl.py new file mode 100644 index 0000000..584bcb7 --- /dev/null +++ b/tests/test_vmailctl.py @@ -0,0 +1,440 @@ +# SPDX-FileCopyrightText: 2026 Eric Ireland +# SPDX-License-Identifier: GPL-3.0-or-later + +import importlib.util +from importlib.machinery import SourceFileLoader +from contextlib import contextmanager +import os +from pathlib import Path +import stat +import sys +import tempfile +import textwrap +import unittest +from unittest import mock + + +SOURCE = Path(__file__).resolve().parents[1] / "vmailctl" +SPEC = importlib.util.spec_from_loader( + "vmailctl", + SourceFileLoader("vmailctl", os.fspath(SOURCE)), +) +vmailctl = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = vmailctl +SPEC.loader.exec_module(vmailctl) + + +@contextmanager +def relaxed_transaction_paths(): + @contextmanager + def unlocked(_path): + yield + + with ( + mock.patch.object(vmailctl, "exclusive_lock", unlocked), + mock.patch.object(vmailctl, "ensure_secure_backup_directory"), + ): + yield + + +class ParsingTests(unittest.TestCase): + def test_normalize_localpart(self): + self.assertEqual( + vmailctl.normalize_localpart("Alice.Smith", "example.com"), + ("alice.smith", "alice.smith@example.com"), + ) + self.assertEqual( + vmailctl.normalize_localpart("alice@example.com", "example.com"), + ("alice", "alice@example.com"), + ) + + def test_rejects_unsafe_localparts(self): + for value in ("alice+tag", ".alice", "alice.", "alice..smith", "alice@example.net", "a/b"): + with self.subTest(value=value), self.assertRaises(vmailctl.VmailError): + vmailctl.normalize_localpart(value, "example.com") + + def test_parse_passwd(self): + text = ( + "# comment\n" + "alice@example.com:{ARGON2ID}hash:5000:5000::" + "/var/mail/vhosts/example.com/alice::userdb_mail_path=~/Maildir\n" + ) + parsed = vmailctl.parse_passwd(text, "users") + self.assertEqual(list(parsed), ["alice@example.com"]) + self.assertEqual(parsed["alice@example.com"].fields[2:4], ("5000", "5000")) + + def test_parse_passwd_rejects_duplicate(self): + line = "alice@example.com:hash:5000:5000::/mail/alice::userdb_mail_path=~/Maildir\n" + with self.assertRaises(vmailctl.VmailError): + vmailctl.parse_passwd(line + line, "users") + + def test_parse_map(self): + parsed = vmailctl.parse_map( + "# comment\nalice@example.com example.com/alice/Maildir/\n", + "vmailbox", + ) + self.assertEqual(parsed["alice@example.com"][1], "example.com/alice/Maildir/") + + def test_append_line_repairs_missing_newline(self): + self.assertEqual(vmailctl.append_line("one", "two"), "one\ntwo\n") + + def test_rejects_invalid_domains(self): + for value in ( + ".example.com", + "example..com", + "-example.com", + "example-.com", + "localhost", + "example_com", + ): + with self.subTest(value=value), self.assertRaises(vmailctl.VmailError): + vmailctl.validate_domain(value, "test") + + def test_rejects_unsafe_and_duplicate_folder_names(self): + for folders in ( + ("-A",), + ("../Archive",), + ("Junk\nInjected",), + ("Archive", "Archive"), + ): + with self.subTest(folders=folders), self.assertRaises(vmailctl.VmailError): + vmailctl.validate_folders(folders, "test") + + +class FileSafetyTests(unittest.TestCase): + @staticmethod + def metadata(mode, uid=0, gid=0): + return os.stat_result((mode, 0, 0, 1, uid, gid, 0, 0, 0, 0)) + + def test_trusted_file_metadata_rules(self): + safe = self.metadata(stat.S_IFREG | 0o644) + vmailctl.validate_regular_metadata(safe, "safe", private=False) + for metadata in ( + self.metadata(stat.S_IFREG | 0o666), + self.metadata(stat.S_IFREG | 0o644, uid=1000, gid=1000), + self.metadata(stat.S_IFLNK | 0o777), + ): + with self.subTest(mode=metadata.st_mode), self.assertRaises(vmailctl.VmailError): + vmailctl.validate_regular_metadata(metadata, "unsafe", private=False) + + def test_private_and_executable_metadata_rules(self): + with self.assertRaises(vmailctl.VmailError): + vmailctl.validate_regular_metadata( + self.metadata(stat.S_IFREG | 0o640), + "private", + private=True, + ) + with self.assertRaises(vmailctl.VmailError): + vmailctl.validate_regular_metadata( + self.metadata(stat.S_IFREG | 0o644), + "command", + private=False, + executable=True, + ) + + def test_replace_text_preserves_metadata(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "users" + path.write_text("old\n", encoding="utf-8") + path.chmod(0o640) + vmailctl.replace_text(path, "new\n") + self.assertEqual(path.read_text(encoding="utf-8"), "new\n") + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640) + + def test_backup_restore_text_and_database(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "virtual" + database = root / "virtual.db" + source.write_text("old source\n", encoding="utf-8") + database.write_bytes(b"old database") + settings = mock.Mock() + settings.backup_dir = root / "backups" + with mock.patch.object(vmailctl, "ensure_secure_backup_directory"): + settings.backup_dir.mkdir() + backup = vmailctl.BackupSet(settings, "test", (source, database)) + source.write_text("new source\n", encoding="utf-8") + database.write_bytes(b"new database") + backup.restore() + self.assertEqual(source.read_text(encoding="utf-8"), "old source\n") + self.assertEqual(database.read_bytes(), b"old database") + + def test_malformed_config_is_a_controlled_error(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "vmailctl.conf" + path.write_text("[broken\n", encoding="utf-8") + with ( + mock.patch.object(vmailctl, "validate_regular_metadata"), + self.assertRaises(vmailctl.VmailError), + ): + vmailctl.Settings.load(path) + + def test_lock_file_is_private_and_uses_strict_metadata_call(self): + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "vmailctl.lock" + checked = [] + + def strict_check(metadata, label, *, private, executable=False): + checked.append((metadata, label, private, executable)) + + with ( + mock.patch.object(vmailctl, "validate_secure_directory"), + mock.patch.object(vmailctl, "validate_regular_metadata", strict_check), + vmailctl.exclusive_lock(path), + ): + self.assertTrue(path.exists()) + self.assertEqual(len(checked), 1) + self.assertTrue(checked[0][2]) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + + +class PasswordTests(unittest.TestCase): + def test_generated_password_length_and_alphabet(self): + password = vmailctl.generate_password() + self.assertEqual(len(password), 24) + self.assertTrue(set(password) <= set(vmailctl.GENERATED_PASSWORD_ALPHABET)) + + @mock.patch.object(vmailctl, "run") + def test_hash_password_uses_stdin_not_argv(self, mocked_run): + mocked_run.return_value = mock.Mock(stdout="{ARGON2ID}result\n") + settings = mock.Mock() + settings.doveadm = Path("/usr/bin/doveadm") + settings.hash_scheme = "ARGON2ID" + result = vmailctl.hash_password(settings, "secret-value") + self.assertEqual(result, "{ARGON2ID}result") + argv = mocked_run.call_args.args[0] + self.assertNotIn("secret-value", [os.fspath(item) for item in argv]) + self.assertEqual(mocked_run.call_args.kwargs["input_text"], "secret-value\nsecret-value\n") + + +class TransactionLogicTests(unittest.TestCase): + def fixture_settings(self, root: Path): + users = root / "virtual-users" + mailboxes = root / "vmailbox" + aliases = root / "virtual" + users.write_text( + f"alice@example.com:{{ARGON2ID}}hash:{os.getuid()}:{os.getgid()}::" + f"{root}/mail/example.com/alice::userdb_mail_path=~/Maildir\n", + encoding="utf-8", + ) + mailboxes.write_text( + "alice@example.com example.com/alice/Maildir/\n", + encoding="utf-8", + ) + aliases.write_text("shopping@example.com alice@example.com\n", encoding="utf-8") + for path in (users, mailboxes, aliases): + path.chmod(0o640) + (root / "mail" / "example.com" / "alice" / "Maildir").mkdir(parents=True) + mailboxes.with_name(mailboxes.name + ".db").write_bytes(b"old mailbox db") + aliases.with_name(aliases.name + ".db").write_bytes(b"old alias db") + fake_postmap = root / "postmap" + fake_postmap.write_text( + textwrap.dedent( + """\ + #!/usr/bin/env python3 + from pathlib import Path + import sys + if sys.argv[1] == "-q": + key = sys.argv[2] + source = Path(sys.argv[3].removeprefix("hash:")) + for raw in source.read_text().splitlines(): + fields = raw.split() + if len(fields) == 2 and fields[0].lower() == key.lower(): + print(fields[1]) + break + else: + Path(sys.argv[1] + ".db").write_bytes(b"compiled map") + """ + ), + encoding="utf-8", + ) + fake_postmap.chmod(0o755) + fake_doveadm = root / "doveadm" + fake_doveadm.write_text( + "#!/bin/sh\n" + "if [ \"$1\" = pw ]; then printf '%s\\n' '{ARGON2ID}testhash'; fi\n" + "exit 0\n", + encoding="utf-8", + ) + fake_doveadm.chmod(0o755) + (root / "backups").mkdir() + return vmailctl.Settings( + domain="example.com", + dovecot_users=users, + postfix_mailboxes=mailboxes, + postfix_aliases=aliases, + mail_root=root / "mail", + uid=os.getuid(), + gid=os.getgid(), + hash_scheme="ARGON2ID", + folders=("Archive", "Sent"), + backup_dir=root / "backups", + lock_file=root / "vmailctl.lock", + postmap=fake_postmap, + postfix=Path("/bin/true"), + doveadm=fake_doveadm, + doveconf=Path("/bin/true"), + directory_mode=0o700, + ) + + def test_dry_run_add_does_not_prompt_or_write(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + before = settings.dovecot_users.read_bytes() + with mock.patch.object(vmailctl, "prompt_password") as prompt: + vmailctl.mailbox_add(settings, "bob", True, False) + prompt.assert_not_called() + self.assertEqual(settings.dovecot_users.read_bytes(), before) + self.assertFalse(settings.mailbox_home("bob").exists()) + + def test_alias_requires_real_mailbox_target(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + with self.assertRaises(vmailctl.VmailError): + vmailctl.alias_add(settings, "newalias", "missing", True) + + def test_new_passwd_line_matches_live_schema(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + line = vmailctl.new_passwd_line( + settings, + "bob", + "bob@example.com", + "{ARGON2ID}hash", + ) + fields = line.split(":") + self.assertEqual(len(fields), 8) + self.assertEqual(fields[2:4], [str(os.getuid()), str(os.getgid())]) + self.assertEqual(fields[7], "userdb_mail_path=~/Maildir") + + def test_full_mailbox_add_transaction(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + with ( + relaxed_transaction_paths(), + mock.patch.object(vmailctl, "generate_password", return_value="StrongGeneratedPassword1"), + ): + vmailctl.mailbox_add(settings, "bob", False, True) + passwd = vmailctl.parse_passwd( + settings.dovecot_users.read_text(encoding="utf-8"), + "users", + ) + mailboxes = vmailctl.parse_map( + settings.postfix_mailboxes.read_text(encoding="utf-8"), + "vmailbox", + ) + self.assertIn("bob@example.com", passwd) + self.assertEqual( + mailboxes["bob@example.com"][1], + "example.com/bob/Maildir/", + ) + self.assertTrue((settings.mailbox_home("bob") / "Maildir" / "cur").is_dir()) + self.assertNotIn( + "StrongGeneratedPassword1", + settings.dovecot_users.read_text(encoding="utf-8"), + ) + + def test_full_alias_add_transaction(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + with relaxed_transaction_paths(): + vmailctl.alias_add(settings, "orders", "alice", False) + aliases = vmailctl.parse_map( + settings.postfix_aliases.read_text(encoding="utf-8"), + "virtual", + ) + self.assertEqual(aliases["orders@example.com"][1], "alice@example.com") + + def test_mailbox_add_rolls_back_and_quarantines_home(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + old_users = settings.dovecot_users.read_bytes() + old_mailboxes = settings.postfix_mailboxes.read_bytes() + with ( + relaxed_transaction_paths(), + mock.patch.object(vmailctl, "generate_password", return_value="StrongGeneratedPassword1"), + mock.patch.object( + vmailctl, + "verify_postfix", + side_effect=vmailctl.VmailError("injected validation failure"), + ), + self.assertRaises(vmailctl.VmailError), + ): + vmailctl.mailbox_add(settings, "bob", False, True) + self.assertEqual(settings.dovecot_users.read_bytes(), old_users) + self.assertEqual(settings.postfix_mailboxes.read_bytes(), old_mailboxes) + self.assertFalse(settings.mailbox_home("bob").exists()) + quarantined = list(settings.backup_dir.glob("*/rolled-back-mailhome")) + self.assertEqual(len(quarantined), 1) + + def test_mailbox_add_interrupt_rolls_back_and_quarantines_home(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + old_users = settings.dovecot_users.read_bytes() + old_mailboxes = settings.postfix_mailboxes.read_bytes() + with ( + relaxed_transaction_paths(), + mock.patch.object(vmailctl, "generate_password", return_value="StrongGeneratedPassword1"), + mock.patch.object(vmailctl, "verify_dovecot", side_effect=KeyboardInterrupt()), + self.assertRaises(KeyboardInterrupt), + ): + vmailctl.mailbox_add(settings, "bob", False, True) + self.assertEqual(settings.dovecot_users.read_bytes(), old_users) + self.assertEqual(settings.postfix_mailboxes.read_bytes(), old_mailboxes) + self.assertFalse(settings.mailbox_home("bob").exists()) + quarantined = list(settings.backup_dir.glob("*/rolled-back-mailhome")) + self.assertEqual(len(quarantined), 1) + + def test_password_interrupt_rolls_back(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + old_users = settings.dovecot_users.read_bytes() + with ( + relaxed_transaction_paths(), + mock.patch.object(vmailctl, "generate_password", return_value="StrongGeneratedPassword1"), + mock.patch.object(vmailctl, "verify_dovecot", side_effect=KeyboardInterrupt()), + self.assertRaises(KeyboardInterrupt), + ): + vmailctl.mailbox_password(settings, "alice", False, True) + self.assertEqual(settings.dovecot_users.read_bytes(), old_users) + + def test_alias_interrupt_rolls_back(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + old_aliases = settings.postfix_aliases.read_bytes() + old_database = Path(str(settings.postfix_aliases) + ".db").read_bytes() + with ( + relaxed_transaction_paths(), + mock.patch.object(vmailctl, "verify_postfix", side_effect=KeyboardInterrupt()), + self.assertRaises(KeyboardInterrupt), + ): + vmailctl.alias_add(settings, "orders", "alice", False) + self.assertEqual(settings.postfix_aliases.read_bytes(), old_aliases) + self.assertEqual( + Path(str(settings.postfix_aliases) + ".db").read_bytes(), + old_database, + ) + + def test_consistent_legacy_map_overlap_is_not_an_audit_error(self): + with tempfile.TemporaryDirectory() as temporary: + settings = self.fixture_settings(Path(temporary)) + with settings.postfix_mailboxes.open("a", encoding="utf-8") as handle: + handle.write("postmaster@example.com example.com/alice/Maildir/\n") + with settings.postfix_aliases.open("a", encoding="utf-8") as handle: + handle.write("postmaster@example.com alice@example.com\n") + os.chown(settings.mailbox_home("alice") / "Maildir", settings.uid, settings.gid) + with ( + mock.patch.object(vmailctl, "query_map") as query, + mock.patch.object(vmailctl, "run", return_value=mock.Mock(returncode=0)), + ): + def lookup(_settings, path, key): + parsed = vmailctl.parse_map(path.read_text(encoding="utf-8"), str(path)) + return parsed[key][1] + + query.side_effect = lookup + self.assertEqual(vmailctl.audit(settings), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/vmailctl b/vmailctl new file mode 100755 index 0000000..8fd1674 --- /dev/null +++ b/vmailctl @@ -0,0 +1,1089 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Eric Ireland +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This file is part of vmailctl. +"""Safely manage a small Postfix/Dovecot flat-file virtual mail setup.""" + +from __future__ import annotations + +import argparse +import configparser +import datetime as dt +import fcntl +import getpass +import os +from pathlib import Path +import re +import secrets +import shutil +import stat +import string +import subprocess +import sys +import tempfile +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Iterable + + +VERSION = "0.1.0" +DEFAULT_CONFIG = Path("/etc/vmailctl.conf") +LOCALPART_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$") +DOMAIN_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") +GENERATED_PASSWORD_ALPHABET = string.ascii_letters + string.digits + "-._~" + + +class VmailError(Exception): + """A safe, user-facing failure.""" + + +@dataclass(frozen=True) +class Settings: + domain: str + dovecot_users: Path + postfix_mailboxes: Path + postfix_aliases: Path + mail_root: Path + uid: int + gid: int + hash_scheme: str + folders: tuple[str, ...] + backup_dir: Path + lock_file: Path + postmap: Path + postfix: Path + doveadm: Path + doveconf: Path + directory_mode: int + + @classmethod + def load(cls, path: Path) -> "Settings": + """Load configuration only after establishing that root can trust it. + + Command paths come from this file and are later executed as root, so + the configuration file's ownership, permissions and symbolic-link + status are checked before any values are parsed. + """ + parser = configparser.ConfigParser(interpolation=None) + descriptor = -1 + try: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags) + metadata = os.fstat(descriptor) + validate_regular_metadata(metadata, os.fspath(path), private=False) + if not hasattr(os, "O_NOFOLLOW"): + # Detect a link or path replacement on systems without + # O_NOFOLLOW by comparing the opened file with the live path. + path_metadata = path.lstat() + if ( + path_metadata.st_dev != metadata.st_dev + or path_metadata.st_ino != metadata.st_ino + or stat.S_ISLNK(path_metadata.st_mode) + ): + raise VmailError(f"{path} must not be a symbolic link") + with os.fdopen(descriptor, encoding="utf-8") as handle: + descriptor = -1 + parser.read_file(handle) + except VmailError: + raise + except (OSError, UnicodeError, configparser.Error) as exc: + raise VmailError(f"cannot read configuration {path}: {exc}") from exc + finally: + if descriptor >= 0: + os.close(descriptor) + if "vmailctl" not in parser: + raise VmailError(f"{path} has no [vmailctl] section") + section = parser["vmailctl"] + + def required(name: str) -> str: + value = section.get(name, "").strip() + if not value: + raise VmailError(f"{path}: missing {name}") + return value + + def absolute_path(name: str) -> Path: + value = Path(required(name)) + if not value.is_absolute(): + raise VmailError(f"{path}: {name} must be an absolute path") + return value + + domain = validate_domain(required("domain"), os.fspath(path)) + try: + uid = int(required("uid")) + gid = int(required("gid")) + directory_mode = int(required("directory_mode"), 8) + except ValueError as exc: + raise VmailError(f"{path}: uid, gid and directory_mode must be numeric") from exc + if uid < 100 or gid < 1: + raise VmailError(f"{path}: refusing unsafe uid/gid {uid}:{gid}") + if directory_mode & 0o077 or directory_mode & 0o700 != 0o700: + raise VmailError( + f"{path}: directory_mode must grant owner rwx and no group/world access" + ) + folders = tuple(x.strip() for x in required("folders").split(",") if x.strip()) + validate_folders(folders, os.fspath(path)) + + settings = cls( + domain=domain, + dovecot_users=absolute_path("dovecot_users"), + postfix_mailboxes=absolute_path("postfix_mailboxes"), + postfix_aliases=absolute_path("postfix_aliases"), + mail_root=absolute_path("mail_root"), + uid=uid, + gid=gid, + hash_scheme=required("hash_scheme").upper(), + folders=folders, + backup_dir=absolute_path("backup_dir"), + lock_file=absolute_path("lock_file"), + postmap=absolute_path("postmap"), + postfix=absolute_path("postfix"), + doveadm=absolute_path("doveadm"), + doveconf=absolute_path("doveconf"), + directory_mode=directory_mode, + ) + for command in (settings.postmap, settings.postfix, settings.doveadm, settings.doveconf): + validate_secure_directory( + command.parent, + f"command directory {command.parent}", + private=False, + ) + validate_root_owned_regular(command, f"required command {command}", executable=True) + return settings + + @property + def domain_mail_root(self) -> Path: + return self.mail_root / self.domain + + def mailbox_home(self, localpart: str) -> Path: + return self.domain_mail_root / localpart + + def mailbox_target(self, localpart: str) -> str: + return f"{self.domain}/{localpart}/Maildir/" + + +@dataclass(frozen=True) +class PasswdEntry: + line_number: int + address: str + fields: tuple[str, ...] + + +def require_root() -> None: + if os.geteuid() != 0: + raise VmailError("this command must be run as root (use sudo)") + + +def validate_regular_metadata( + metadata: os.stat_result, + label: str, + *, + private: bool, + executable: bool = False, +) -> None: + """Apply the trust rules shared by configuration and managed files.""" + mode = stat.S_IMODE(metadata.st_mode) + if not stat.S_ISREG(metadata.st_mode): + raise VmailError(f"{label} must be a regular file, not a symlink or special file") + if metadata.st_uid != 0: + raise VmailError(f"{label} must be owned by root") + if mode & 0o022: + raise VmailError(f"{label} must not be group/world writable ({mode:04o})") + if private and mode & 0o077: + raise VmailError(f"{label} must be accessible only to root ({mode:04o})") + if executable and not mode & 0o111: + raise VmailError(f"{label} is not executable") + + +def validate_root_owned_regular( + path: Path, + label: str, + *, + private: bool = False, + executable: bool = False, +) -> None: + try: + metadata = path.lstat() + except OSError as exc: + raise VmailError(f"cannot inspect {label}: {exc}") from exc + validate_regular_metadata( + metadata, + label, + private=private, + executable=executable, + ) + + +def validate_domain(value: str, source: str) -> str: + domain = value.strip().lower() + labels = domain.split(".") + if ( + len(domain) > 253 + or len(labels) < 2 + or any(not DOMAIN_LABEL_RE.fullmatch(label) for label in labels) + ): + raise VmailError(f"{source}: invalid domain {domain!r}") + return domain + + +def validate_folders(folders: tuple[str, ...], source: str) -> None: + seen: set[str] = set() + for folder in folders: + if ( + folder in {".", ".."} + or folder.startswith("-") + or "/" in folder + or any(ord(character) < 32 or ord(character) == 127 for character in folder) + ): + raise VmailError(f"{source}: invalid standard folder name {folder!r}") + if folder in seen: + raise VmailError(f"{source}: duplicate standard folder name {folder!r}") + seen.add(folder) + + +def validate_secure_directory( + path: Path, + label: str, + *, + private: bool, + allow_sticky_world_writable: bool = False, +) -> None: + """Reject directories in which an untrusted user could replace a file.""" + try: + metadata = path.lstat() + except OSError as exc: + raise VmailError(f"cannot inspect {label}: {exc}") from exc + mode = stat.S_IMODE(metadata.st_mode) + if not stat.S_ISDIR(metadata.st_mode): + raise VmailError(f"{label} must be a directory, not a symlink or special file") + if metadata.st_uid != 0: + raise VmailError(f"{label} must be owned by root") + if private and mode & 0o077: + raise VmailError(f"{label} must be accessible only to root ({mode:04o})") + if not private: + if mode & 0o002 and not ( + allow_sticky_world_writable and metadata.st_mode & stat.S_ISVTX + ): + raise VmailError(f"{label} is unsafely world writable ({mode:04o})") + if mode & 0o020 and metadata.st_gid != 0: + raise VmailError(f"{label} is writable by a non-root group ({mode:04o})") + + +def ensure_secure_backup_directory(path: Path) -> None: + if not path.exists(): + validate_secure_directory(path.parent, f"backup parent {path.parent}", private=False) + try: + path.mkdir(mode=0o700) + except OSError as exc: + raise VmailError(f"cannot create backup directory {path}: {exc}") from exc + validate_secure_directory(path, f"backup directory {path}", private=True) + + +def validate_mail_domain_root(settings: Settings) -> None: + path = settings.domain_mail_root + try: + metadata = path.lstat() + except OSError as exc: + raise VmailError(f"cannot inspect mail domain root {path}: {exc}") from exc + mode = stat.S_IMODE(metadata.st_mode) + if not stat.S_ISDIR(metadata.st_mode): + raise VmailError(f"mail domain root {path} must be a real directory") + if metadata.st_uid not in {0, settings.uid} or metadata.st_gid not in {0, settings.gid}: + raise VmailError( + f"mail domain root {path} must be owned by root or virtual-mail " + f"UID/GID {settings.uid}:{settings.gid}" + ) + if mode & 0o002: + raise VmailError(f"mail domain root {path} must not be world writable ({mode:04o})") + if mode & 0o020 and metadata.st_gid not in {0, settings.gid}: + raise VmailError(f"mail domain root {path} has an unsafe writable group ({mode:04o})") + + +def validate_runtime_security(settings: Settings, *, modifying: bool) -> None: + """Validate every configured path before reading or changing live state.""" + checked_directories: set[Path] = set() + for path in ( + settings.dovecot_users, + settings.postfix_mailboxes, + settings.postfix_aliases, + ): + if path.parent not in checked_directories: + validate_secure_directory( + path.parent, + f"managed-file directory {path.parent}", + private=False, + ) + checked_directories.add(path.parent) + validate_root_owned_regular( + settings.dovecot_users, + f"Dovecot users file {settings.dovecot_users}", + ) + for source in (settings.postfix_mailboxes, settings.postfix_aliases): + validate_root_owned_regular(source, f"Postfix source map {source}") + database = Path(str(source) + ".db") + validate_root_owned_regular(database, f"Postfix compiled map {database}") + validate_mail_domain_root(settings) + if modifying: + ensure_secure_backup_directory(settings.backup_dir) + + +def read_text(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + raise VmailError(f"cannot read {path}: {exc}") from exc + + +def parse_passwd(text: str, source: str) -> dict[str, PasswdEntry]: + entries: dict[str, PasswdEntry] = {} + for number, raw in enumerate(text.splitlines(), 1): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + fields = tuple(raw.split(":")) + if len(fields) != 8: + raise VmailError(f"{source}:{number}: expected 8 colon-separated fields") + address = fields[0].lower() + if not address or "@" not in address: + raise VmailError(f"{source}:{number}: invalid login field") + if address in entries: + raise VmailError(f"{source}:{number}: duplicate login {address}") + entries[address] = PasswdEntry(number, address, fields) + return entries + + +def parse_map(text: str, source: str) -> dict[str, tuple[int, str]]: + entries: dict[str, tuple[int, str]] = {} + for number, raw in enumerate(text.splitlines(), 1): + stripped = raw.strip() + if not stripped or stripped.startswith("#"): + continue + fields = stripped.split() + if len(fields) != 2: + raise VmailError(f"{source}:{number}: expected exactly two whitespace-separated fields") + address = fields[0].lower() + if address in entries: + raise VmailError(f"{source}:{number}: duplicate map key {address}") + entries[address] = (number, fields[1]) + return entries + + +def append_line(text: str, line: str) -> str: + if text and not text.endswith("\n"): + text += "\n" + return text + line + "\n" + + +def normalize_localpart(value: str, domain: str) -> tuple[str, str]: + candidate = value.strip().lower() + if "@" in candidate: + if candidate.count("@") != 1: + raise VmailError(f"invalid address: {value!r}") + localpart, supplied_domain = candidate.rsplit("@", 1) + if supplied_domain != domain: + raise VmailError(f"only the {domain} domain is managed") + else: + localpart = candidate + if ( + not LOCALPART_RE.fullmatch(localpart) + or localpart.endswith(".") + or ".." in localpart + or "+" in localpart + ): + raise VmailError( + "local part must be lowercase letters, digits, dots, underscores or hyphens; " + "it cannot end in a dot or contain consecutive dots" + ) + return localpart, f"{localpart}@{domain}" + + +def run( + argv: Iterable[os.PathLike[str] | str], + *, + input_text: str | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + """Run a fixed argument vector without invoking a shell.""" + command = [os.fspath(item) for item in argv] + result = subprocess.run( + command, + input=input_text, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if check and result.returncode != 0: + detail = result.stderr.strip() or result.stdout.strip() or f"exit {result.returncode}" + raise VmailError(f"{command[0]} failed: {detail}") + return result + + +def prompt_password() -> str: + first = getpass.getpass("New mailbox password: ") + second = getpass.getpass("Confirm password: ") + if first != second: + raise VmailError("passwords do not match") + if len(first) < 12: + raise VmailError("password must contain at least 12 characters") + if any(char in first for char in ("\n", "\r", "\x00")): + raise VmailError("password contains an unsupported control character") + return first + + +def generate_password(length: int = 24) -> str: + return "".join(secrets.choice(GENERATED_PASSWORD_ALPHABET) for _ in range(length)) + + +def hash_password(settings: Settings, password: str) -> str: + result = run( + [settings.doveadm, "pw", "-s", settings.hash_scheme], + input_text=f"{password}\n{password}\n", + ) + expected_prefix = "{" + settings.hash_scheme + "}" + hashes = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if len(hashes) != 1 or not hashes[0].startswith(expected_prefix): + raise VmailError(f"doveadm did not return a {expected_prefix} password hash") + return hashes[0] + + +def write_temp_like(path: Path, content: str) -> Path: + """Write and sync a same-directory replacement with live-file metadata. + + Keeping the temporary file on the destination filesystem makes the later + os.replace operation atomic. + """ + try: + metadata = path.stat() + except OSError as exc: + raise VmailError(f"cannot stat {path}: {exc}") from exc + fd, name = tempfile.mkstemp(prefix=f".{path.name}.vmailctl.", dir=path.parent) + temp_path = Path(name) + try: + os.fchmod(fd, stat.S_IMODE(metadata.st_mode)) + os.fchown(fd, metadata.st_uid, metadata.st_gid) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + fd = -1 + handle.write(content) + handle.flush() + os.fsync(handle.fileno()) + except (Exception, KeyboardInterrupt): + if fd >= 0: + os.close(fd) + temp_path.unlink(missing_ok=True) + raise + return temp_path + + +def fsync_directory(path: Path) -> None: + """Persist directory-entry changes after an atomic rename.""" + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def replace_text(path: Path, content: str) -> None: + """Atomically replace a text file while preserving its ownership and mode.""" + temp_path = write_temp_like(path, content) + try: + os.replace(temp_path, path) + fsync_directory(path.parent) + finally: + temp_path.unlink(missing_ok=True) + + +@dataclass +class PreparedMap: + """A compiled Postfix source/database pair awaiting atomic renames.""" + + source: Path + database: Path + + def cleanup(self) -> None: + self.source.unlink(missing_ok=True) + self.database.unlink(missing_ok=True) + + +def prepare_map(settings: Settings, path: Path, content: str) -> PreparedMap: + """Compile a candidate Postfix map without touching the live pair.""" + temp_source = write_temp_like(path, content) + temp_database = Path(str(temp_source) + ".db") + try: + run([settings.postmap, temp_source]) + if not temp_database.is_file(): + raise VmailError(f"postmap did not create {temp_database}") + existing_database = Path(str(path) + ".db") + if existing_database.exists(): + metadata = existing_database.stat() + os.chmod(temp_database, stat.S_IMODE(metadata.st_mode)) + os.chown(temp_database, metadata.st_uid, metadata.st_gid) + return PreparedMap(temp_source, temp_database) + except (Exception, KeyboardInterrupt): + temp_source.unlink(missing_ok=True) + temp_database.unlink(missing_ok=True) + raise + + +def commit_map(path: Path, prepared: PreparedMap) -> None: + """Install a prepared source and database pair using atomic renames.""" + database = Path(str(path) + ".db") + try: + os.replace(prepared.source, path) + os.replace(prepared.database, database) + fsync_directory(path.parent) + finally: + prepared.cleanup() + + +@contextmanager +def exclusive_lock(path: Path): + """Serialize operations with a root-only, symlink-resistant advisory lock.""" + validate_secure_directory( + path.parent, + f"lock directory {path.parent}", + private=False, + allow_sticky_world_writable=True, + ) + flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0) + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(path, flags, 0o600) + except OSError as exc: + raise VmailError(f"cannot open lock file {path}: {exc}") from exc + try: + metadata = os.fstat(descriptor) + validate_regular_metadata( + metadata, + f"lock file {path}", + private=True, + ) + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w") as handle: + descriptor = -1 + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + yield + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + finally: + if descriptor >= 0: + os.close(descriptor) + + +class BackupSet: + """Root-only snapshots used to roll a transaction back.""" + + def __init__(self, settings: Settings, action: str, paths: Iterable[Path]): + ensure_secure_backup_directory(settings.backup_dir) + timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + safe_action = re.sub(r"[^a-zA-Z0-9_.-]+", "-", action).strip("-") + destination = settings.backup_dir / f"{timestamp}-{safe_action}-{os.getpid()}" + destination.mkdir(mode=0o700) + self.destination = destination + self.records: list[tuple[Path, Path | None]] = [] + for original in paths: + backup_name = original.as_posix().lstrip("/").replace("/", "__") + backup = destination / backup_name + if original.exists(): + shutil.copy2(original, backup) + self.records.append((original, backup)) + else: + self.records.append((original, None)) + manifest = destination / "manifest.txt" + manifest.write_text( + f"created_utc={timestamp}\naction={action}\n" + + "".join(f"path={original}\n" for original, _ in self.records), + encoding="utf-8", + ) + os.chmod(manifest, 0o600) + + def restore(self) -> None: + """Restore every recorded path, continuing after individual failures.""" + errors: list[str] = [] + for original, backup in self.records: + try: + if backup is None: + if original.exists(): + quarantine = self.destination / f"created-{original.name}" + os.replace(original, quarantine) + else: + temp = write_temp_like(original, backup.read_text(encoding="utf-8")) if not original.name.endswith(".db") else None + if temp is not None: + os.replace(temp, original) + else: + restore_temp = original.parent / f".{original.name}.restore-{os.getpid()}" + shutil.copy2(backup, restore_temp) + os.replace(restore_temp, original) + fsync_directory(original.parent) + except BaseException as exc: # Best effort across all transaction files. + errors.append(f"{original}: {exc}") + if errors: + raise VmailError("rollback was incomplete: " + "; ".join(errors)) + + +def make_maildir(settings: Settings, localpart: str) -> Path: + home = settings.mailbox_home(localpart) + if home.exists(): + raise VmailError(f"mailbox home already exists: {home}") + for directory in (home, home / "Maildir", home / "Maildir" / "cur", home / "Maildir" / "new", home / "Maildir" / "tmp"): + directory.mkdir(mode=settings.directory_mode) + os.chown(directory, settings.uid, settings.gid) + os.chmod(directory, settings.directory_mode) + return home + + +def normalize_new_maildir_permissions(settings: Settings, home: Path) -> None: + for item in [home, *home.rglob("*")]: + if item.is_symlink(): + raise VmailError(f"unexpected symlink in newly created mailbox: {item}") + os.chown(item, settings.uid, settings.gid) + if item.is_dir(): + os.chmod(item, settings.directory_mode) + elif item.is_file(): + os.chmod(item, 0o600) + + +def quarantine_failed_mailhome(home: Path, backup: BackupSet) -> None: + """Preserve a partially created Maildir for inspection instead of deleting it.""" + if not home.exists(): + return + destination = backup.destination / "rolled-back-mailhome" + if destination.exists(): + destination = backup.destination / f"rolled-back-mailhome-{os.getpid()}" + shutil.move(os.fspath(home), os.fspath(destination)) + + +def verify_postfix(settings: Settings) -> None: + run([settings.postfix, "check"]) + + +def verify_dovecot(settings: Settings) -> None: + run([settings.doveconf, "-n"]) + + +def query_map(settings: Settings, path: Path, key: str) -> str: + result = run([settings.postmap, "-q", key, f"hash:{path}"]) + return result.stdout.strip() + + +def new_passwd_line(settings: Settings, localpart: str, address: str, password_hash: str) -> str: + home = settings.mailbox_home(localpart) + return ( + f"{address}:{password_hash}:{settings.uid}:{settings.gid}::{home}::" + "userdb_mail_path=~/Maildir" + ) + + +def load_live_state(settings: Settings): + passwd_text = read_text(settings.dovecot_users) + mailbox_text = read_text(settings.postfix_mailboxes) + alias_text = read_text(settings.postfix_aliases) + passwd = parse_passwd(passwd_text, os.fspath(settings.dovecot_users)) + mailboxes = parse_map(mailbox_text, os.fspath(settings.postfix_mailboxes)) + aliases = parse_map(alias_text, os.fspath(settings.postfix_aliases)) + return passwd_text, mailbox_text, alias_text, passwd, mailboxes, aliases + + +def ensure_address_available(address: str, passwd, mailboxes, aliases) -> None: + locations = [] + if address in passwd: + locations.append("Dovecot users") + if address in mailboxes: + locations.append("Postfix mailboxes") + if address in aliases: + locations.append("Postfix aliases") + if locations: + raise VmailError(f"{address} already exists in {', '.join(locations)}") + + +def mailbox_add(settings: Settings, value: str, dry_run: bool, use_generated_password: bool) -> None: + """Create a login, delivery map and Maildir as one recoverable transaction.""" + localpart, address = normalize_localpart(value, settings.domain) + state = load_live_state(settings) + ensure_address_available(address, state[3], state[4], state[5]) + if dry_run: + print(f"DRY RUN: would create mailbox {address}") + print(f"DRY RUN: would create {settings.mailbox_home(localpart) / 'Maildir'}") + print(f"DRY RUN: would create and subscribe folders: {', '.join(settings.folders)}") + return + + password = generate_password() if use_generated_password else prompt_password() + password_hash = hash_password(settings, password) + backup: BackupSet | None = None + home: Path | None = None + with exclusive_lock(settings.lock_file): + passwd_text, mailbox_text, _, passwd, mailboxes, aliases = load_live_state(settings) + ensure_address_available(address, passwd, mailboxes, aliases) + backup = BackupSet( + settings, + f"mailbox-add-{address}", + ( + settings.dovecot_users, + settings.postfix_mailboxes, + Path(str(settings.postfix_mailboxes) + ".db"), + ), + ) + new_passwd = append_line( + passwd_text, + new_passwd_line(settings, localpart, address, password_hash), + ) + new_mailboxes = append_line( + mailbox_text, + f"{address}\t{settings.mailbox_target(localpart)}", + ) + prepared = prepare_map(settings, settings.postfix_mailboxes, new_mailboxes) + try: + # From this point, any failure or terminal interrupt must restore + # the files and quarantine a Maildir that was already created. + home = settings.mailbox_home(localpart) + home = make_maildir(settings, localpart) + replace_text(settings.dovecot_users, new_passwd) + commit_map(settings.postfix_mailboxes, prepared) + verify_dovecot(settings) + verify_postfix(settings) + result = run([settings.doveadm, "user", address]) + if result.returncode != 0: + raise VmailError(f"Dovecot cannot resolve {address}") + expected = settings.mailbox_target(localpart) + if query_map(settings, settings.postfix_mailboxes, address) != expected: + raise VmailError(f"Postfix mailbox lookup failed for {address}") + if settings.folders: + run([settings.doveadm, "mailbox", "create", "-u", address, "-s", *settings.folders]) + normalize_new_maildir_permissions(settings, home) + except (Exception, KeyboardInterrupt) as exc: + prepared.cleanup() + rollback_error = None + try: + backup.restore() + if home is not None: + quarantine_failed_mailhome(home, backup) + except BaseException as rollback_exc: + rollback_error = rollback_exc + if rollback_error: + raise VmailError(f"{exc}; additionally, rollback failed: {rollback_error}") from exc + if isinstance(exc, KeyboardInterrupt): + raise + raise VmailError(f"{exc}; changes were rolled back to {backup.destination}") from exc + + print(f"Created mailbox {address}") + print(f"Backup: {backup.destination}") + if use_generated_password: + print(f"Generated password (shown once): {password}") + + +def mailbox_password(settings: Settings, value: str, dry_run: bool, use_generated_password: bool) -> None: + """Change one password hash with validation and rollback.""" + _, address = normalize_localpart(value, settings.domain) + state = load_live_state(settings) + if address not in state[3]: + raise VmailError(f"unknown Dovecot mailbox: {address}") + if dry_run: + print(f"DRY RUN: would change the password for {address}") + return + + password = generate_password() if use_generated_password else prompt_password() + password_hash = hash_password(settings, password) + backup: BackupSet | None = None + with exclusive_lock(settings.lock_file): + passwd_text = read_text(settings.dovecot_users) + entries = parse_passwd(passwd_text, os.fspath(settings.dovecot_users)) + if address not in entries: + raise VmailError(f"unknown Dovecot mailbox: {address}") + output: list[str] = [] + for raw in passwd_text.splitlines(): + if raw.strip() and not raw.lstrip().startswith("#") and raw.split(":", 1)[0].lower() == address: + fields = raw.split(":") + fields[1] = password_hash + raw = ":".join(fields) + output.append(raw) + new_text = "\n".join(output) + ("\n" if passwd_text.endswith("\n") or output else "") + backup = BackupSet(settings, f"mailbox-passwd-{address}", (settings.dovecot_users,)) + try: + replace_text(settings.dovecot_users, new_text) + verify_dovecot(settings) + run([settings.doveadm, "user", address]) + except (Exception, KeyboardInterrupt) as exc: + try: + backup.restore() + except BaseException as rollback_exc: + raise VmailError(f"{exc}; additionally, rollback failed: {rollback_exc}") from exc + if isinstance(exc, KeyboardInterrupt): + raise + raise VmailError(f"{exc}; change was rolled back to {backup.destination}") from exc + + print(f"Changed password for {address}") + print(f"Backup: {backup.destination}") + if use_generated_password: + print(f"Generated password (shown once): {password}") + + +def mailbox_list(settings: Settings) -> None: + passwd = parse_passwd(read_text(settings.dovecot_users), os.fspath(settings.dovecot_users)) + for address in sorted(passwd): + print(address) + print(f"{len(passwd)} mailbox login(s)") + + +def alias_add(settings: Settings, alias_value: str, target_value: str, dry_run: bool) -> None: + """Add and verify one Postfix alias as a recoverable transaction.""" + _, alias_address = normalize_localpart(alias_value, settings.domain) + target_localpart, target_address = normalize_localpart(target_value, settings.domain) + state = load_live_state(settings) + ensure_address_available(alias_address, state[3], state[4], state[5]) + expected_target = settings.mailbox_target(target_localpart) + if target_address not in state[4] or state[4][target_address][1] != expected_target: + raise VmailError(f"alias target is not a real mailbox: {target_address}") + if dry_run: + print(f"DRY RUN: would create alias {alias_address} -> {target_address}") + return + + backup: BackupSet | None = None + with exclusive_lock(settings.lock_file): + _, _, alias_text, passwd, mailboxes, aliases = load_live_state(settings) + ensure_address_available(alias_address, passwd, mailboxes, aliases) + if target_address not in mailboxes or mailboxes[target_address][1] != expected_target: + raise VmailError(f"alias target is not a real mailbox: {target_address}") + backup = BackupSet( + settings, + f"alias-add-{alias_address}", + (settings.postfix_aliases, Path(str(settings.postfix_aliases) + ".db")), + ) + new_aliases = append_line(alias_text, f"{alias_address}\t{target_address}") + prepared = prepare_map(settings, settings.postfix_aliases, new_aliases) + try: + commit_map(settings.postfix_aliases, prepared) + verify_postfix(settings) + if query_map(settings, settings.postfix_aliases, alias_address) != target_address: + raise VmailError(f"Postfix alias lookup failed for {alias_address}") + except (Exception, KeyboardInterrupt) as exc: + prepared.cleanup() + try: + backup.restore() + except BaseException as rollback_exc: + raise VmailError(f"{exc}; additionally, rollback failed: {rollback_exc}") from exc + if isinstance(exc, KeyboardInterrupt): + raise + raise VmailError(f"{exc}; change was rolled back to {backup.destination}") from exc + + print(f"Created alias {alias_address} -> {target_address}") + print(f"Backup: {backup.destination}") + + +def alias_list(settings: Settings) -> None: + aliases = parse_map(read_text(settings.postfix_aliases), os.fspath(settings.postfix_aliases)) + for address in sorted(aliases): + print(f"{address} -> {aliases[address][1]}") + print(f"{len(aliases)} alias(es)") + + +def audit(settings: Settings) -> int: + """Cross-check files, Maildirs, service configuration and live lookups.""" + errors: list[str] = [] + warnings: list[str] = [] + infos: list[str] = [] + try: + _, _, _, passwd, mailboxes, aliases = load_live_state(settings) + except VmailError as exc: + print(f"ERROR: {exc}") + return 2 + + for path in (settings.dovecot_users, settings.postfix_mailboxes, settings.postfix_aliases): + try: + metadata = path.stat() + mode = stat.S_IMODE(metadata.st_mode) + if mode & 0o022: + errors.append(f"{path} is group/world writable ({mode:04o})") + if path == settings.dovecot_users and mode & 0o007: + errors.append(f"{path} is accessible to other users ({mode:04o})") + except OSError as exc: + errors.append(f"cannot stat {path}: {exc}") + + collisions = sorted(set(mailboxes) & set(aliases)) + for address in collisions: + alias_target = aliases[address][1].lower() + direct_target = mailboxes[address][1] + if alias_target in mailboxes and mailboxes[alias_target][1] == direct_target: + infos.append( + f"{address} has consistent alias and direct-delivery compatibility mappings" + ) + else: + errors.append( + f"{address} exists in both mailbox and alias maps with conflicting destinations" + ) + + for address, entry in passwd.items(): + try: + localpart, _ = normalize_localpart(address, settings.domain) + except VmailError as exc: + errors.append(f"{settings.dovecot_users}:{entry.line_number}: {exc}") + continue + fields = entry.fields + if fields[2] != str(settings.uid) or fields[3] != str(settings.gid): + errors.append(f"{address} has unexpected uid/gid {fields[2]}:{fields[3]}") + expected_home = os.fspath(settings.mailbox_home(localpart)) + if fields[5] != expected_home: + errors.append(f"{address} has unexpected home {fields[5]}") + if fields[7] != "userdb_mail_path=~/Maildir": + errors.append(f"{address} has unexpected Dovecot extra fields") + expected_target = settings.mailbox_target(localpart) + if address not in mailboxes: + errors.append(f"{address} is missing from the Postfix mailbox map") + elif mailboxes[address][1] != expected_target: + errors.append(f"{address} has unexpected Postfix target {mailboxes[address][1]}") + home = settings.mailbox_home(localpart) + maildir = home / "Maildir" + if not maildir.is_dir(): + errors.append(f"{address} has no Maildir at {maildir}") + else: + metadata = maildir.stat() + if metadata.st_uid != settings.uid or metadata.st_gid != settings.gid: + warnings.append( + f"{maildir} is owned by {metadata.st_uid}:{metadata.st_gid}, " + f"expected {settings.uid}:{settings.gid}" + ) + + for address, (_, target) in aliases.items(): + if target.lower() == address: + errors.append(f"{address} is a direct alias loop") + + for source in (settings.postfix_mailboxes, settings.postfix_aliases): + database = Path(str(source) + ".db") + if not database.is_file(): + errors.append(f"compiled Postfix map is missing: {database}") + elif database.stat().st_mtime < source.stat().st_mtime: + warnings.append(f"compiled Postfix map is older than its source: {database}") + + for label, command in ( + ("Postfix configuration", [settings.postfix, "check"]), + ("Dovecot configuration", [settings.doveconf, "-n"]), + ): + result = run(command, check=False) + if result.returncode != 0: + errors.append(f"{label} validation failed") + + for address, (_, target) in mailboxes.items(): + try: + if query_map(settings, settings.postfix_mailboxes, address) != target: + errors.append(f"Postfix mailbox lookup mismatch for {address}") + except VmailError as exc: + errors.append(f"Postfix mailbox lookup failed for {address}: {exc}") + for address, (_, target) in aliases.items(): + try: + if query_map(settings, settings.postfix_aliases, address) != target: + errors.append(f"Postfix alias lookup mismatch for {address}") + except VmailError as exc: + errors.append(f"Postfix alias lookup failed for {address}: {exc}") + for address in passwd: + result = run([settings.doveadm, "user", address], check=False) + if result.returncode != 0: + errors.append(f"Dovecot user lookup failed for {address}") + + delivery_only = len(set(mailboxes) - set(passwd)) + print( + f"Checked {len(passwd)} mailbox login(s), {len(mailboxes)} delivery mapping(s), " + f"{len(aliases)} alias(es)" + ) + if delivery_only: + print(f"INFO: {delivery_only} delivery mapping(s) intentionally have no mailbox login") + for information in infos: + print(f"INFO: {information}") + for warning in warnings: + print(f"WARNING: {warning}") + for error in errors: + print(f"ERROR: {error}") + if errors: + print(f"Audit failed with {len(errors)} error(s) and {len(warnings)} warning(s)") + return 2 + print(f"Audit passed with {len(warnings)} warning(s)") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="vmailctl", + description="Manage flat-file Postfix/Dovecot virtual mailboxes safely.", + ) + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG) + parser.add_argument("--dry-run", action="store_true", help="show a planned change without writing") + parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}") + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("audit", help="validate account files, maps, paths and lookups") + + mailbox = commands.add_parser("mailbox", help="manage mailbox logins") + mailbox_commands = mailbox.add_subparsers(dest="mailbox_command", required=True) + mailbox_commands.add_parser("list", help="list mailbox logins") + mailbox_add_parser = mailbox_commands.add_parser("add", help="create a mailbox") + mailbox_add_parser.add_argument("address") + mailbox_add_parser.add_argument( + "--generate-password", + action="store_true", + help="generate a strong password and display it once after success", + ) + mailbox_password_parser = mailbox_commands.add_parser("passwd", help="change a mailbox password") + mailbox_password_parser.add_argument("address") + mailbox_password_parser.add_argument( + "--generate-password", + action="store_true", + help="generate a strong password and display it once after success", + ) + + alias = commands.add_parser("alias", help="manage aliases") + alias_commands = alias.add_subparsers(dest="alias_command", required=True) + alias_commands.add_parser("list", help="list aliases") + alias_add_parser = alias_commands.add_parser("add", help="create an alias to a real mailbox") + alias_add_parser.add_argument("alias") + alias_add_parser.add_argument("target") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + require_root() + settings = Settings.load(args.config) + modifying = ( + not args.dry_run + and ( + args.command == "mailbox" + and args.mailbox_command in {"add", "passwd"} + or args.command == "alias" + and args.alias_command == "add" + ) + ) + validate_runtime_security(settings, modifying=modifying) + if args.command == "audit": + if args.dry_run: + raise VmailError("--dry-run is not meaningful with audit") + with exclusive_lock(settings.lock_file): + return audit(settings) + if args.command == "mailbox": + if args.mailbox_command == "list": + if args.dry_run: + raise VmailError("--dry-run is not meaningful with mailbox list") + mailbox_list(settings) + elif args.mailbox_command == "add": + mailbox_add(settings, args.address, args.dry_run, args.generate_password) + elif args.mailbox_command == "passwd": + mailbox_password(settings, args.address, args.dry_run, args.generate_password) + elif args.command == "alias": + if args.alias_command == "list": + if args.dry_run: + raise VmailError("--dry-run is not meaningful with alias list") + alias_list(settings) + elif args.alias_command == "add": + alias_add(settings, args.alias, args.target, args.dry_run) + return 0 + except VmailError as exc: + print(f"vmailctl: {exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("vmailctl: cancelled", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vmailctl.conf.example b/vmailctl.conf.example new file mode 100644 index 0000000..72886aa --- /dev/null +++ b/vmailctl.conf.example @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 Eric Ireland +# SPDX-License-Identifier: GPL-3.0-or-later + +[vmailctl] +# Adjust these values to match the existing Postfix/Dovecot installation. +# All configured files and commands must be real root-owned files in trusted +# directories; symbolic links and group/world-writable files are rejected. +domain = example.com +dovecot_users = /etc/dovecot/virtual-users +postfix_mailboxes = /etc/postfix/vmailbox +postfix_aliases = /etc/postfix/virtual +mail_root = /var/mail/vhosts +uid = 5000 +gid = 5000 +hash_scheme = ARGON2ID +folders = Archive,Drafts,Sent,Trash,Junk +backup_dir = /var/backups/vmailctl +lock_file = /run/lock/vmailctl.lock +postmap = /usr/sbin/postmap +postfix = /usr/sbin/postfix +doveadm = /usr/bin/doveadm +doveconf = /usr/bin/doveconf +directory_mode = 0700