Deploy a fresh Stalwart and ihasmail, linked, in one command
deploy stands up Stalwart 0.16, ihasmail and (for a mail host) Caddy as a compose project: completes Stalwart's bootstrap over x:Bootstrap, links ihasmail over the private network, requests certificates for both Caddy (TLS-ALPN-01) and Stalwart (HTTP-01 through Caddy), makes the auto-ban safe behind the proxy, and proves the link by signing in through the webmail. --local gives a loopback-only pair. certs retries Stalwart's certificate; destroy removes a deployment. e2e/public.sh runs the whole mail-host path against Pebble with no internet involved.
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# Binaries
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
/ihasmail-oneshot
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
*.out
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# A deployment directory written by a local run: it holds secrets.
|
||||||
|
/ihasmail-*/
|
||||||
|
/e2e/work/
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# ihasmail-oneshot
|
||||||
|
|
||||||
|
One command that stands up a fresh [Stalwart](https://stalw.art) mail server
|
||||||
|
and a fresh [ihasmail](https://github.com/Coffey-Labs/ihasmail) webmail on a
|
||||||
|
Docker host, already linked to each other:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ihasmail-oneshot deploy --domain example.com --user alice
|
||||||
|
```
|
||||||
|
|
||||||
|
It writes a deployment directory, starts Stalwart, completes Stalwart's setup
|
||||||
|
wizard over its API, brings up ihasmail and Caddy, wires them together, requests
|
||||||
|
certificates, signs in through the webmail to prove the link, and hands you the
|
||||||
|
administrator password and the DNS records to publish. Afterwards it is an
|
||||||
|
ordinary `docker compose` project you manage with the usual commands.
|
||||||
|
|
||||||
|
It only ever deploys **fresh**: it refuses a directory with files in it, and a
|
||||||
|
compose project that already has containers or volumes.
|
||||||
|
|
||||||
|
## Two shapes
|
||||||
|
|
||||||
|
| | `deploy --domain example.com` | `deploy --local` |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| For | A mail host on the internet | Trying ihasmail against a real Stalwart |
|
||||||
|
| Containers | Stalwart, ihasmail, Caddy | Stalwart, ihasmail |
|
||||||
|
| Published | 25, 465, 993, 995, 4190, 80, 443 on every interface | Nothing but two loopback ports |
|
||||||
|
| Certificates | Let's Encrypt, for Caddy and for Stalwart | None |
|
||||||
|
| Webmail | `https://webmail.example.com` | `http://127.0.0.1:8080` |
|
||||||
|
| Stalwart admin | `https://mail.example.com/admin` | `http://127.0.0.1:8081/admin` |
|
||||||
|
|
||||||
|
Both also bind ihasmail to `127.0.0.1:8080` and Stalwart's plain-HTTP port to
|
||||||
|
`127.0.0.1:8081`, for looking at either directly from the host.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Linux with Docker Engine and the compose plugin, as a user allowed to run
|
||||||
|
`docker`.
|
||||||
|
- Go 1.26 or newer to build it (there are no release binaries yet).
|
||||||
|
|
||||||
|
For a mail host, also:
|
||||||
|
|
||||||
|
- A domain whose DNS you control.
|
||||||
|
- Ports 25, 80, 443, 465, 993, 995 and 4190 free on the host and open in any
|
||||||
|
firewall in front of it.
|
||||||
|
- **Outbound port 25.** Many hosting providers block it until you ask.
|
||||||
|
- **Reverse DNS** (a PTR record, set at the hosting provider) for the host's
|
||||||
|
address, naming the mail host.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Coffey-Labs/ihasmail-oneshot.git
|
||||||
|
cd ihasmail-oneshot
|
||||||
|
go build -o ihasmail-oneshot ./cmd/ihasmail-oneshot
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the binary to the Docker host and run it there. It needs no other files.
|
||||||
|
|
||||||
|
## Deploying a mail host
|
||||||
|
|
||||||
|
Point DNS at the host first, so certificates can be issued on the first run:
|
||||||
|
|
||||||
|
```
|
||||||
|
mail.example.com. A <the host's IPv4 address>
|
||||||
|
webmail.example.com. A <the host's IPv4 address>
|
||||||
|
```
|
||||||
|
|
||||||
|
(AAAA records too, if the host has IPv6.) Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ihasmail-oneshot deploy --domain example.com --email [email protected] --user alice --user bob
|
||||||
|
```
|
||||||
|
|
||||||
|
It lists what it will do, checks the host, and asks before changing anything;
|
||||||
|
`--yes` skips the question, and is required when there is no terminal to ask
|
||||||
|
on. At the end:
|
||||||
|
|
||||||
|
- **`credentials.txt`** holds the Stalwart administrator (`[email protected]`)
|
||||||
|
and a generated password for each `--user`. Readable only by you. The
|
||||||
|
administrator can sign in to the webmail, where it gets the Administration
|
||||||
|
menu, and to Stalwart's own admin UI.
|
||||||
|
- **`dns-records.zone`** holds every record Stalwart wants published: MX, SPF,
|
||||||
|
DKIM, DMARC, the SRV records, MTA-STS, and the autoconfig names. Publish all
|
||||||
|
of them.
|
||||||
|
|
||||||
|
If DNS did not point at the host yet, the webmail still comes up and Caddy
|
||||||
|
keeps retrying its certificates by itself. Stalwart does not retry a failed
|
||||||
|
order, so once DNS is right:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ihasmail-oneshot certs --dir ihasmail-example-com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trying it locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ihasmail-oneshot deploy --local --user alice
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://127.0.0.1:8080` and sign in with a mailbox from
|
||||||
|
`ihasmail-example-test/credentials.txt`. Nothing outside the host can reach
|
||||||
|
it, and no mail can be delivered to it.
|
||||||
|
|
||||||
|
## What it sets up, and why
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────── private network (172.31.253.0/24) ────────────┐
|
||||||
|
443, 80 ──► Caddy .10 ─┼─► ihasmail .11 ──http://stalwart:8080──► Stalwart .12 │
|
||||||
|
│ ▲ │
|
||||||
|
└─── Stalwart's web names ─────────────────────┘ │
|
||||||
|
25, 465, 993, 995, 4190 ────────────────────────────────────────────► Stalwart │
|
||||||
|
└───────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**ihasmail reaches Stalwart over plain HTTP on the private network.** The leg
|
||||||
|
never leaves the host, and the private route is about a third of the memory per
|
||||||
|
signed-in tab of going back out through HTTPS. Stalwart's HTTP port is published
|
||||||
|
on loopback only.
|
||||||
|
|
||||||
|
**ihasmail runs immutable**: read-only root filesystem, no volume, sessions in
|
||||||
|
memory. A restart signs everyone out and loses nothing else, because everything
|
||||||
|
durable, including each user's settings, lives in Stalwart.
|
||||||
|
|
||||||
|
**Stalwart is set up through its bootstrap API, not a template.** Stalwart 0.16
|
||||||
|
keeps its configuration in its data store, and a server with an empty
|
||||||
|
configuration starts in bootstrap mode. The tool starts it with a one-off
|
||||||
|
recovery administrator, completes setup with `x:Bootstrap/set`, and restarts it
|
||||||
|
*without* that credential, so no fixed administrator password outlives the
|
||||||
|
setup. Logging goes to stdout rather than the default log directory, which does
|
||||||
|
not exist in the container.
|
||||||
|
|
||||||
|
**Caddy and Stalwart share ports 80 and 443 without competing.** Both need
|
||||||
|
certificates for Stalwart's names: Caddy to serve its web side over HTTPS,
|
||||||
|
Stalwart for IMAP and SMTP. They are separated by challenge type. Caddy uses
|
||||||
|
TLS-ALPN-01 on 443 for those names and never port 80; Stalwart uses HTTP-01,
|
||||||
|
and Caddy forwards `/.well-known/acme-challenge/` on port 80 to it untouched.
|
||||||
|
Stalwart's certificate covers the mail host plus `autoconfig`, `autodiscover`,
|
||||||
|
`mta-sts` and `ua-auto-config` under the domain, and Caddy fronts all five.
|
||||||
|
|
||||||
|
**Stalwart's auto-ban is made safe for a proxy in front of it.** Stalwart bans
|
||||||
|
an address that probes scanner paths such as `/wp-admin.php`, and counts other
|
||||||
|
abuse per address too. Behind a proxy, that address is the proxy's. So:
|
||||||
|
|
||||||
|
- Stalwart takes client addresses from Caddy's `X-Forwarded-For`. Without it,
|
||||||
|
one scanner bans Caddy, and with it every autoconfig lookup, calendar client
|
||||||
|
and certificate renewal. With it, the scanner is banned and nobody else is.
|
||||||
|
This is safe only because nothing untrusted can reach Stalwart's HTTP port.
|
||||||
|
- Every request the webmail makes arrives from ihasmail's fixed address, which
|
||||||
|
is added to Stalwart's allowed addresses, so no ban can take the webmail down
|
||||||
|
for everybody. ihasmail rate-limits sign-ins per real client itself.
|
||||||
|
|
||||||
|
Neither setting applies to a running Stalwart, so the tool restarts it after
|
||||||
|
making them.
|
||||||
|
|
||||||
|
**Push by subscription.** ihasmail is given `PUSH_URL`, so Stalwart posts
|
||||||
|
changes to it instead of holding a connection open per browser tab. If Stalwart
|
||||||
|
cannot reach that URL, every tab falls back to the relay and nothing breaks;
|
||||||
|
`/api/health` shows which is in use.
|
||||||
|
|
||||||
|
## Afterwards
|
||||||
|
|
||||||
|
The deployment directory is a compose project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ihasmail-example-com
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f stalwart
|
||||||
|
docker compose restart ihasmail
|
||||||
|
```
|
||||||
|
|
||||||
|
To upgrade, change an image tag in `compose.yaml` and run `docker compose up -d`.
|
||||||
|
Read ihasmail's release notes for the Stalwart version it supports before
|
||||||
|
moving Stalwart.
|
||||||
|
|
||||||
|
Data lives in the named volumes `stalwart-etc` and `stalwart-data` (mail,
|
||||||
|
accounts, configuration) and `caddy-data` (certificates and the ACME account).
|
||||||
|
Back those up. `APP_SECRET` in `.env` seals ihasmail's sessions; changing it
|
||||||
|
signs everyone out.
|
||||||
|
|
||||||
|
To remove a deployment and **everything in it**, mail included:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ihasmail-oneshot destroy --dir ihasmail-example-com
|
||||||
|
```
|
||||||
|
|
||||||
|
It removes the containers, network and volumes, and the files the tool wrote. A
|
||||||
|
file you added to the directory yourself is left, and so is the directory.
|
||||||
|
|
||||||
|
## Flags
|
||||||
|
|
||||||
|
`ihasmail-oneshot deploy -h` lists them all.
|
||||||
|
|
||||||
|
| Flag | Default | |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--domain` | required; `example.test` with `--local` | The mail domain |
|
||||||
|
| `--mail-host` | `mail.DOMAIN` | Stalwart's hostname: one label under the domain |
|
||||||
|
| `--webmail-host` | `webmail.DOMAIN` | The webmail's hostname |
|
||||||
|
| `--email` | `postmaster@DOMAIN` | ACME contact address. Use one that does not depend on this server |
|
||||||
|
| `--user` | none | Create a mailbox with a generated password. Repeat for more |
|
||||||
|
| `--local` | off | The loopback-only shape |
|
||||||
|
| `--dir` | `./PROJECT` | Deployment directory: new or empty |
|
||||||
|
| `--project` | `ihasmail-DOMAIN` | Compose project name, dots as dashes |
|
||||||
|
| `--stalwart-image` | `stalwartlabs/stalwart:v0.16.22` | |
|
||||||
|
| `--ihasmail-image` | `ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328` | |
|
||||||
|
| `--caddy-image` | `caddy:2.11.4` | |
|
||||||
|
| `--webmail-bind` | `127.0.0.1:8080` | Host address for ihasmail's own port |
|
||||||
|
| `--stalwart-bind` | `127.0.0.1:8081` | Host address for Stalwart's plain-HTTP port |
|
||||||
|
| `--subnet` | `172.31.253.0/24` | Private network. Change it if it overlaps one you have |
|
||||||
|
| `--acme-directory` | Let's Encrypt | ACME directory of a private CA |
|
||||||
|
| `--acme-ca-root` | none | PEM root the private CA's own HTTPS is signed by |
|
||||||
|
| `--yes` | off | Do not ask for confirmation |
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- **One domain.** More can be added afterwards in Stalwart or in ihasmail's
|
||||||
|
Administration; their certificates and DNS are then yours to arrange.
|
||||||
|
- **IPv4 on the private network.** Ports are published on IPv6 too wherever
|
||||||
|
Docker does so on the host.
|
||||||
|
- **Stalwart does not retry a certificate order that fails**, including on a
|
||||||
|
transient error from the CA. `certs` starts a new one.
|
||||||
|
- **Push by subscription is not covered by the end-to-end test**, which has no
|
||||||
|
public DNS for Stalwart to resolve the webmail's name with. Without it the
|
||||||
|
relay is used, which is the documented fallback.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
e2e/public.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
`e2e/public.sh` deploys a full mail host on the machine it runs on with no
|
||||||
|
internet involved: [Pebble](https://github.com/letsencrypt/pebble) stands in for
|
||||||
|
Let's Encrypt and a DNS stub answers every name with the host's own address. It
|
||||||
|
checks that Stalwart's IMAPS and submissions ports and Caddy's HTTPS all present
|
||||||
|
verified certificates, that users sign in through the webmail over HTTPS, that
|
||||||
|
autoconfig is served, that a scan through Caddy bans the scanner while other
|
||||||
|
clients still get through, and that ihasmail is exempt from bans. It
|
||||||
|
publishes the mail ports while it runs and removes everything when it ends;
|
||||||
|
`KEEP=1` leaves it up to look at.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Command ihasmail-oneshot deploys a fresh Stalwart and a fresh ihasmail on
|
||||||
|
// one Docker host, linked, in one command.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/deploy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// version is set at build time: -ldflags "-X main.version=...".
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
const usageText = `ihasmail-oneshot deploys a fresh Stalwart mail server and a fresh ihasmail
|
||||||
|
webmail on this Docker host, linked together, in one command.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
ihasmail-oneshot deploy --domain example.com [flags] a mail host on the internet
|
||||||
|
ihasmail-oneshot deploy --local [flags] a loopback-only pair to try it
|
||||||
|
ihasmail-oneshot certs --dir DIR retry Stalwart's certificate after fixing DNS
|
||||||
|
ihasmail-oneshot destroy --dir DIR [--yes] remove a deployment and all its data
|
||||||
|
ihasmail-oneshot version
|
||||||
|
|
||||||
|
Run a command with -h for its flags.
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Fprint(os.Stderr, usageText)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "deploy":
|
||||||
|
err = runDeploy(ctx, os.Args[2:])
|
||||||
|
case "certs":
|
||||||
|
err = runCerts(ctx, os.Args[2:])
|
||||||
|
case "destroy":
|
||||||
|
err = runDestroy(ctx, os.Args[2:])
|
||||||
|
case "version", "--version":
|
||||||
|
fmt.Println("ihasmail-oneshot", version)
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
fmt.Print(usageText)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usageText)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, flag.ErrHelp) {
|
||||||
|
fmt.Fprintf(os.Stderr, "!! %v\n", err)
|
||||||
|
}
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stringList []string
|
||||||
|
|
||||||
|
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
||||||
|
func (s *stringList) Set(v string) error { *s = append(*s, v); return nil }
|
||||||
|
|
||||||
|
func runDeploy(ctx context.Context, args []string) error {
|
||||||
|
var o config.Options
|
||||||
|
var yes bool
|
||||||
|
var users stringList
|
||||||
|
fs := flag.NewFlagSet("deploy", flag.ContinueOnError)
|
||||||
|
fs.Usage = func() {
|
||||||
|
fmt.Fprint(fs.Output(), `Usage: ihasmail-oneshot deploy --domain example.com [flags]
|
||||||
|
ihasmail-oneshot deploy --local [flags]
|
||||||
|
|
||||||
|
Without --local this publishes SMTP (25), submissions (465), IMAPS (993),
|
||||||
|
POP3S (995), ManageSieve (4190) and HTTP/HTTPS (80, 443) on every interface,
|
||||||
|
and asks Let's Encrypt for certificates. With --local it publishes nothing
|
||||||
|
but the two loopback addresses below and asks for no certificates.
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
`)
|
||||||
|
fs.PrintDefaults()
|
||||||
|
}
|
||||||
|
fs.BoolVar(&o.Local, "local", false, "loopback-only evaluation pair: no mail ports, no Caddy, no certificates")
|
||||||
|
fs.StringVar(&o.Domain, "domain", "", "mail domain, e.g. example.com (default example.test with --local)")
|
||||||
|
fs.StringVar(&o.MailHost, "mail-host", "", "Stalwart's hostname, one label under the domain (default mail.DOMAIN)")
|
||||||
|
fs.StringVar(&o.WebmailHost, "webmail-host", "", "the webmail's hostname (default webmail.DOMAIN)")
|
||||||
|
fs.StringVar(&o.Email, "email", "", "ACME contact address (default postmaster@DOMAIN)")
|
||||||
|
fs.Var(&users, "user", "create a mailbox with a generated password; repeat for more (e.g. --user alice)")
|
||||||
|
fs.StringVar(&o.Dir, "dir", "", "deployment directory to write, new or empty (default ./PROJECT)")
|
||||||
|
fs.StringVar(&o.Project, "project", "", "compose project name (default ihasmail-DOMAIN, dots as dashes)")
|
||||||
|
fs.StringVar(&o.StalwartImage, "stalwart-image", config.DefaultStalwartImage, "Stalwart image")
|
||||||
|
fs.StringVar(&o.IhasmailImage, "ihasmail-image", config.DefaultIhasmailImage, "ihasmail image")
|
||||||
|
fs.StringVar(&o.CaddyImage, "caddy-image", config.DefaultCaddyImage, "Caddy image")
|
||||||
|
fs.StringVar(&o.WebmailBind, "webmail-bind", "127.0.0.1:8080", "host address for ihasmail's own port")
|
||||||
|
fs.StringVar(&o.StalwartBind, "stalwart-bind", "127.0.0.1:8081", "host address for Stalwart's plain-HTTP port (admin UI)")
|
||||||
|
fs.StringVar(&o.Subnet, "subnet", "172.31.253.0/24", "private network for the stack")
|
||||||
|
fs.StringVar(&o.ACMEDirectory, "acme-directory", "", "ACME directory of a private CA, instead of Let's Encrypt")
|
||||||
|
fs.StringVar(&o.ACMECARoot, "acme-ca-root", "", "PEM root that the private ACME directory's HTTPS is signed by")
|
||||||
|
fs.BoolVar(&yes, "yes", false, "do not ask for confirmation")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fs.NArg() > 0 {
|
||||||
|
return fmt.Errorf("unexpected argument %q", fs.Arg(0))
|
||||||
|
}
|
||||||
|
o.Users = users
|
||||||
|
|
||||||
|
plan, err := o.Validate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log := deploy.Log{W: os.Stdout}
|
||||||
|
|
||||||
|
describe(plan, log)
|
||||||
|
log.Step("preflight")
|
||||||
|
warnings, err := deploy.Preflight(ctx, plan, log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, w := range warnings {
|
||||||
|
log.Warn("%s", w)
|
||||||
|
}
|
||||||
|
if !yes {
|
||||||
|
if err := confirm("deploy this?"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := deploy.Deploy(ctx, plan, version, log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
summarise(plan, res, log)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func describe(p config.Plan, log deploy.Log) {
|
||||||
|
if p.Local {
|
||||||
|
log.Step("a local pair for %s, in %s", p.Domain, p.Dir)
|
||||||
|
log.Info("ihasmail http://%s", p.WebmailBind)
|
||||||
|
log.Info("Stalwart http://%s (admin UI; no mail ports published)", p.StalwartBind)
|
||||||
|
} else {
|
||||||
|
log.Step("a mail host for %s, in %s", p.Domain, p.Dir)
|
||||||
|
log.Info("webmail https://%s", p.WebmailHost)
|
||||||
|
log.Info("Stalwart %s (admin UI at https://%s/admin)", p.MailHost, p.MailHost)
|
||||||
|
log.Info("ports %s on every interface", joinInts(p.PublishedPorts()))
|
||||||
|
if p.ACMEDirectory != "" {
|
||||||
|
log.Info("ACME %s", p.ACMEDirectory)
|
||||||
|
} else {
|
||||||
|
log.Info("ACME Let's Encrypt, contact %s", p.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Info("images %s, %s", p.StalwartImage, p.IhasmailImage)
|
||||||
|
if !p.Local {
|
||||||
|
log.Info(" %s", p.CaddyImage)
|
||||||
|
}
|
||||||
|
if len(p.Users) > 0 {
|
||||||
|
log.Info("mailboxes %s", strings.Join(p.Users, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarise(p config.Plan, r *deploy.Result, log deploy.Log) {
|
||||||
|
log.Step("done")
|
||||||
|
if p.Local {
|
||||||
|
log.Info("webmail http://%s", p.WebmailBind)
|
||||||
|
log.Info("Stalwart http://%s/admin", p.StalwartBind)
|
||||||
|
} else {
|
||||||
|
log.Info("webmail https://%s", p.WebmailHost)
|
||||||
|
log.Info("Stalwart https://%s/admin", p.MailHost)
|
||||||
|
}
|
||||||
|
log.Info("sign in as %s (password in %s/credentials.txt)", r.Admin.Username, p.Dir)
|
||||||
|
if r.AdminInWebmail {
|
||||||
|
log.Info(" the administrator gets the webmail's Administration menu")
|
||||||
|
}
|
||||||
|
for addr := range r.Mailboxes {
|
||||||
|
log.Info("mailbox %s", addr)
|
||||||
|
}
|
||||||
|
if p.Local {
|
||||||
|
log.Info("")
|
||||||
|
log.Info("Nothing can reach this pair from outside the host, and no mail can be")
|
||||||
|
log.Info("delivered to it. Remove it with: ihasmail-oneshot destroy --dir %s", p.Dir)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("DNS %s/dns-records.zone -- publish every record in it", p.Dir)
|
||||||
|
if r.Certificate != nil {
|
||||||
|
log.Info("certificate issued by %s, valid until %s", r.Certificate.Issuer, r.Certificate.NotValidAfter)
|
||||||
|
} else {
|
||||||
|
log.Warn("Stalwart has no certificate yet, so IMAP and SMTP clients will refuse it.")
|
||||||
|
log.Info(" Once %s resolves to this host: ihasmail-oneshot certs --dir %s", p.MailHost, p.Dir)
|
||||||
|
}
|
||||||
|
for _, host := range []string{p.WebmailHost, p.MailHost} {
|
||||||
|
if issuer, ok := r.CaddyCertificates[host]; ok {
|
||||||
|
log.Info("https %s: issued by %s", host, issuer)
|
||||||
|
} else {
|
||||||
|
log.Warn("Caddy has no certificate for %s yet; it keeps retrying by itself once DNS points here.", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Info("")
|
||||||
|
log.Info("Before mail flows: reverse DNS for this host's address should name %s,", p.MailHost)
|
||||||
|
log.Info("and outbound port 25 must be open -- many providers block it until asked.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCerts(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("certs", flag.ContinueOnError)
|
||||||
|
dir := fs.String("dir", "", "deployment directory")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *dir == "" {
|
||||||
|
return errors.New("--dir is required")
|
||||||
|
}
|
||||||
|
return deploy.Certs(ctx, *dir, deploy.Log{W: os.Stdout})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDestroy(ctx context.Context, args []string) error {
|
||||||
|
fs := flag.NewFlagSet("destroy", flag.ContinueOnError)
|
||||||
|
dir := fs.String("dir", "", "deployment directory")
|
||||||
|
yes := fs.Bool("yes", false, "do not ask for confirmation")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *dir == "" {
|
||||||
|
return errors.New("--dir is required")
|
||||||
|
}
|
||||||
|
if !*yes {
|
||||||
|
if err := confirm(fmt.Sprintf("destroy the deployment in %s, with every mailbox and message in it?", *dir)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deploy.Destroy(ctx, *dir, deploy.Log{W: os.Stdout})
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm asks on the terminal, and refuses outright without one: a script
|
||||||
|
// that forgot --yes should stop, not hang or guess.
|
||||||
|
func confirm(question string) error {
|
||||||
|
if fi, err := os.Stdin.Stat(); err != nil || fi.Mode()&os.ModeCharDevice == 0 {
|
||||||
|
return errors.New("no terminal to confirm on; pass --yes if this is what you mean")
|
||||||
|
}
|
||||||
|
fmt.Printf("%s [y/N] ", question)
|
||||||
|
answer, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
switch strings.ToLower(strings.TrimSpace(answer)) {
|
||||||
|
case "y", "yes":
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("aborted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinInts(ns []int) string {
|
||||||
|
s := make([]string, len(ns))
|
||||||
|
for i, n := range ns {
|
||||||
|
s[i] = fmt.Sprint(n)
|
||||||
|
}
|
||||||
|
return strings.Join(s, ", ")
|
||||||
|
}
|
||||||
Executable
+218
@@ -0,0 +1,218 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#
|
||||||
|
# End-to-end test of a public deployment, with no internet involved: Pebble
|
||||||
|
# stands in for Let's Encrypt and a DNS stub answers every name with this
|
||||||
|
# host's address, so both Caddy and Stalwart really get certificates, over the
|
||||||
|
# real ports, through the real Caddyfile.
|
||||||
|
#
|
||||||
|
# It publishes 25, 80, 443, 465, 993, 995 and 4190 on this machine while it
|
||||||
|
# runs, and removes everything it created when it ends, pass or fail.
|
||||||
|
#
|
||||||
|
# Usage: e2e/public.sh (from the repository root; needs docker, curl, openssl, python3)
|
||||||
|
# KEEP=1 e2e/public.sh leave the stack up afterwards, to look at
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
WORK="$ROOT/e2e/work"
|
||||||
|
BIN="$WORK/ihasmail-oneshot"
|
||||||
|
DOMAIN=lab.test
|
||||||
|
MAIL=mx.lab.test # not "mail": proves the names follow --mail-host
|
||||||
|
WEBMAIL=webmail.lab.test
|
||||||
|
LABNET=ihasmail-oneshot-e2e
|
||||||
|
LABSUBNET=172.31.254.0/24
|
||||||
|
HOSTIP=172.31.254.1 # this host, as seen from the lab network
|
||||||
|
DEPLOY="$WORK/deployment"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
ok() { echo " ok $*"; pass=$((pass + 1)); }
|
||||||
|
die() { echo " FAIL $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
status=$?
|
||||||
|
if [ "${KEEP:-}" = 1 ]; then
|
||||||
|
echo "==> KEEP=1: leaving the stack and the lab up"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
echo "==> cleaning up"
|
||||||
|
[ -f "$DEPLOY/compose.yaml" ] && "$BIN" destroy --dir "$DEPLOY" --yes >/dev/null 2>&1 || true
|
||||||
|
docker rm -f oneshot-e2e-pebble oneshot-e2e-dns >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$LABNET" >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
[ "$status" -eq 0 ] && echo "==> passed: $pass checks" || echo "==> failed after $pass checks" >&2
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
rm -rf "$WORK" && mkdir -p "$WORK"
|
||||||
|
echo "==> building"
|
||||||
|
(cd "$ROOT" && go build -o "$BIN" ./cmd/ihasmail-oneshot)
|
||||||
|
|
||||||
|
# --- the lab: an ACME CA and a DNS stub ---------------------------------------
|
||||||
|
echo "==> starting Pebble and the DNS stub"
|
||||||
|
docker network create --subnet "$LABSUBNET" "$LABNET" >/dev/null
|
||||||
|
# Pebble's own HTTPS certificate names only localhost and "pebble". Stalwart
|
||||||
|
# and Caddy reach it at this host's address, from another network, so it gets
|
||||||
|
# a certificate for that address, signed by the test root that ships with it.
|
||||||
|
docker create --name oneshot-e2e-extract ghcr.io/letsencrypt/pebble:latest >/dev/null
|
||||||
|
docker cp -q oneshot-e2e-extract:/test/certs "$WORK/pebble-certs"
|
||||||
|
docker cp -q oneshot-e2e-extract:/test/config/pebble-config.json "$WORK/pebble-config.json"
|
||||||
|
docker rm oneshot-e2e-extract >/dev/null
|
||||||
|
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj "/CN=pebble" \
|
||||||
|
-keyout "$WORK/pebble-key.pem" -out "$WORK/pebble.csr" 2>/dev/null
|
||||||
|
openssl x509 -req -in "$WORK/pebble.csr" -days 2 -CA "$WORK/pebble-certs/pebble.minica.pem" \
|
||||||
|
-CAkey "$WORK/pebble-certs/pebble.minica.key.pem" -CAcreateserial \
|
||||||
|
-extfile <(printf 'subjectAltName=IP:%s\nextendedKeyUsage=serverAuth\n' "$HOSTIP") -out "$WORK/pebble-cert.pem" 2>/dev/null
|
||||||
|
python3 - "$WORK/pebble-config.json" <<'EOF'
|
||||||
|
import json, sys
|
||||||
|
path = sys.argv[1]
|
||||||
|
c = json.load(open(path))
|
||||||
|
c["pebble"].update(httpPort=80, tlsPort=443, certificate="/work/pebble-cert.pem", privateKey="/work/pebble-key.pem")
|
||||||
|
json.dump(c, open(path, "w"))
|
||||||
|
EOF
|
||||||
|
docker run -d --name oneshot-e2e-dns --network "$LABNET" --ip 172.31.254.3 \
|
||||||
|
ghcr.io/letsencrypt/pebble-challtestsrv:latest \
|
||||||
|
-defaultIPv4 "$HOSTIP" -defaultIPv6 "" -http01 "" -https01 "" -tlsalpn01 "" -doh "" >/dev/null
|
||||||
|
# Nonce rejection off: Pebble refuses 5% of nonces on purpose, and Stalwart
|
||||||
|
# 0.16.22 gives up an order on the first one instead of retrying.
|
||||||
|
docker run -d --name oneshot-e2e-pebble --network "$LABNET" --ip 172.31.254.2 \
|
||||||
|
-p 14000:14000 -p 15000:15000 -v "$WORK:/work:ro" \
|
||||||
|
-e PEBBLE_VA_NOSLEEP=1 -e PEBBLE_WFE_NONCEREJECT=0 \
|
||||||
|
ghcr.io/letsencrypt/pebble:latest -config /work/pebble-config.json -dnsserver 172.31.254.3:8053 >/dev/null
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
curl -sf --cacert "$WORK/pebble-certs/pebble.minica.pem" "https://$HOSTIP:14000/dir" >/dev/null && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
curl -sf --cacert "$WORK/pebble-certs/pebble.minica.pem" "https://$HOSTIP:14000/dir" >/dev/null || die "Pebble did not come up"
|
||||||
|
ok "Pebble answers at https://$HOSTIP:14000/dir"
|
||||||
|
|
||||||
|
# --- the deployment -------------------------------------------------------------
|
||||||
|
echo "==> deploying"
|
||||||
|
"$BIN" deploy --domain "$DOMAIN" --mail-host "$MAIL" --webmail-host "$WEBMAIL" --user alice \
|
||||||
|
--acme-directory "https://$HOSTIP:14000/dir" --acme-ca-root "$WORK/pebble-certs/pebble.minica.pem" \
|
||||||
|
--dir "$DEPLOY" --yes | tee "$WORK/deploy.log"
|
||||||
|
grep -q "linked" "$WORK/deploy.log" || die "deploy did not report the link"
|
||||||
|
ok "deploy completed and signed in through ihasmail"
|
||||||
|
grep -q "certificate issued by CN=Pebble" "$WORK/deploy.log" || die "deploy did not report Stalwart's certificate"
|
||||||
|
ok "deploy reported Stalwart's certificate"
|
||||||
|
|
||||||
|
# expect CODE curl-args...: retry for up to 30s until curl gets CODE. Caddy
|
||||||
|
# obtains certificates for its names in parallel and in the background, so the
|
||||||
|
# first handshake for any one of them can come a few seconds after deploy.
|
||||||
|
expect() {
|
||||||
|
local want=$1 got=; shift
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
got=$(curl -s -o /dev/null -w '%{http_code}' "$@" 2>/dev/null || true)
|
||||||
|
[ "$got" = "$want" ] && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo " got HTTP ${got:-nothing}, wanted $want" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cred() { sed -n "s/^$1 = //p" "$DEPLOY/credentials.txt"; }
|
||||||
|
ADMIN=$(cred admin); ADMIN_PW=$(cred admin_password); ALICE_PW=$(cred "mailbox alice@$DOMAIN")
|
||||||
|
[ "$(stat -c %a "$DEPLOY/credentials.txt")" = 600 ] || die "credentials.txt is not 0600"
|
||||||
|
[ "$(stat -c %a "$DEPLOY/.env")" = 600 ] || die ".env is not 0600"
|
||||||
|
ok "credentials.txt and .env are private"
|
||||||
|
|
||||||
|
stalwart_env=$(docker compose --project-directory "$DEPLOY" ps -q stalwart | xargs docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}')
|
||||||
|
grep -q STALWART_RECOVERY_ADMIN <<<"$stalwart_env" && die "the bootstrap credential outlived the setup"
|
||||||
|
ok "no bootstrap credential left on the Stalwart container"
|
||||||
|
|
||||||
|
# Pebble issues from a root it generates at startup.
|
||||||
|
curl -sf --cacert "$WORK/pebble-certs/pebble.minica.pem" "https://$HOSTIP:15000/roots/0" > "$WORK/issuer-root.pem"
|
||||||
|
curl -sf --cacert "$WORK/pebble-certs/pebble.minica.pem" "https://$HOSTIP:15000/intermediates/0" > "$WORK/issuer-int.pem"
|
||||||
|
cat "$WORK/issuer-int.pem" "$WORK/issuer-root.pem" > "$WORK/issuer-chain.pem"
|
||||||
|
|
||||||
|
# --- Stalwart's own TLS: IMAPS and submissions --------------------------------
|
||||||
|
for port in 993 465; do
|
||||||
|
out=$(openssl s_client -connect "127.0.0.1:$port" -servername "$MAIL" -verify_hostname "$MAIL" \
|
||||||
|
-CAfile "$WORK/issuer-chain.pem" </dev/null 2>&1 || true)
|
||||||
|
grep -q "Verify return code: 0 (ok)" <<<"$out" || { echo "$out" | tail -20; die "port $port does not present a valid certificate for $MAIL"; }
|
||||||
|
ok "port $port presents a verified certificate for $MAIL"
|
||||||
|
done
|
||||||
|
# The Pebble CA's own HTTPS cert is signed by minica, but what Stalwart holds
|
||||||
|
# came through the ACME order -- so the issuer check above is the real test.
|
||||||
|
|
||||||
|
# --- Caddy: the webmail and Stalwart's web side --------------------------------
|
||||||
|
resolve=(--resolve "$WEBMAIL:443:127.0.0.1" --resolve "$MAIL:443:127.0.0.1" --resolve "autoconfig.$DOMAIN:443:127.0.0.1")
|
||||||
|
# Captured rather than piped: grep -q exits at the first match, curl takes a
|
||||||
|
# SIGPIPE, and pipefail reports a pass as a failure.
|
||||||
|
# Retried: deploy waits for Stalwart's certificate, not Caddy's, and Caddy
|
||||||
|
# obtains its own in the background.
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
health=$(curl -sS -f "${resolve[@]}" --cacert "$WORK/issuer-chain.pem" "https://$WEBMAIL/api/health" 2>&1 || true)
|
||||||
|
grep -q '"ok":true' <<<"$health" && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
grep -q '"ok":true' <<<"$health" || die "the webmail is not served over verified HTTPS: $health"
|
||||||
|
[ "$i" -gt 1 ] && echo " (Caddy's certificate took ${i}s after deploy finished)"
|
||||||
|
ok "https://$WEBMAIL serves ihasmail with a verified certificate"
|
||||||
|
|
||||||
|
expect 200 "${resolve[@]}" --cacert "$WORK/issuer-chain.pem" -H 'Content-Type: application/json' -H 'X-Requested-With: ihasmail' \
|
||||||
|
"https://$WEBMAIL/api/auth/login" -d "{\"username\":\"alice@$DOMAIN\",\"password\":\"$ALICE_PW\"}" \
|
||||||
|
|| die "alice cannot sign in through https://$WEBMAIL"
|
||||||
|
ok "alice signs in through https://$WEBMAIL"
|
||||||
|
|
||||||
|
expect 200 "${resolve[@]}" --cacert "$WORK/issuer-chain.pem" -u "$ADMIN:$ADMIN_PW" "https://$MAIL/jmap/session" \
|
||||||
|
|| die "Stalwart's JMAP is not reachable at https://$MAIL"
|
||||||
|
ok "https://$MAIL reaches Stalwart with a verified certificate"
|
||||||
|
|
||||||
|
expect 200 "${resolve[@]}" --cacert "$WORK/issuer-chain.pem" "https://autoconfig.$DOMAIN/mail/config-v1.1.xml?emailaddress=alice@$DOMAIN" \
|
||||||
|
|| die "autoconfig is not served at https://autoconfig.$DOMAIN"
|
||||||
|
ok "https://autoconfig.$DOMAIN serves Thunderbird autoconfig"
|
||||||
|
|
||||||
|
expect 308 --resolve "$MAIL:80:127.0.0.1" "http://$MAIL/" || die "port 80 for $MAIL does not redirect"
|
||||||
|
ok "http://$MAIL redirects to HTTPS"
|
||||||
|
|
||||||
|
# --- DNS records and the certs command ----------------------------------------
|
||||||
|
grep -q "^$DOMAIN\. IN MX 10 $MAIL\." "$DEPLOY/dns-records.zone" || die "dns-records.zone has no MX for $MAIL"
|
||||||
|
grep -q "_domainkey\.$DOMAIN\. IN TXT" "$DEPLOY/dns-records.zone" || die "dns-records.zone has no DKIM record"
|
||||||
|
ok "dns-records.zone has the MX and DKIM records"
|
||||||
|
|
||||||
|
certs_out=$("$BIN" certs --dir "$DEPLOY" 2>&1 || true)
|
||||||
|
grep -q "already holds a certificate for $MAIL" <<<"$certs_out" || die "certs did not see the existing certificate: $certs_out"
|
||||||
|
ok "certs recognises the certificate already issued"
|
||||||
|
|
||||||
|
# --- the auto-ban -------------------------------------------------------------
|
||||||
|
# Last, so nothing earlier can be affected by a ban. Scans come from throwaway
|
||||||
|
# containers at fixed addresses; queries go through the Stalwart container
|
||||||
|
# itself, which no ban applies to.
|
||||||
|
# Called from inside the Stalwart container, so a ban on any outside address
|
||||||
|
# cannot lock the test out of checking it.
|
||||||
|
jmap() {
|
||||||
|
docker compose --project-directory "$DEPLOY" exec -T stalwart curl -s -u "$ADMIN:$ADMIN_PW" \
|
||||||
|
-H 'Content-Type: application/json' http://127.0.0.1:8080/jmap/ \
|
||||||
|
-d "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[$1]}"
|
||||||
|
}
|
||||||
|
blocked() { jmap '["x:BlockedIp/get",{"ids":null,"properties":["address"]},"0"]'; }
|
||||||
|
SUBNET_PREFIX=172.31.253
|
||||||
|
|
||||||
|
# A scanner through Caddy is banned by its own address, not Caddy's. The path
|
||||||
|
# has to be a scanner path that Stalwart answers 404 -- it redirects
|
||||||
|
# /wp-login.php, which is never counted -- and it takes 30 of them by default.
|
||||||
|
scanner=172.31.253.81
|
||||||
|
docker run --rm --network ihasmail-lab-test_stack --ip "$scanner" curlimages/curl -s -o /dev/null \
|
||||||
|
--resolve "$MAIL:443:$SUBNET_PREFIX.10" -k "https://$MAIL/probe/wp-admin.php?[1-35]" || true
|
||||||
|
sleep 1
|
||||||
|
bl=$(blocked)
|
||||||
|
grep -q "\"$SUBNET_PREFIX.10\"" <<<"$bl" && die "a scan through Caddy banned Caddy itself: $bl"
|
||||||
|
grep -q "\"$scanner\"" <<<"$bl" || die "a scan through Caddy did not ban the scanner: $bl"
|
||||||
|
ok "a scan through Caddy bans the scanner ($scanner), not Caddy"
|
||||||
|
code=$(docker run --rm --network ihasmail-lab-test_stack --ip 172.31.253.82 curlimages/curl -s -o /dev/null -w '%{http_code}' \
|
||||||
|
--resolve "autoconfig.$DOMAIN:443:$SUBNET_PREFIX.10" -k "https://autoconfig.$DOMAIN/mail/config-v1.1.xml?emailaddress=alice@$DOMAIN" || true)
|
||||||
|
[ "$code" = 200 ] || die "another client is refused through Caddy after the scan (HTTP $code)"
|
||||||
|
ok "other clients still reach Stalwart through Caddy"
|
||||||
|
|
||||||
|
allowed=$(jmap '["x:AllowedIp/get",{"ids":null,"properties":["address"]},"0"]')
|
||||||
|
grep -q "\"$SUBNET_PREFIX.11\"" <<<"$allowed" || die "ihasmail is not exempt from the auto-ban: $allowed"
|
||||||
|
ok "ihasmail's address is exempt from the auto-ban"
|
||||||
|
for _ in 1 2 3 4 5; do
|
||||||
|
curl -s -o /dev/null -H 'Content-Type: application/json' -H 'X-Requested-With: ihasmail' \
|
||||||
|
http://127.0.0.1:8080/api/auth/login -d "{\"username\":\"alice@$DOMAIN\",\"password\":\"wrong\"}"
|
||||||
|
done
|
||||||
|
expect 200 -H 'Content-Type: application/json' -H 'X-Requested-With: ihasmail' \
|
||||||
|
http://127.0.0.1:8080/api/auth/login -d "{\"username\":\"alice@$DOMAIN\",\"password\":\"$ALICE_PW\"}" \
|
||||||
|
|| die "alice cannot sign in through ihasmail after failed attempts"
|
||||||
|
ok "alice still signs in through ihasmail after failed attempts"
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package config turns the command line into a Plan: every name, address and
|
||||||
|
// image the deployment uses, validated once, before anything touches Docker.
|
||||||
|
//
|
||||||
|
// Nothing downstream re-checks what is here. A plan that validates is one the
|
||||||
|
// templates can render without quoting surprises, so the rules are strict on
|
||||||
|
// purpose: a hostname is a hostname, a bind is host:port, a user name is the
|
||||||
|
// local part of an address and nothing else.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"net/netip"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Versions this release was tested with, end to end. Stalwart is pinned
|
||||||
|
// because ihasmail validates against one Stalwart release at a time; the
|
||||||
|
// ihasmail tag is the newest release at the time; Caddy is pinned so that a
|
||||||
|
// redeploy months from now renders the same proxy.
|
||||||
|
const (
|
||||||
|
DefaultStalwartImage = "stalwartlabs/stalwart:v0.16.22"
|
||||||
|
DefaultIhasmailImage = "ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328"
|
||||||
|
DefaultCaddyImage = "caddy:2.11.4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stalwart's ACME order covers these next to the mail host, all under the mail
|
||||||
|
// domain: it is what its own DNS zone points at the mail host as CNAMEs, and
|
||||||
|
// Caddy has to answer for every one of them on port 80 or the order fails.
|
||||||
|
var stalwartServiceLabels = []string{"autoconfig", "autodiscover", "mta-sts", "ua-auto-config"}
|
||||||
|
|
||||||
|
// Options is the command line, as given.
|
||||||
|
type Options struct {
|
||||||
|
Local bool
|
||||||
|
|
||||||
|
Domain string
|
||||||
|
MailHost string
|
||||||
|
WebmailHost string
|
||||||
|
Email string
|
||||||
|
|
||||||
|
Dir string
|
||||||
|
Project string
|
||||||
|
Users []string
|
||||||
|
|
||||||
|
StalwartImage string
|
||||||
|
IhasmailImage string
|
||||||
|
CaddyImage string
|
||||||
|
|
||||||
|
WebmailBind string
|
||||||
|
StalwartBind string
|
||||||
|
Subnet string
|
||||||
|
|
||||||
|
// A private ACME CA, instead of Let's Encrypt. Both are for an internal CA
|
||||||
|
// (and for the end-to-end test, which runs one); neither is needed on the
|
||||||
|
// open internet.
|
||||||
|
ACMEDirectory string
|
||||||
|
ACMECARoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan is Options after defaults and validation.
|
||||||
|
type Plan struct {
|
||||||
|
Local bool
|
||||||
|
|
||||||
|
Domain string
|
||||||
|
MailHost string
|
||||||
|
WebmailHost string
|
||||||
|
Email string
|
||||||
|
|
||||||
|
Dir string
|
||||||
|
Project string
|
||||||
|
Users []string // local parts, lower-case, without the domain
|
||||||
|
|
||||||
|
StalwartImage string
|
||||||
|
IhasmailImage string
|
||||||
|
CaddyImage string
|
||||||
|
|
||||||
|
WebmailBind string
|
||||||
|
StalwartBind string
|
||||||
|
|
||||||
|
Subnet netip.Prefix
|
||||||
|
CaddyIP netip.Addr
|
||||||
|
IhasmailIP netip.Addr
|
||||||
|
StalwartIP netip.Addr
|
||||||
|
|
||||||
|
ACMEDirectory string
|
||||||
|
ACMECARoot string // absolute path, or empty
|
||||||
|
}
|
||||||
|
|
||||||
|
// StalwartNames is every hostname Caddy fronts for Stalwart: the mail host
|
||||||
|
// first, then the service names its ACME order includes.
|
||||||
|
func (p Plan) StalwartNames() []string {
|
||||||
|
names := []string{p.MailHost}
|
||||||
|
for _, l := range stalwartServiceLabels {
|
||||||
|
names = append(names, l+"."+p.Domain)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublishedPorts is every host port the stack binds on all interfaces. Local
|
||||||
|
// mode binds nothing but the two loopback addresses.
|
||||||
|
func (p Plan) PublishedPorts() []int {
|
||||||
|
if p.Local {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// No 587 or 143: Stalwart 0.16 opens no listener on either by default, and
|
||||||
|
// its own DNS zone advertises 465 and 993. 995 is advertised too, so it is
|
||||||
|
// published rather than left as an SRV record that points at nothing.
|
||||||
|
return []int{25, 80, 443, 465, 993, 995, 4190}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
hostnameRE = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$`)
|
||||||
|
localRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$`)
|
||||||
|
projectRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||||
|
imageRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*(?::[A-Za-z0-9._-]+)?(?:@sha256:[a-f0-9]{64})?$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
func normaliseHost(s string) string {
|
||||||
|
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(s)), ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate applies defaults and checks everything, returning every problem at
|
||||||
|
// once rather than the first: a one-shot tool that makes you run it five times
|
||||||
|
// to find five typos is not one shot.
|
||||||
|
func (o Options) Validate() (Plan, error) {
|
||||||
|
var errs []error
|
||||||
|
fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) }
|
||||||
|
|
||||||
|
p := Plan{Local: o.Local}
|
||||||
|
|
||||||
|
p.Domain = normaliseHost(o.Domain)
|
||||||
|
switch {
|
||||||
|
case p.Domain == "" && o.Local:
|
||||||
|
p.Domain = "example.test"
|
||||||
|
case p.Domain == "":
|
||||||
|
fail("--domain is required: the mail domain this server receives for, e.g. example.com")
|
||||||
|
case !hostnameRE.MatchString(p.Domain):
|
||||||
|
fail("--domain %q is not a domain name", o.Domain)
|
||||||
|
}
|
||||||
|
// Defaults below are built from the domain. Past a bad one, report only
|
||||||
|
// what was typed: "webmail.bad domain is not a hostname" is the same
|
||||||
|
// mistake again, not a second one.
|
||||||
|
domainOK := hostnameRE.MatchString(p.Domain)
|
||||||
|
if !domainOK {
|
||||||
|
p.Domain = "domain.invalid"
|
||||||
|
}
|
||||||
|
|
||||||
|
p.MailHost = normaliseHost(o.MailHost)
|
||||||
|
if p.MailHost == "" {
|
||||||
|
p.MailHost = "mail." + p.Domain
|
||||||
|
}
|
||||||
|
// Stalwart's ACME certificate and its DNS zone are both built per domain,
|
||||||
|
// so a mail host outside the domain would get a certificate that does not
|
||||||
|
// name it. One label under the domain is the shape both are sure to cover.
|
||||||
|
if label, ok := strings.CutSuffix(p.MailHost, "."+p.Domain); (domainOK && (!ok || strings.Contains(label, "."))) || !hostnameRE.MatchString(p.MailHost) {
|
||||||
|
fail("--mail-host %q must be one label under the domain, e.g. mail.%s", o.MailHost, p.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.WebmailHost = normaliseHost(o.WebmailHost)
|
||||||
|
if p.WebmailHost == "" {
|
||||||
|
p.WebmailHost = "webmail." + p.Domain
|
||||||
|
}
|
||||||
|
if !hostnameRE.MatchString(p.WebmailHost) {
|
||||||
|
fail("--webmail-host %q is not a hostname", o.WebmailHost)
|
||||||
|
}
|
||||||
|
for _, n := range p.StalwartNames() {
|
||||||
|
if n == p.WebmailHost {
|
||||||
|
fail("--webmail-host %q is already one of Stalwart's names; give the webmail a name of its own", p.WebmailHost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Email = strings.TrimSpace(o.Email)
|
||||||
|
if p.Email == "" {
|
||||||
|
p.Email = "postmaster@" + p.Domain
|
||||||
|
}
|
||||||
|
if a, err := mail.ParseAddress(p.Email); err != nil || a.Address != p.Email {
|
||||||
|
fail("--email %q is not a plain email address", o.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Project = strings.TrimSpace(o.Project)
|
||||||
|
if p.Project == "" {
|
||||||
|
p.Project = "ihasmail-" + strings.ReplaceAll(p.Domain, ".", "-")
|
||||||
|
}
|
||||||
|
if !projectRE.MatchString(p.Project) {
|
||||||
|
fail("--project %q may hold only lower-case letters, digits, '-' and '_'", p.Project)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Dir = o.Dir
|
||||||
|
if p.Dir == "" {
|
||||||
|
p.Dir = p.Project
|
||||||
|
}
|
||||||
|
if abs, err := filepath.Abs(p.Dir); err != nil {
|
||||||
|
fail("--dir %q: %v", o.Dir, err)
|
||||||
|
} else {
|
||||||
|
p.Dir = abs
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{"admin": true}
|
||||||
|
for _, u := range o.Users {
|
||||||
|
local := strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if at := strings.LastIndexByte(local, '@'); at >= 0 {
|
||||||
|
if domainOK && local[at+1:] != p.Domain {
|
||||||
|
fail("--user %q is not in %s, the only domain this deploys", u, p.Domain)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
local = local[:at]
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case !localRE.MatchString(local):
|
||||||
|
fail("--user %q is not a valid mailbox name", u)
|
||||||
|
case seen[local]:
|
||||||
|
fail("--user %q is given twice, or is the administrator", u)
|
||||||
|
default:
|
||||||
|
seen[local] = true
|
||||||
|
p.Users = append(p.Users, local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.StalwartImage = orDefault(o.StalwartImage, DefaultStalwartImage)
|
||||||
|
p.IhasmailImage = orDefault(o.IhasmailImage, DefaultIhasmailImage)
|
||||||
|
p.CaddyImage = orDefault(o.CaddyImage, DefaultCaddyImage)
|
||||||
|
for flag, img := range map[string]string{"--stalwart-image": p.StalwartImage, "--ihasmail-image": p.IhasmailImage, "--caddy-image": p.CaddyImage} {
|
||||||
|
if !imageRE.MatchString(img) {
|
||||||
|
fail("%s %q is not an image reference", flag, img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.WebmailBind = orDefault(o.WebmailBind, "127.0.0.1:8080")
|
||||||
|
p.StalwartBind = orDefault(o.StalwartBind, "127.0.0.1:8081")
|
||||||
|
for flag, b := range map[string]string{"--webmail-bind": p.WebmailBind, "--stalwart-bind": p.StalwartBind} {
|
||||||
|
if err := checkBind(b); err != nil {
|
||||||
|
fail("%s %q: %v", flag, b, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.WebmailBind == p.StalwartBind {
|
||||||
|
fail("--webmail-bind and --stalwart-bind are both %s", p.WebmailBind)
|
||||||
|
}
|
||||||
|
if !p.Local {
|
||||||
|
for _, port := range p.PublishedPorts() {
|
||||||
|
for flag, b := range map[string]string{"--webmail-bind": p.WebmailBind, "--stalwart-bind": p.StalwartBind} {
|
||||||
|
if _, bp, _ := net.SplitHostPort(b); bp == strconv.Itoa(port) {
|
||||||
|
fail("%s %q collides with port %d, which the mail host publishes", flag, b, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subnet := orDefault(o.Subnet, "172.31.253.0/24")
|
||||||
|
if pfx, err := netip.ParsePrefix(subnet); err != nil || !pfx.Addr().Is4() || pfx.Bits() > 27 || pfx.Masked() != pfx {
|
||||||
|
fail("--subnet %q must be an IPv4 network no smaller than a /27, e.g. 172.31.253.0/24", subnet)
|
||||||
|
} else {
|
||||||
|
p.Subnet = pfx
|
||||||
|
// Fixed addresses, because Stalwart is told about two of them: Caddy's
|
||||||
|
// forwarded-for header is believed, and ihasmail is exempt from the
|
||||||
|
// auto-ban. An address Docker picks afresh on every recreate cannot be
|
||||||
|
// written into either.
|
||||||
|
base := pfx.Addr().As4()
|
||||||
|
at := func(n byte) netip.Addr { b := base; b[3] += n; return netip.AddrFrom4(b) }
|
||||||
|
p.CaddyIP, p.IhasmailIP, p.StalwartIP = at(10), at(11), at(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ACMEDirectory = strings.TrimSpace(o.ACMEDirectory)
|
||||||
|
if o.ACMECARoot != "" {
|
||||||
|
if abs, err := filepath.Abs(o.ACMECARoot); err != nil {
|
||||||
|
fail("--acme-ca-root %q: %v", o.ACMECARoot, err)
|
||||||
|
} else {
|
||||||
|
p.ACMECARoot = abs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.Local && (p.ACMEDirectory != "" || p.ACMECARoot != "" || o.Email != "") {
|
||||||
|
fail("--acme-directory, --acme-ca-root and --email have no effect with --local, which requests no certificates")
|
||||||
|
}
|
||||||
|
if p.ACMEDirectory != "" && !strings.HasPrefix(p.ACMEDirectory, "https://") {
|
||||||
|
fail("--acme-directory %q must be an https URL", p.ACMEDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return Plan{}, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orDefault(s, def string) string {
|
||||||
|
if s = strings.TrimSpace(s); s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkBind(b string) error {
|
||||||
|
host, port, err := net.SplitHostPort(b)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("must be host:port, e.g. 127.0.0.1:8080")
|
||||||
|
}
|
||||||
|
if _, err := netip.ParseAddr(host); err != nil {
|
||||||
|
return errors.New("the host part must be an IP address")
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
||||||
|
return errors.New("the port must be 1-65535")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultsFollowTheDomain(t *testing.T) {
|
||||||
|
p, err := Options{Domain: "Example.COM.", Dir: "/srv/mail"}.Validate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for got, want := range map[string]string{
|
||||||
|
p.Domain: "example.com",
|
||||||
|
p.MailHost: "mail.example.com",
|
||||||
|
p.WebmailHost: "webmail.example.com",
|
||||||
|
p.Email: "[email protected]",
|
||||||
|
p.Project: "ihasmail-example-com",
|
||||||
|
p.Dir: "/srv/mail",
|
||||||
|
} {
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.CaddyIP.String() != "172.31.253.10" || p.IhasmailIP.String() != "172.31.253.11" || p.StalwartIP.String() != "172.31.253.12" {
|
||||||
|
t.Errorf("addresses %s %s %s", p.CaddyIP, p.IhasmailIP, p.StalwartIP)
|
||||||
|
}
|
||||||
|
want := []string{"mail.example.com", "autoconfig.example.com", "autodiscover.example.com", "mta-sts.example.com", "ua-auto-config.example.com"}
|
||||||
|
if !slices.Equal(p.StalwartNames(), want) {
|
||||||
|
t.Errorf("StalwartNames = %v", p.StalwartNames())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalNeedsNoDomainAndPublishesNothing(t *testing.T) {
|
||||||
|
p, err := Options{Local: true}.Validate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Domain != "example.test" || len(p.PublishedPorts()) != 0 {
|
||||||
|
t.Errorf("domain %q, ports %v", p.Domain, p.PublishedPorts())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejects(t *testing.T) {
|
||||||
|
for name, tc := range map[string]struct {
|
||||||
|
o Options
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"no domain": {Options{}, "--domain is required"},
|
||||||
|
"bad domain": {Options{Domain: "not a domain"}, `--domain "not a domain"`},
|
||||||
|
"mail host elsewhere": {Options{Domain: "example.com", MailHost: "mx.other.net"}, "one label under the domain"},
|
||||||
|
"mail host two deep": {Options{Domain: "example.com", MailHost: "a.b.example.com"}, "one label under the domain"},
|
||||||
|
"webmail is autoconfig": {Options{Domain: "example.com", WebmailHost: "autoconfig.example.com"}, "already one of Stalwart's names"},
|
||||||
|
"webmail is mail host": {Options{Domain: "example.com", WebmailHost: "mail.example.com"}, "already one of Stalwart's names"},
|
||||||
|
"user in another domain": {Options{Domain: "example.com", Users: []string{"[email protected]"}}, "not in example.com"},
|
||||||
|
"user is admin": {Options{Domain: "example.com", Users: []string{"admin"}}, "or is the administrator"},
|
||||||
|
"duplicate user": {Options{Domain: "example.com", Users: []string{"bob", "[email protected]"}}, "given twice"},
|
||||||
|
"bad user": {Options{Domain: "example.com", Users: []string{"bob smith"}}, "not a valid mailbox name"},
|
||||||
|
"bind not host:port": {Options{Domain: "example.com", WebmailBind: "8080"}, "--webmail-bind"},
|
||||||
|
"bind on a mail port": {Options{Domain: "example.com", StalwartBind: "127.0.0.1:443"}, "collides with port 443"},
|
||||||
|
"same binds": {Options{Domain: "example.com", WebmailBind: "127.0.0.1:9000", StalwartBind: "127.0.0.1:9000"}, "are both"},
|
||||||
|
"subnet too small": {Options{Domain: "example.com", Subnet: "10.0.0.0/29"}, "--subnet"},
|
||||||
|
"subnet not a network": {Options{Domain: "example.com", Subnet: "10.0.0.5/24"}, "--subnet"},
|
||||||
|
"acme flags with local": {Options{Local: true, ACMEDirectory: "https://ca.internal/dir"}, "no effect with --local"},
|
||||||
|
"acme over http": {Options{Domain: "example.com", ACMEDirectory: "http://ca.internal/dir"}, "must be an https URL"},
|
||||||
|
"image with a space": {Options{Domain: "example.com", CaddyImage: "caddy 2"}, "--caddy-image"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := tc.o.Validate()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Fatalf("err = %v, want it to mention %q", err, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bad domain is one mistake, not a cascade of defaults built from it.
|
||||||
|
func TestBadDomainIsReportedOnce(t *testing.T) {
|
||||||
|
_, err := Options{Domain: "bad domain"}.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("no error")
|
||||||
|
}
|
||||||
|
if lines := strings.Split(err.Error(), "\n"); len(lines) != 1 {
|
||||||
|
t.Errorf("got %d errors: %v", len(lines), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEveryProblemAtOnce(t *testing.T) {
|
||||||
|
_, err := Options{Domain: "example.com", MailHost: "mx.other.net", Users: []string{"bob smith"}, WebmailBind: "x"}.Validate()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("no error")
|
||||||
|
}
|
||||||
|
if lines := strings.Split(err.Error(), "\n"); len(lines) != 3 {
|
||||||
|
t.Errorf("got %d errors, want 3: %v", len(lines), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package deploy is the one shot: preflight, write the directory, bootstrap
|
||||||
|
// Stalwart, bring the stack up, link and verify it.
|
||||||
|
package deploy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/docker"
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/render"
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/stalwart"
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/webmail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Log is where progress goes. Each step says what it is doing before it does
|
||||||
|
// it, and every wait longer than a few seconds says so while it waits.
|
||||||
|
type Log struct{ W io.Writer }
|
||||||
|
|
||||||
|
func (l Log) Step(format string, a ...any) { fmt.Fprintf(l.W, "==> "+format+"\n", a...) }
|
||||||
|
func (l Log) Info(format string, a ...any) { fmt.Fprintf(l.W, " "+format+"\n", a...) }
|
||||||
|
func (l Log) Warn(format string, a ...any) { fmt.Fprintf(l.W, "!! "+format+"\n", a...) }
|
||||||
|
|
||||||
|
// Preflight checks everything that can be checked without changing anything.
|
||||||
|
// Warnings are things that will not stop the deployment but will stop it
|
||||||
|
// being useful until they are fixed, like DNS that does not point here yet.
|
||||||
|
func Preflight(ctx context.Context, p config.Plan, log Log) (warnings []string, err error) {
|
||||||
|
engine, compose, err := docker.Versions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Info("docker %s, compose %s", engine, compose)
|
||||||
|
|
||||||
|
var problems []error
|
||||||
|
if leftovers, err := docker.ProjectLeftovers(ctx, p.Project); err != nil {
|
||||||
|
problems = append(problems, err)
|
||||||
|
} else if len(leftovers) > 0 {
|
||||||
|
problems = append(problems, fmt.Errorf("project %s already exists in Docker (%s); destroy it first or choose another --project", p.Project, strings.Join(leftovers, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries, err := os.ReadDir(p.Dir); err == nil && len(entries) > 0 {
|
||||||
|
problems = append(problems, fmt.Errorf("%s already has files in it; give --dir a new or empty directory", p.Dir))
|
||||||
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
problems = append(problems, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.ACMECARoot != "" {
|
||||||
|
if _, err := os.Stat(p.ACMECARoot); err != nil {
|
||||||
|
problems = append(problems, fmt.Errorf("--acme-ca-root: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addrs := []string{p.WebmailBind, p.StalwartBind}
|
||||||
|
for _, port := range p.PublishedPorts() {
|
||||||
|
addrs = append(addrs, fmt.Sprintf(":%d", port))
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
if err := portFree(a); err != nil {
|
||||||
|
problems = append(problems, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.Local {
|
||||||
|
for _, host := range []string{p.WebmailHost, p.MailHost} {
|
||||||
|
if ips, err := net.DefaultResolver.LookupHost(ctx, host); err != nil || len(ips) == 0 {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("%s does not resolve yet: its certificate cannot be issued until it points at this host", host))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return warnings, errors.Join(problems...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// portFree tries to bind an address. A permission error means an unprivileged
|
||||||
|
// user asking about a low port, which says nothing about whether Docker can
|
||||||
|
// have it, so it is not reported.
|
||||||
|
func portFree(addr string) error {
|
||||||
|
l, err := net.Listen("tcp", addr)
|
||||||
|
if err == nil {
|
||||||
|
return l.Close()
|
||||||
|
}
|
||||||
|
if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, syscall.EADDRINUSE) {
|
||||||
|
return fmt.Errorf("port %s is already in use on this host", strings.TrimPrefix(addr, ":"))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("cannot bind %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a successful deployment reports.
|
||||||
|
type Result struct {
|
||||||
|
Admin stalwart.Admin
|
||||||
|
Mailboxes map[string]string
|
||||||
|
IhasmailVersion string
|
||||||
|
AdminInWebmail bool
|
||||||
|
Certificate *stalwart.Certificate
|
||||||
|
// Caddy's certificates, by hostname, as the issuer's name. A name missing
|
||||||
|
// here had none by the time the tool stopped waiting.
|
||||||
|
CaddyCertificates map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy runs the whole thing. On an error after containers exist, it leaves
|
||||||
|
// them as they are for inspection and says how to start over.
|
||||||
|
func Deploy(ctx context.Context, p config.Plan, version string, log Log) (*Result, error) {
|
||||||
|
log.Step("writing %s", p.Dir)
|
||||||
|
if err := render.PrepareDir(p.Dir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
appSecret, err := randomBase64(48)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bootPassword := randomPassword(32)
|
||||||
|
|
||||||
|
caBundle := p.ACMECARoot != ""
|
||||||
|
composeYAML, err := render.Compose(p, version, caBundle)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := render.WriteFile(p.Dir, render.ComposeFile, composeYAML, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := render.WriteFile(p.Dir, render.EnvFile, render.Env(appSecret), true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !p.Local {
|
||||||
|
caddyfile, err := render.Caddy(p, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := render.WriteFile(p.Dir, render.Caddyfile, caddyfile, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compose := docker.Compose{Dir: p.Dir, Out: indent(log.W)}
|
||||||
|
|
||||||
|
log.Step("pulling images")
|
||||||
|
if err := compose.Run(ctx, "pull", "--quiet"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if caBundle {
|
||||||
|
root, err := os.ReadFile(p.ACMECARoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
system, err := docker.SystemCABundle(ctx, p.StalwartImage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading the CA bundle out of %s: %w", p.StalwartImage, err)
|
||||||
|
}
|
||||||
|
if err := render.WriteFile(p.Dir, render.CARootFile, root, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := render.WriteFile(p.Dir, render.CABundleFile, append(system, root...), false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From here on there are containers, so a failure says how to clear them.
|
||||||
|
res, err := bringUp(ctx, p, compose, bootPassword, log)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("%w\n\nThe stack is left as it is, to look at. To start again from nothing:\n ihasmail-oneshot destroy --dir %s --yes", err, p.Dir)
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bringUp(ctx context.Context, p config.Plan, compose docker.Compose, bootPassword string, log Log) (*Result, error) {
|
||||||
|
stalwartURL := "http://" + p.StalwartBind
|
||||||
|
res := &Result{Mailboxes: map[string]string{}, CaddyCertificates: map[string]string{}}
|
||||||
|
|
||||||
|
// --- bootstrap -----------------------------------------------------------
|
||||||
|
// The bootstrap account comes from an override file that lives only for
|
||||||
|
// this step, and its password only in this process's environment. Bringing
|
||||||
|
// the stack up afterwards without the override recreates Stalwart without
|
||||||
|
// the variable, so no fixed recovery credential outlives the setup.
|
||||||
|
override, err := os.CreateTemp("", "ihasmail-oneshot-bootstrap-*.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.Remove(override.Name())
|
||||||
|
if _, err := override.WriteString("services:\n stalwart:\n environment:\n STALWART_RECOVERY_ADMIN: ${ONESHOT_BOOTSTRAP_ADMIN:?}\n"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
override.Close()
|
||||||
|
|
||||||
|
log.Step("starting Stalwart in bootstrap mode")
|
||||||
|
boot := compose
|
||||||
|
boot.Files = []string{override.Name()}
|
||||||
|
boot.Env = []string{"ONESHOT_BOOTSTRAP_ADMIN=admin:" + bootPassword}
|
||||||
|
if err := boot.Run(ctx, "up", "-d", "stalwart"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := waitFor(ctx, log, "Stalwart", 90*time.Second, func(ctx context.Context) error {
|
||||||
|
return stalwart.Live(ctx, stalwartURL)
|
||||||
|
}); err != nil {
|
||||||
|
return nil, withLogs(ctx, err, compose, "stalwart")
|
||||||
|
}
|
||||||
|
|
||||||
|
bootClient := &stalwart.Client{BaseURL: stalwartURL, Username: "admin", Password: bootPassword}
|
||||||
|
if err := bootClient.CheckBootstrapMode(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Step("setting up Stalwart for %s (hostname %s)", p.Domain, p.MailHost)
|
||||||
|
admin, err := bootClient.Bootstrap(ctx, p.MailHost, p.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bootstrap: %w", err)
|
||||||
|
}
|
||||||
|
res.Admin = admin
|
||||||
|
// Written now, not at the end: from this moment the password exists nowhere
|
||||||
|
// else, and a failure in a later step must not lose it.
|
||||||
|
if err := writeCredentials(p, admin); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
log.Info("administrator %s, password in %s", admin.Username, render.CredentialsFile)
|
||||||
|
|
||||||
|
// --- the whole stack -----------------------------------------------------
|
||||||
|
log.Step("starting the stack")
|
||||||
|
if err := compose.Run(ctx, "up", "-d"); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
sw := &stalwart.Client{BaseURL: stalwartURL, Username: admin.Username, Password: admin.Secret}
|
||||||
|
if err := waitFor(ctx, log, "Stalwart to restart configured", 90*time.Second, func(ctx context.Context) error {
|
||||||
|
_, err := sw.DomainID(ctx, p.Domain)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return res, withLogs(ctx, err, compose, "stalwart")
|
||||||
|
}
|
||||||
|
domainID, err := sw.DomainID(ctx, p.Domain)
|
||||||
|
if err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- linking -------------------------------------------------------------
|
||||||
|
log.Step("linking ihasmail and Stalwart")
|
||||||
|
// Every request the webmail makes reaches Stalwart from ihasmail's one
|
||||||
|
// address -- every sign-in, every push stream opened and dropped as tabs
|
||||||
|
// come and go. Stalwart bans per address, so a ban on that one would be a
|
||||||
|
// ban on everybody's webmail. ihasmail rate-limits sign-ins per real client
|
||||||
|
// itself. (Checked on 0.16.22: failed sign-ins were refused per session but
|
||||||
|
// never became an address ban; scans and dropped connections are counted,
|
||||||
|
// and this is not a limit worth finding by losing the webmail to it.)
|
||||||
|
if err := sw.AllowIP(ctx, p.IhasmailIP.String(), "ihasmail: every webmail request arrives from this address"); err != nil {
|
||||||
|
return res, fmt.Errorf("exempting ihasmail from the auto-ban: %w", err)
|
||||||
|
}
|
||||||
|
log.Info("ihasmail (%s) exempt from Stalwart's auto-ban", p.IhasmailIP)
|
||||||
|
if !p.Local {
|
||||||
|
if err := sw.TrustForwardedFor(ctx); err != nil {
|
||||||
|
return res, fmt.Errorf("trusting Caddy's X-Forwarded-For: %w", err)
|
||||||
|
}
|
||||||
|
log.Info("Stalwart takes client addresses from Caddy's X-Forwarded-For")
|
||||||
|
}
|
||||||
|
// Neither setting takes effect on a running Stalwart: on 0.16.22 a scan
|
||||||
|
// through Caddy straight after setting them still banned Caddy, and the
|
||||||
|
// same scan after a restart banned the scanner. Nor does lifting a ban.
|
||||||
|
log.Info("restarting Stalwart to apply them")
|
||||||
|
if err := compose.Run(ctx, "restart", "stalwart"); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
if err := waitFor(ctx, log, "Stalwart to restart", 90*time.Second, func(ctx context.Context) error {
|
||||||
|
_, err := sw.DomainID(ctx, p.Domain)
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return res, withLogs(ctx, err, compose, "stalwart")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range p.Users {
|
||||||
|
password := randomPassword(24)
|
||||||
|
if _, err := sw.CreateUser(ctx, name, domainID, password); err != nil {
|
||||||
|
return res, fmt.Errorf("creating mailbox %s@%s: %w", name, p.Domain, err)
|
||||||
|
}
|
||||||
|
address := name + "@" + p.Domain
|
||||||
|
res.Mailboxes[address] = password
|
||||||
|
if err := render.AppendFile(p.Dir, render.CredentialsFile, []byte(fmt.Sprintf("mailbox %s = %s\n", address, password))); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
log.Info("mailbox %s created", address)
|
||||||
|
}
|
||||||
|
|
||||||
|
webmailURL := "http://" + p.WebmailBind
|
||||||
|
if err := waitFor(ctx, log, "ihasmail", 60*time.Second, func(ctx context.Context) error {
|
||||||
|
h, err := webmail.CheckHealth(ctx, webmailURL)
|
||||||
|
res.IhasmailVersion = h.Version
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return res, withLogs(ctx, err, compose, "ihasmail")
|
||||||
|
}
|
||||||
|
// Retried briefly: ihasmail can report healthy a moment before its first
|
||||||
|
// session discovery against a Stalwart that has only just restarted.
|
||||||
|
if err := waitFor(ctx, log, "a sign-in through ihasmail", 30*time.Second, func(ctx context.Context) error {
|
||||||
|
ok, err := webmail.SignIn(ctx, webmailURL, admin.Username, admin.Secret)
|
||||||
|
res.AdminInWebmail = ok
|
||||||
|
return err
|
||||||
|
}); err != nil {
|
||||||
|
return res, withLogs(ctx, err, compose, "ihasmail")
|
||||||
|
}
|
||||||
|
log.Info("signed in to ihasmail %s as %s: linked", res.IhasmailVersion, admin.Username)
|
||||||
|
|
||||||
|
if p.Local {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- certificates and DNS -----------------------------------------------
|
||||||
|
log.Step("requesting Stalwart's certificate")
|
||||||
|
if err := waitFor(ctx, log, "Caddy", 30*time.Second, func(ctx context.Context) error {
|
||||||
|
if !compose.Running(ctx, "caddy") {
|
||||||
|
return errors.New("not running")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return res, withLogs(ctx, err, compose, "caddy")
|
||||||
|
}
|
||||||
|
if _, err := sw.EnableACME(ctx, domainID, p.ACMEDirectory, p.Email); err != nil {
|
||||||
|
return res, fmt.Errorf("enabling ACME: %w", err)
|
||||||
|
}
|
||||||
|
// Not an error if it does not arrive: DNS that does not point here yet is
|
||||||
|
// the usual reason, and the fix is DNS and then `certs`, not a redeploy.
|
||||||
|
_ = waitFor(ctx, log, "the certificate", 90*time.Second, func(ctx context.Context) error {
|
||||||
|
certs, err := sw.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, c := range certs {
|
||||||
|
if c.SubjectAlternativeNames[p.MailHost] {
|
||||||
|
res.Certificate = &c
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("not issued yet")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Caddy obtains its own in the background. Waited for, so that "done"
|
||||||
|
// means the HTTPS it prints works -- and, like Stalwart's, not an error
|
||||||
|
// when it does not arrive.
|
||||||
|
for _, host := range []string{p.WebmailHost, p.MailHost} {
|
||||||
|
_ = waitFor(ctx, log, "Caddy's certificate for "+host, 60*time.Second, func(ctx context.Context) error {
|
||||||
|
issuer, err := servedCertificate(ctx, host)
|
||||||
|
if err == nil {
|
||||||
|
res.CaddyCertificates[host] = issuer
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
zone, err := sw.DNSZone(ctx, domainID)
|
||||||
|
if err != nil {
|
||||||
|
return res, fmt.Errorf("reading the DNS records: %w", err)
|
||||||
|
}
|
||||||
|
if err := render.WriteFile(p.Dir, render.DNSFile, []byte(dnsFile(p, zone)), false); err != nil {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// servedCertificate reports the issuer of the certificate Caddy presents for
|
||||||
|
// host on this machine's port 443, without trusting it: the question is
|
||||||
|
// whether Caddy has one yet, not whether this host trusts the CA. Until it has
|
||||||
|
// one the handshake fails outright.
|
||||||
|
func servedCertificate(ctx context.Context, host string) (string, error) {
|
||||||
|
d := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}}
|
||||||
|
conn, err := d.DialContext(ctx, "tcp", "127.0.0.1:443")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
|
||||||
|
if len(certs) == 0 {
|
||||||
|
return "", errors.New("no certificate presented")
|
||||||
|
}
|
||||||
|
if err := certs[0].VerifyHostname(host); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return certs[0].Issuer.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitFor retries check until it passes or timeout passes, saying every ten
|
||||||
|
// seconds that it is still waiting and what the last answer was.
|
||||||
|
func waitFor(ctx context.Context, log Log, what string, timeout time.Duration, check func(context.Context) error) error {
|
||||||
|
start := time.Now()
|
||||||
|
deadline := start.Add(timeout)
|
||||||
|
lastReport := start
|
||||||
|
for {
|
||||||
|
attempt, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
err := check(attempt)
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("gave up waiting for %s after %s: %w", what, timeout, err)
|
||||||
|
}
|
||||||
|
if time.Since(lastReport) >= 10*time.Second {
|
||||||
|
log.Info("still waiting for %s (%s): %v", what, time.Since(start).Round(time.Second), err)
|
||||||
|
lastReport = time.Now()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func withLogs(ctx context.Context, err error, compose docker.Compose, service string) error {
|
||||||
|
return fmt.Errorf("%w\n\nlast lines of %s's log:\n%s", err, service, compose.Logs(ctx, service, 25))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeCredentials(p config.Plan, a stalwart.Admin) error {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "# ihasmail-oneshot credentials for %s, written %s.\n", p.Domain, time.Now().UTC().Format(time.RFC3339))
|
||||||
|
b.WriteString("# Keep this file private. The administrator can sign in to the webmail too,\n")
|
||||||
|
b.WriteString("# and to Stalwart's own admin UI. Change the passwords after first sign-in.\n")
|
||||||
|
fmt.Fprintf(&b, "domain = %s\n", p.Domain)
|
||||||
|
fmt.Fprintf(&b, "mail_host = %s\n", p.MailHost)
|
||||||
|
fmt.Fprintf(&b, "stalwart_url = http://%s\n", p.StalwartBind)
|
||||||
|
fmt.Fprintf(&b, "admin = %s\n", a.Username)
|
||||||
|
fmt.Fprintf(&b, "admin_password = %s\n", a.Secret)
|
||||||
|
return render.WriteFile(p.Dir, render.CredentialsFile, []byte(b.String()), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dnsFile(p config.Plan, zone string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "; DNS records for %s, from Stalwart. Publish all of them.\n", p.Domain)
|
||||||
|
b.WriteString(";\n; Two more that Stalwart cannot know, because they are this host's address:\n")
|
||||||
|
for _, h := range []string{p.MailHost, p.WebmailHost} {
|
||||||
|
fmt.Fprintf(&b, "; %s. IN A <this host's IPv4 address>\n", h)
|
||||||
|
fmt.Fprintf(&b, "; %s. IN AAAA <this host's IPv6 address, if it has one>\n", h)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, ";\n; And one at your hosting provider rather than in this zone: reverse DNS (PTR)\n; for this host's address, pointing at %s.\n\n", p.MailHost)
|
||||||
|
b.WriteString(zone)
|
||||||
|
if !strings.HasSuffix(zone, "\n") {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy removes the stack, its volumes, and the files the tool wrote. Mail,
|
||||||
|
// accounts and certificates go with the volumes; that is the point of it.
|
||||||
|
func Destroy(ctx context.Context, dir string, log Log) error {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, render.ComposeFile)); err != nil {
|
||||||
|
return fmt.Errorf("%s is not a deployment directory: %w", dir, err)
|
||||||
|
}
|
||||||
|
log.Step("removing containers, networks and volumes")
|
||||||
|
compose := docker.Compose{Dir: dir, Out: indent(log.W)}
|
||||||
|
// APP_SECRET is required by compose.yaml's interpolation, and a missing
|
||||||
|
// .env must not make the stack impossible to remove.
|
||||||
|
compose.Env = []string{"APP_SECRET=unused-by-down"}
|
||||||
|
if err := compose.Run(ctx, "down", "--volumes", "--remove-orphans"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Step("removing the files it wrote")
|
||||||
|
for _, name := range render.Written {
|
||||||
|
if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Remove(dir); err != nil {
|
||||||
|
log.Info("left %s in place: it holds files this tool did not write", dir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certs starts a new certificate order, for after DNS has been fixed.
|
||||||
|
func Certs(ctx context.Context, dir string, log Log) error {
|
||||||
|
creds, err := ReadCredentials(filepath.Join(dir, render.CredentialsFile))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sw := &stalwart.Client{BaseURL: creds["stalwart_url"], Username: creds["admin"], Password: creds["admin_password"]}
|
||||||
|
domainID, err := sw.DomainID(ctx, creds["domain"])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
covering := func(ctx context.Context) (*stalwart.Certificate, error) {
|
||||||
|
certs, err := sw.Certificates(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, c := range certs {
|
||||||
|
if c.SubjectAlternativeNames[creds["mail_host"]] {
|
||||||
|
return &c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
// A new order while a certificate is still valid is refused by Stalwart as
|
||||||
|
// "renewal not due", so asking would only look like it had done something.
|
||||||
|
if c, err := covering(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
} else if c != nil {
|
||||||
|
log.Info("Stalwart already holds a certificate for %s, issued by %s, valid until %s", creds["mail_host"], c.Issuer, c.NotValidAfter)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Step("starting a new certificate order for %s", creds["domain"])
|
||||||
|
if err := sw.RetryCertificates(ctx, domainID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var got *stalwart.Certificate
|
||||||
|
err = waitFor(ctx, log, "the certificate", 90*time.Second, func(ctx context.Context) error {
|
||||||
|
c, err := covering(ctx)
|
||||||
|
if err == nil && c == nil {
|
||||||
|
err = errors.New("not issued yet")
|
||||||
|
}
|
||||||
|
got = c
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w\n Stalwart's log says why: docker compose --project-directory %s logs stalwart | grep -i acme", err, dir)
|
||||||
|
}
|
||||||
|
log.Info("issued by %s, valid until %s", got.Issuer, got.NotValidAfter)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadCredentials parses credentials.txt's "key = value" lines.
|
||||||
|
func ReadCredentials(path string) (map[string]string, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
if line = strings.TrimSpace(line); line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if k, v, ok := strings.Cut(line, " = "); ok {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range []string{"domain", "mail_host", "stalwart_url", "admin", "admin_password"} {
|
||||||
|
if out[k] == "" {
|
||||||
|
return nil, fmt.Errorf("%s has no %s", path, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomBase64(n int) (string, error) {
|
||||||
|
b := make([]byte, n)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// randomPassword is letters and digits only, so it survives YAML, an env file,
|
||||||
|
// a shell and being read aloud without any quoting.
|
||||||
|
func randomPassword(n int) string {
|
||||||
|
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
out := make([]byte, n)
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for i := 0; i < n; {
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
panic(err) // crypto/rand.Read does not fail on supported platforms
|
||||||
|
}
|
||||||
|
// Rejection sampling keeps every character equally likely.
|
||||||
|
if int(buf[0]) < 256-256%len(alphabet) {
|
||||||
|
out[i] = alphabet[int(buf[0])%len(alphabet)]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return string(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
type indentWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
bol bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (iw *indentWriter) Write(p []byte) (int, error) {
|
||||||
|
for _, c := range p {
|
||||||
|
if iw.bol {
|
||||||
|
if _, err := iw.w.Write([]byte(" ")); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := iw.w.Write([]byte{c}); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
iw.bol = c == '\n'
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// indent passes docker's own output through, indented under the step it
|
||||||
|
// belongs to.
|
||||||
|
func indent(w io.Writer) io.Writer { return &indentWriter{w: w, bol: true} }
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package docker drives the docker CLI. The CLI rather than the Engine API,
|
||||||
|
// because compose is the thing being driven and the CLI is how it ships: the
|
||||||
|
// deployment the tool leaves behind is one an operator manages with the same
|
||||||
|
// commands.
|
||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Output runs docker with args and returns its standard output.
|
||||||
|
func Output(ctx context.Context, args ...string) (string, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
msg := strings.TrimSpace(stderr.String())
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("docker %s: %s", strings.Join(args, " "), msg)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(stdout.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versions returns the engine and compose versions, which is also the check
|
||||||
|
// that both are installed and this user may use them.
|
||||||
|
func Versions(ctx context.Context) (engine, compose string, err error) {
|
||||||
|
if _, err := exec.LookPath("docker"); err != nil {
|
||||||
|
return "", "", errors.New("docker is not installed, or not on PATH")
|
||||||
|
}
|
||||||
|
if engine, err = Output(ctx, "version", "--format", "{{.Server.Version}}"); err != nil {
|
||||||
|
return "", "", fmt.Errorf("cannot talk to the Docker daemon -- is it running, and may this user use it? (%w)", err)
|
||||||
|
}
|
||||||
|
if compose, err = Output(ctx, "compose", "version", "--short"); err != nil {
|
||||||
|
return engine, "", fmt.Errorf("the docker compose plugin is not installed (%w)", err)
|
||||||
|
}
|
||||||
|
return engine, compose, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectLeftovers lists containers, volumes and networks already labelled
|
||||||
|
// with a compose project name. Any at all means an earlier run of the same
|
||||||
|
// project, and its volumes would hand a "fresh" deployment an old server.
|
||||||
|
func ProjectLeftovers(ctx context.Context, project string) ([]string, error) {
|
||||||
|
filter := "label=com.docker.compose.project=" + project
|
||||||
|
var found []string
|
||||||
|
for _, kind := range []struct{ name, format string }{
|
||||||
|
{"container", "{{.Names}}"},
|
||||||
|
{"volume", "{{.Name}}"},
|
||||||
|
{"network", "{{.Name}}"},
|
||||||
|
} {
|
||||||
|
args := []string{kind.name, "ls", "--filter", filter, "--format", kind.format}
|
||||||
|
if kind.name == "container" {
|
||||||
|
args = []string{"ps", "-a", "--filter", filter, "--format", kind.format}
|
||||||
|
}
|
||||||
|
out, err := Output(ctx, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, line := range strings.Fields(out) {
|
||||||
|
found = append(found, kind.name+" "+line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose runs docker compose against one deployment directory.
|
||||||
|
type Compose struct {
|
||||||
|
Dir string
|
||||||
|
Files []string // extra -f files after compose.yaml, e.g. the bootstrap override
|
||||||
|
Env []string // added to the environment compose interpolates from
|
||||||
|
Out io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs a compose command with its output passed through: pulling images
|
||||||
|
// takes long enough that silence would look like a hang. Everything but a pull
|
||||||
|
// is quiet, because without a terminal compose prints each container's every
|
||||||
|
// state change twice and the tool already says what step it is on.
|
||||||
|
func (c Compose) Run(ctx context.Context, args ...string) error {
|
||||||
|
full := []string{"compose", "--project-directory", c.Dir, "-f", c.Dir + "/compose.yaml"}
|
||||||
|
if len(args) > 0 && args[0] != "pull" {
|
||||||
|
full = append(full, "--progress", "quiet")
|
||||||
|
}
|
||||||
|
for _, f := range c.Files {
|
||||||
|
full = append(full, "-f", f)
|
||||||
|
}
|
||||||
|
full = append(full, args...)
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", full...)
|
||||||
|
cmd.Env = append(os.Environ(), c.Env...)
|
||||||
|
cmd.Stdout, cmd.Stderr = c.Out, c.Out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("docker compose %s: %w", strings.Join(args, " "), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running reports whether a service has a running container.
|
||||||
|
func (c Compose) Running(ctx context.Context, service string) bool {
|
||||||
|
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
|
||||||
|
"ps", "--status", "running", "--services")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, s := range strings.Fields(out) {
|
||||||
|
if s == service {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs returns the last lines of one service's log, for a failure report.
|
||||||
|
func (c Compose) Logs(ctx context.Context, service string, lines int) string {
|
||||||
|
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
|
||||||
|
"logs", "--no-color", "--tail", fmt.Sprint(lines), service)
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemCABundle reads the CA bundle out of an image, so a private CA can be
|
||||||
|
// added to the roots the image already trusts rather than replacing them.
|
||||||
|
func SystemCABundle(ctx context.Context, image string) ([]byte, error) {
|
||||||
|
out, err := Output(ctx, "run", "--rm", "--entrypoint", "cat", image, "/etc/ssl/certs/ca-certificates.crt")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []byte(out + "\n"), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package render writes the deployment directory: compose.yaml, the Caddyfile,
|
||||||
|
// and the files holding secrets.
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.tmpl
|
||||||
|
var templates embed.FS
|
||||||
|
|
||||||
|
var tmpl = template.Must(template.New("").Funcs(template.FuncMap{
|
||||||
|
"join": strings.Join,
|
||||||
|
}).ParseFS(templates, "templates/*.tmpl"))
|
||||||
|
|
||||||
|
// Files the tool writes into a deployment directory, and nothing else. Destroy
|
||||||
|
// removes exactly these, so a file the operator added survives it.
|
||||||
|
const (
|
||||||
|
ComposeFile = "compose.yaml"
|
||||||
|
Caddyfile = "Caddyfile"
|
||||||
|
EnvFile = ".env"
|
||||||
|
CredentialsFile = "credentials.txt"
|
||||||
|
DNSFile = "dns-records.zone"
|
||||||
|
CARootFile = "acme-ca-root.pem"
|
||||||
|
CABundleFile = "ca-bundle.crt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Written lists every file name Destroy may remove.
|
||||||
|
var Written = []string{ComposeFile, Caddyfile, EnvFile, CredentialsFile, DNSFile, CARootFile, CABundleFile}
|
||||||
|
|
||||||
|
type data struct {
|
||||||
|
Version string
|
||||||
|
Plan config.Plan
|
||||||
|
CABundle bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compose renders compose.yaml.
|
||||||
|
func Compose(p config.Plan, version string, caBundle bool) ([]byte, error) {
|
||||||
|
return execute("compose.yaml.tmpl", data{Version: version, Plan: p, CABundle: caBundle})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caddy renders the Caddyfile.
|
||||||
|
func Caddy(p config.Plan, version string) ([]byte, error) {
|
||||||
|
return execute("Caddyfile.tmpl", data{Version: version, Plan: p})
|
||||||
|
}
|
||||||
|
|
||||||
|
func execute(name string, d data) ([]byte, error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := tmpl.ExecuteTemplate(&b, name, d); err != nil {
|
||||||
|
return nil, fmt.Errorf("render %s: %w", name, err)
|
||||||
|
}
|
||||||
|
return b.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env renders .env. Only the app secret lives here: compose.yaml reads it by
|
||||||
|
// interpolation, so the file compose.yaml sits in can be shown to someone
|
||||||
|
// without showing them the key every session is sealed with.
|
||||||
|
func Env(appSecret string) []byte {
|
||||||
|
return []byte("# Read by docker compose. Changing APP_SECRET signs everyone out.\nAPP_SECRET=" + appSecret + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrepareDir creates dir, or accepts it if it exists and is empty. Anything in
|
||||||
|
// it already is refused: the tool writes a fresh deployment, and silently
|
||||||
|
// replacing someone's compose.yaml is not a fresh deployment.
|
||||||
|
func PrepareDir(dir string) error {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, os.ErrNotExist):
|
||||||
|
return os.MkdirAll(dir, 0o750)
|
||||||
|
case err != nil:
|
||||||
|
return err
|
||||||
|
case len(entries) > 0:
|
||||||
|
return fmt.Errorf("%s already has files in it; give --dir a new or empty directory", dir)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile writes one file into dir, private if it holds a secret. It refuses
|
||||||
|
// to replace an existing file, for the same reason PrepareDir refuses a full
|
||||||
|
// directory.
|
||||||
|
func WriteFile(dir, name string, content []byte, secret bool) error {
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
if secret {
|
||||||
|
mode = 0o600
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Write(content); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendFile adds to a file WriteFile created, keeping its mode.
|
||||||
|
func AppendFile(dir, name string, content []byte) error {
|
||||||
|
f, err := os.OpenFile(filepath.Join(dir, name), os.O_WRONLY|os.O_APPEND, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Write(content); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return f.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func plan(t *testing.T, o config.Options) config.Plan {
|
||||||
|
t.Helper()
|
||||||
|
p, err := o.Validate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicCompose(t *testing.T) {
|
||||||
|
p := plan(t, config.Options{Domain: "example.com"})
|
||||||
|
out, err := Compose(p, "test", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
for _, want := range []string{
|
||||||
|
"name: ihasmail-example-com",
|
||||||
|
"hostname: mail.example.com",
|
||||||
|
`- "127.0.0.1:8081:8080"`,
|
||||||
|
`- "25:25"`, `- "465:465"`, `- "993:993"`, `- "995:995"`, `- "4190:4190"`,
|
||||||
|
`- "80:80"`, `- "443:443"`, `- "443:443/udp"`,
|
||||||
|
"STALWART_URL: http://stalwart:8080",
|
||||||
|
"APP_SECRET: ${APP_SECRET:?",
|
||||||
|
"PUSH_URL: https://webmail.example.com",
|
||||||
|
"read_only: true",
|
||||||
|
"ipv4_address: 172.31.253.11",
|
||||||
|
"caddy-data:",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Errorf("compose.yaml lacks %q:\n%s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Caddy's ports belong to Caddy; Stalwart must not also claim them.
|
||||||
|
stalwart := s[strings.Index(s, " stalwart:"):strings.Index(s, " ihasmail:")]
|
||||||
|
if strings.Contains(stalwart, `"80:80"`) || strings.Contains(stalwart, `"443:443"`) {
|
||||||
|
t.Errorf("Stalwart publishes 80 or 443:\n%s", stalwart)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "ca-bundle.crt") || strings.Contains(s, "acme-ca-root.pem") {
|
||||||
|
t.Error("CA files mounted with no private CA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalComposeHasNoCaddyAndNoMailPorts(t *testing.T) {
|
||||||
|
p := plan(t, config.Options{Local: true})
|
||||||
|
out, err := Compose(p, "test", false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
for _, unwanted := range []string{"caddy", `"25:25"`, "PUSH_URL"} {
|
||||||
|
if strings.Contains(s, unwanted) {
|
||||||
|
t.Errorf("local compose.yaml has %q:\n%s", unwanted, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaddyfile(t *testing.T) {
|
||||||
|
p := plan(t, config.Options{Domain: "example.com", MailHost: "mx.example.com", Email: "[email protected]"})
|
||||||
|
out, err := Caddy(p, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
for _, want := range []string{
|
||||||
|
"email [email protected]",
|
||||||
|
"webmail.example.com {",
|
||||||
|
"mx.example.com, autoconfig.example.com, autodiscover.example.com, mta-sts.example.com, ua-auto-config.example.com {",
|
||||||
|
"disable_http_challenge",
|
||||||
|
"http://mx.example.com, http://autoconfig.example.com, http://autodiscover.example.com, http://mta-sts.example.com, http://ua-auto-config.example.com {",
|
||||||
|
"handle /.well-known/acme-challenge/* {",
|
||||||
|
"flush_interval -1",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(s, want) {
|
||||||
|
t.Errorf("Caddyfile lacks %q:\n%s", want, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "acme_ca") {
|
||||||
|
t.Error("Caddyfile names a CA without --acme-directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
p = plan(t, config.Options{Domain: "example.com", ACMEDirectory: "https://ca.internal/dir", ACMECARoot: "/tmp/root.pem"})
|
||||||
|
out, _ = Caddy(p, "test")
|
||||||
|
for _, want := range []string{"acme_ca https://ca.internal/dir", "dir https://ca.internal/dir", "trusted_roots /etc/caddy/acme-ca-root.pem"} {
|
||||||
|
if !strings.Contains(string(out), want) {
|
||||||
|
t.Errorf("Caddyfile with a private CA lacks %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirectoryIsNeverOverwritten(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "d")
|
||||||
|
if err := PrepareDir(dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := WriteFile(dir, EnvFile, []byte("x"), true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if fi, _ := os.Stat(filepath.Join(dir, EnvFile)); fi.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("secret file mode %v", fi.Mode().Perm())
|
||||||
|
}
|
||||||
|
if err := WriteFile(dir, EnvFile, []byte("y"), true); err == nil {
|
||||||
|
t.Error("WriteFile replaced an existing file")
|
||||||
|
}
|
||||||
|
if err := PrepareDir(dir); err == nil {
|
||||||
|
t.Error("PrepareDir accepted a directory with files in it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Written by ihasmail-oneshot {{.Version}} for {{.Plan.Domain}}.
|
||||||
|
#
|
||||||
|
# Caddy holds ports 80 and 443 for two things that both want certificates for
|
||||||
|
# some of the same names: Caddy itself, to serve HTTPS, and Stalwart, whose
|
||||||
|
# IMAP and SMTP listeners need a certificate of their own. They are kept apart
|
||||||
|
# by challenge type rather than by name:
|
||||||
|
#
|
||||||
|
# Caddy TLS-ALPN-01 on 443 -- for Stalwart's names it never uses port 80.
|
||||||
|
# Stalwart HTTP-01 on 80, which Caddy forwards to it untouched.
|
||||||
|
#
|
||||||
|
# So neither answers the other's challenge, and neither needs the other's key.
|
||||||
|
|
||||||
|
{
|
||||||
|
email {{.Plan.Email}}
|
||||||
|
{{- if .Plan.ACMEDirectory}}
|
||||||
|
acme_ca {{.Plan.ACMEDirectory}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Plan.ACMECARoot}}
|
||||||
|
acme_ca_root /etc/caddy/acme-ca-root.pem
|
||||||
|
{{- end}}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The webmail. Push arrives as Server-Sent Events, so responses are flushed as
|
||||||
|
# they are written rather than buffered.
|
||||||
|
{{.Plan.WebmailHost}} {
|
||||||
|
encode zstd gzip
|
||||||
|
reverse_proxy ihasmail:8080 {
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stalwart's web side: its admin UI, JMAP for other clients, CalDAV, CardDAV,
|
||||||
|
# autoconfig and MTA-STS. Stalwart is told to believe the X-Forwarded-For Caddy
|
||||||
|
# sets here, so a scanner is banned by its own address and not by Caddy's.
|
||||||
|
{{join .Plan.StalwartNames ", "}} {
|
||||||
|
tls {
|
||||||
|
issuer acme {
|
||||||
|
{{- if .Plan.ACMEDirectory}}
|
||||||
|
dir {{.Plan.ACMEDirectory}}
|
||||||
|
{{- end}}
|
||||||
|
{{- if .Plan.ACMECARoot}}
|
||||||
|
trusted_roots /etc/caddy/acme-ca-root.pem
|
||||||
|
{{- end}}
|
||||||
|
email {{.Plan.Email}}
|
||||||
|
disable_http_challenge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverse_proxy stalwart:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
# Port 80 for Stalwart's names is Stalwart's challenge path and a redirect.
|
||||||
|
{{range $i, $n := .Plan.StalwartNames}}{{if $i}}, {{end}}http://{{$n}}{{end}} {
|
||||||
|
handle /.well-known/acme-challenge/* {
|
||||||
|
reverse_proxy stalwart:8080
|
||||||
|
}
|
||||||
|
handle {
|
||||||
|
redir https://{host}{uri} 308
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Written by ihasmail-oneshot {{.Version}} for {{.Plan.Domain}}.
|
||||||
|
#
|
||||||
|
# This is the whole deployment: bring it up again with `docker compose up -d`
|
||||||
|
# from this directory. Secrets are in .env next to it, and the Stalwart
|
||||||
|
# administrator's password is in credentials.txt -- both readable only by you.
|
||||||
|
#
|
||||||
|
# Stalwart's plain-HTTP port is reachable only on the private network below and
|
||||||
|
# on {{.Plan.StalwartBind}}. ihasmail talks to it over that network, which is
|
||||||
|
# why STALWART_URL is http://: the leg never leaves this host.
|
||||||
|
name: {{.Plan.Project}}
|
||||||
|
|
||||||
|
services:
|
||||||
|
stalwart:
|
||||||
|
image: {{.Plan.StalwartImage}}
|
||||||
|
hostname: {{.Plan.MailHost}}
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "{{.Plan.StalwartBind}}:8080"
|
||||||
|
{{- range .Plan.PublishedPorts}}{{if and (ne . 80) (ne . 443)}}
|
||||||
|
- "{{.}}:{{.}}"
|
||||||
|
{{- end}}{{end}}
|
||||||
|
volumes:
|
||||||
|
- stalwart-etc:/etc/stalwart
|
||||||
|
- stalwart-data:/var/lib/stalwart
|
||||||
|
{{- if .CABundle}}
|
||||||
|
# The system roots plus the private ACME CA, so Stalwart can reach it.
|
||||||
|
- ./ca-bundle.crt:/etc/ssl/certs/ca-certificates.crt:ro
|
||||||
|
{{- end}}
|
||||||
|
networks:
|
||||||
|
stack:
|
||||||
|
ipv4_address: {{.Plan.StalwartIP}}
|
||||||
|
|
||||||
|
ihasmail:
|
||||||
|
image: {{.Plan.IhasmailImage}}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [stalwart]
|
||||||
|
# Immutable: read-only root, no volume, sessions in memory. A restart signs
|
||||||
|
# everyone out; nothing else is lost, because nothing else is kept here.
|
||||||
|
read_only: true
|
||||||
|
tmpfs: [/tmp]
|
||||||
|
ports:
|
||||||
|
- "{{.Plan.WebmailBind}}:8080"
|
||||||
|
environment:
|
||||||
|
STALWART_URL: http://stalwart:8080
|
||||||
|
APP_SECRET: ${APP_SECRET:?APP_SECRET is missing from .env}
|
||||||
|
IMMUTABLE: "1"
|
||||||
|
SESSION_FILE: ""
|
||||||
|
TRUST_PROXY: "1"
|
||||||
|
IMAGE_PROXY: "1"
|
||||||
|
{{- if not .Plan.Local}}
|
||||||
|
# Stalwart pushes changes to this URL instead of holding a connection per
|
||||||
|
# tab. If it cannot reach it, every tab uses the relay; nothing breaks.
|
||||||
|
PUSH_URL: https://{{.Plan.WebmailHost}}
|
||||||
|
{{- end}}
|
||||||
|
networks:
|
||||||
|
stack:
|
||||||
|
ipv4_address: {{.Plan.IhasmailIP}}
|
||||||
|
{{- if not .Plan.Local}}
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: {{.Plan.CaddyImage}}
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [ihasmail, stalwart]
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
- "443:443/udp"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
- caddy-data:/data
|
||||||
|
- caddy-config:/config
|
||||||
|
{{- if .Plan.ACMECARoot}}
|
||||||
|
- ./acme-ca-root.pem:/etc/caddy/acme-ca-root.pem:ro
|
||||||
|
{{- end}}
|
||||||
|
networks:
|
||||||
|
stack:
|
||||||
|
ipv4_address: {{.Plan.CaddyIP}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
stack:
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: {{.Plan.Subnet}}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
stalwart-etc:
|
||||||
|
stalwart-data:
|
||||||
|
{{- if not .Plan.Local}}
|
||||||
|
# Certificates and the ACME account. Losing this means asking for every
|
||||||
|
# certificate again, which is how rate limits are reached.
|
||||||
|
caddy-data:
|
||||||
|
caddy-config:
|
||||||
|
{{- end}}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package stalwart configures a fresh Stalwart 0.16 over JMAP.
|
||||||
|
//
|
||||||
|
// 0.16 has no REST management API and no configuration file to template: a
|
||||||
|
// server with an empty /etc/stalwart starts in bootstrap mode, and everything
|
||||||
|
// from the first administrator to the ACME account is a registry object read
|
||||||
|
// and written with x: methods. Every call here was worked out against a real
|
||||||
|
// 0.16.22, not the documentation.
|
||||||
|
package stalwart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var using = []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"}
|
||||||
|
|
||||||
|
// Client calls one Stalwart as one account. The zero HTTP uses a client with a
|
||||||
|
// timeout, so a hung server fails a step instead of hanging the tool.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string // e.g. http://127.0.0.1:8081, no trailing slash
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call is one method call in a request.
|
||||||
|
type Call struct {
|
||||||
|
Method string
|
||||||
|
Args any
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response is one method response.
|
||||||
|
type Response struct {
|
||||||
|
Method string
|
||||||
|
Args json.RawMessage
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodError is a JMAP method-level error: the request was fine, the call was
|
||||||
|
// refused.
|
||||||
|
type MethodError struct {
|
||||||
|
Method string
|
||||||
|
Type string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MethodError) Error() string {
|
||||||
|
if e.Description != "" {
|
||||||
|
return fmt.Sprintf("%s: %s: %s", e.Method, e.Type, e.Description)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s: %s", e.Method, e.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPError is a response that was not a JMAP response at all.
|
||||||
|
type HTTPError struct {
|
||||||
|
Status int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *HTTPError) Error() string {
|
||||||
|
return fmt.Sprintf("HTTP %d: %s", e.Status, strings.TrimSpace(e.Body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) httpClient() *http.Client {
|
||||||
|
if c.HTTP != nil {
|
||||||
|
return c.HTTP
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do sends calls in one request and returns their responses in order. A method
|
||||||
|
// error in any of them is returned as a *MethodError, after the responses
|
||||||
|
// before it -- JMAP stops nothing on an error, but every caller here needs
|
||||||
|
// all of its calls to have worked.
|
||||||
|
func (c *Client) Do(ctx context.Context, calls ...Call) ([]Response, error) {
|
||||||
|
mc := make([][3]any, len(calls))
|
||||||
|
for i, call := range calls {
|
||||||
|
mc[i] = [3]any{call.Method, call.Args, call.ID}
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(map[string]any{"using": using, "methodCalls": mc})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/jmap/", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(c.Username, c.Password)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res, err := c.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(res.Body, 16<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return nil, &HTTPError{Status: res.StatusCode, Body: string(raw)}
|
||||||
|
}
|
||||||
|
var envelope struct {
|
||||||
|
MethodResponses [][3]json.RawMessage `json:"methodResponses"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||||
|
return nil, fmt.Errorf("not a JMAP response: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]Response, 0, len(envelope.MethodResponses))
|
||||||
|
for _, mr := range envelope.MethodResponses {
|
||||||
|
var r Response
|
||||||
|
if err := json.Unmarshal(mr[0], &r.Method); err != nil {
|
||||||
|
return nil, fmt.Errorf("not a JMAP response: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(mr[2], &r.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("not a JMAP response: %w", err)
|
||||||
|
}
|
||||||
|
r.Args = mr[1]
|
||||||
|
if r.Method == "error" {
|
||||||
|
var e struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(r.Args, &e)
|
||||||
|
method := r.ID
|
||||||
|
for _, call := range calls {
|
||||||
|
if call.ID == r.ID {
|
||||||
|
method = call.Method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, &MethodError{Method: method, Type: e.Type, Description: e.Description}
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
if len(out) != len(calls) {
|
||||||
|
return out, fmt.Errorf("sent %d method calls and got %d responses", len(calls), len(out))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetError is one entry of notCreated, notUpdated or notDestroyed.
|
||||||
|
type SetError struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Properties []string `json:"properties"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e SetError) Error() string {
|
||||||
|
s := e.Type
|
||||||
|
if e.Description != "" {
|
||||||
|
s += ": " + e.Description
|
||||||
|
}
|
||||||
|
if len(e.Properties) > 0 {
|
||||||
|
s += " (" + strings.Join(e.Properties, ", ") + ")"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetResult is the part of a /set response every caller here reads.
|
||||||
|
type SetResult struct {
|
||||||
|
Created map[string]json.RawMessage `json:"created"`
|
||||||
|
Updated map[string]json.RawMessage `json:"updated"`
|
||||||
|
NotCreated map[string]SetError `json:"notCreated"`
|
||||||
|
NotUpdated map[string]SetError `json:"notUpdated"`
|
||||||
|
NotDestroyed map[string]SetError `json:"notDestroyed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refused returns the first refusal in the result, if any.
|
||||||
|
func (r SetResult) Refused() error {
|
||||||
|
for _, m := range []map[string]SetError{r.NotCreated, r.NotUpdated, r.NotDestroyed} {
|
||||||
|
for id, e := range m {
|
||||||
|
return fmt.Errorf("%s: %w", id, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSet(r Response) (SetResult, error) {
|
||||||
|
var s SetResult
|
||||||
|
if err := json.Unmarshal(r.Args, &s); err != nil {
|
||||||
|
return s, fmt.Errorf("%s: %w", r.Method, err)
|
||||||
|
}
|
||||||
|
if err := s.Refused(); err != nil {
|
||||||
|
return s, fmt.Errorf("%s refused %w", r.Method, err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createdID reads the server-assigned id of a created object.
|
||||||
|
func createdID(s SetResult, key string) (string, error) {
|
||||||
|
raw, ok := s.Created[key]
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("the server did not confirm the create")
|
||||||
|
}
|
||||||
|
var obj struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &obj); err != nil || obj.ID == "" {
|
||||||
|
return "", errors.New("the server confirmed the create without an id")
|
||||||
|
}
|
||||||
|
return obj.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live reports whether Stalwart answers its liveness probe, which it does in
|
||||||
|
// bootstrap mode too.
|
||||||
|
func Live(ctx context.Context, baseURL string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz/live", nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return &HTTPError{Status: res.StatusCode}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package stalwart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake answers each request with the response registered for its first
|
||||||
|
// method, and records what it was sent.
|
||||||
|
func fake(t *testing.T, responses map[string]string) (*Client, *[]map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
var seen []map[string]any
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if u, p, _ := r.BasicAuth(); u != "admin" || p != "pw" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Using []string `json:"using"`
|
||||||
|
MethodCalls [][3]json.RawMessage `json:"methodCalls"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Errorf("bad request body: %v", err)
|
||||||
|
}
|
||||||
|
var method string
|
||||||
|
_ = json.Unmarshal(req.MethodCalls[0][0], &method)
|
||||||
|
var args map[string]any
|
||||||
|
_ = json.Unmarshal(req.MethodCalls[0][1], &args)
|
||||||
|
seen = append(seen, map[string]any{"method": method, "args": args, "using": req.Using})
|
||||||
|
body, ok := responses[method]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("unexpected method %s", method)
|
||||||
|
}
|
||||||
|
w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return &Client{BaseURL: srv.URL, Username: "admin", Password: "pw"}, &seen
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBootstrapReturnsTheAdministrator(t *testing.T) {
|
||||||
|
c, seen := fake(t, map[string]string{
|
||||||
|
"x:Bootstrap/set": `{"methodResponses":[["x:Bootstrap/set",{"updated":{"singleton":{"username":"[email protected]","secret":"s3cret"}}},"0"]]}`,
|
||||||
|
})
|
||||||
|
a, err := c.Bootstrap(context.Background(), "mail.example.com", "example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.Username != "[email protected]" || a.Secret != "s3cret" {
|
||||||
|
t.Errorf("admin = %+v", a)
|
||||||
|
}
|
||||||
|
update := (*seen)[0]["args"].(map[string]any)["update"].(map[string]any)["singleton"].(map[string]any)
|
||||||
|
if update["requestTlsCertificate"] != false || update["tracer"].(map[string]any)["@type"] != "Stdout" {
|
||||||
|
t.Errorf("bootstrap sent %v", update)
|
||||||
|
}
|
||||||
|
if using := (*seen)[0]["using"].([]string); len(using) != 2 || using[1] != "urn:stalwart:jmap" {
|
||||||
|
t.Errorf("using = %v", using)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetRefusalIsAnError(t *testing.T) {
|
||||||
|
c, _ := fake(t, map[string]string{
|
||||||
|
"x:Account/set": `{"methodResponses":[["x:Account/set",{"notCreated":{"user":{"type":"invalidPatch","description":"Missing or invalid '@type' property in object","properties":["roles"]}}},"0"]]}`,
|
||||||
|
})
|
||||||
|
_, err := c.CreateUser(context.Background(), "alice", "b", "pw")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalidPatch") || !strings.Contains(err.Error(), "roles") {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMethodErrorNamesTheMethod(t *testing.T) {
|
||||||
|
c, _ := fake(t, map[string]string{
|
||||||
|
"x:Bootstrap/get": `{"methodResponses":[["error",{"type":"unknownMethod"},"0"]]}`,
|
||||||
|
})
|
||||||
|
err := c.CheckBootstrapMode(context.Background())
|
||||||
|
var me *MethodError
|
||||||
|
if !errors.As(err, &me) || me.Method != "x:Bootstrap/get" || me.Type != "unknownMethod" {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not in bootstrap mode") {
|
||||||
|
t.Errorf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredServerRefusesBootstrapCredentials(t *testing.T) {
|
||||||
|
c, _ := fake(t, nil)
|
||||||
|
c.Password = "the-bootstrap-password"
|
||||||
|
if err := c.CheckBootstrapMode(context.Background()); err == nil || !strings.Contains(err.Error(), "not in bootstrap mode") {
|
||||||
|
t.Fatalf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnableACMEUsesHTTP01AndTheBackReference(t *testing.T) {
|
||||||
|
c, seen := fake(t, map[string]string{
|
||||||
|
"x:AcmeProvider/set": `{"methodResponses":[["x:AcmeProvider/set",{"created":{"acme":{"id":"p1"}}},"0"],["x:Domain/set",{"updated":{"b":null}},"1"]]}`,
|
||||||
|
})
|
||||||
|
id, err := c.EnableACME(context.Background(), "b", "", "[email protected]")
|
||||||
|
if err != nil || id != "p1" {
|
||||||
|
t.Fatalf("id %q, err %v", id, err)
|
||||||
|
}
|
||||||
|
provider := (*seen)[0]["args"].(map[string]any)["create"].(map[string]any)["acme"].(map[string]any)
|
||||||
|
if provider["challengeType"] != "Http01" {
|
||||||
|
t.Errorf("challengeType = %v", provider["challengeType"])
|
||||||
|
}
|
||||||
|
if _, set := provider["directory"]; set {
|
||||||
|
t.Error("directory sent without --acme-directory; Stalwart's default is Let's Encrypt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDomainIDNotFound(t *testing.T) {
|
||||||
|
c, _ := fake(t, map[string]string{
|
||||||
|
"x:Domain/query": `{"methodResponses":[["x:Domain/query",{"ids":["b"]},"0"],["x:Domain/get",{"list":[{"id":"b","name":"other.test"}]},"1"]]}`,
|
||||||
|
})
|
||||||
|
if _, err := c.DomainID(context.Background(), "example.com"); err == nil {
|
||||||
|
t.Fatal("found a domain that is not there")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package stalwart
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admin is the permanent administrator bootstrap provisions.
|
||||||
|
type Admin struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckBootstrapMode confirms the server is fresh. x:Bootstrap exists only in
|
||||||
|
// bootstrap mode, so a server that has been set up before -- a volume left
|
||||||
|
// over from an earlier run, say -- refuses the call, and the tool stops before
|
||||||
|
// writing anything into someone's configured mail server.
|
||||||
|
func (c *Client) CheckBootstrapMode(ctx context.Context) error {
|
||||||
|
_, err := c.Do(ctx, Call{"x:Bootstrap/get", map[string]any{"ids": []string{"singleton"}, "properties": []string{"id"}}, "0"})
|
||||||
|
var me *MethodError
|
||||||
|
var he *HTTPError
|
||||||
|
// A configured server either refuses the method or, having no bootstrap
|
||||||
|
// account any more, the credentials.
|
||||||
|
if errors.As(err, &me) || (errors.As(err, &he) && he.Status == 401) {
|
||||||
|
return fmt.Errorf("this Stalwart is not in bootstrap mode, so it has been configured before (%w)", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap completes the setup wizard the web UI would otherwise walk someone
|
||||||
|
// through, and returns the administrator it creates. The temporary bootstrap
|
||||||
|
// account stops working once the server restarts out of bootstrap mode.
|
||||||
|
func (c *Client) Bootstrap(ctx context.Context, hostname, domain string) (Admin, error) {
|
||||||
|
update := map[string]any{
|
||||||
|
"serverHostname": hostname,
|
||||||
|
"defaultDomain": domain,
|
||||||
|
// Off, and done explicitly afterwards (see EnableACME): on 0.16.22 this
|
||||||
|
// flag creates no ACME provider and leaves the domain on manual
|
||||||
|
// certificates, so turning it on would only look like it had worked.
|
||||||
|
"requestTlsCertificate": false,
|
||||||
|
"generateDkimKeys": true,
|
||||||
|
// The default logs to /var/log/stalwart, which does not exist in the
|
||||||
|
// image and is not a volume. A container logs to stdout.
|
||||||
|
"tracer": map[string]any{
|
||||||
|
"@type": "Stdout", "enable": true, "level": "info",
|
||||||
|
"ansi": false, "multiline": false, "lossy": false,
|
||||||
|
"events": map[string]any{}, "eventsPolicy": "exclude",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
rs, err := c.Do(ctx, Call{"x:Bootstrap/set", map[string]any{"update": map[string]any{"singleton": update}}, "0"})
|
||||||
|
if err != nil {
|
||||||
|
return Admin{}, err
|
||||||
|
}
|
||||||
|
s, err := decodeSet(rs[0])
|
||||||
|
if err != nil {
|
||||||
|
return Admin{}, err
|
||||||
|
}
|
||||||
|
var a Admin
|
||||||
|
if err := json.Unmarshal(s.Updated["singleton"], &a); err != nil || a.Username == "" || a.Secret == "" {
|
||||||
|
return Admin{}, errors.New("x:Bootstrap/set did not return the administrator it created")
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainID finds a domain by name.
|
||||||
|
func (c *Client) DomainID(ctx context.Context, name string) (string, error) {
|
||||||
|
rs, err := c.Do(ctx,
|
||||||
|
Call{"x:Domain/query", map[string]any{}, "0"},
|
||||||
|
Call{"x:Domain/get", map[string]any{
|
||||||
|
"#ids": map[string]any{"resultOf": "0", "name": "x:Domain/query", "path": "/ids"},
|
||||||
|
"properties": []string{"name"},
|
||||||
|
}, "1"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
List []struct{ ID, Name string } `json:"list"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rs[1].Args, &got); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for _, d := range got.List {
|
||||||
|
if d.Name == name {
|
||||||
|
return d.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("domain %s does not exist on this server", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableACME creates an ACME account using HTTP-01 and moves the domain's
|
||||||
|
// certificates onto it. Stalwart starts the order at once, with no restart.
|
||||||
|
//
|
||||||
|
// HTTP-01 rather than Stalwart's default TLS-ALPN-01, because Caddy holds 443.
|
||||||
|
// Caddy forwards /.well-known/acme-challenge/ on port 80 for Stalwart's names
|
||||||
|
// to Stalwart and uses TLS-ALPN-01 itself, so the two never compete.
|
||||||
|
func (c *Client) EnableACME(ctx context.Context, domainID, directory, contact string) (string, error) {
|
||||||
|
provider := map[string]any{
|
||||||
|
"challengeType": "Http01",
|
||||||
|
"contact": map[string]bool{contact: true},
|
||||||
|
"renewBefore": "R23",
|
||||||
|
"maxRetries": 10,
|
||||||
|
"reuseKey": false,
|
||||||
|
}
|
||||||
|
if directory != "" {
|
||||||
|
provider["directory"] = directory
|
||||||
|
}
|
||||||
|
rs, err := c.Do(ctx,
|
||||||
|
Call{"x:AcmeProvider/set", map[string]any{"create": map[string]any{"acme": provider}}, "0"},
|
||||||
|
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: automaticCertificates("#acme")}}, "1"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s, err := decodeSet(rs[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
id, err := createdID(s, "acme")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("x:AcmeProvider/set: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := decodeSet(rs[1]); err != nil {
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryCertificates starts a fresh ACME order for the domain. A failed order
|
||||||
|
// is not retried on a restart; moving the domain to manual and straight back
|
||||||
|
// is what starts a new one.
|
||||||
|
func (c *Client) RetryCertificates(ctx context.Context, domainID string) error {
|
||||||
|
var got struct {
|
||||||
|
List []struct {
|
||||||
|
CertificateManagement struct {
|
||||||
|
Type string `json:"@type"`
|
||||||
|
AcmeProviderID string `json:"acmeProviderId"`
|
||||||
|
} `json:"certificateManagement"`
|
||||||
|
} `json:"list"`
|
||||||
|
}
|
||||||
|
rs, err := c.Do(ctx, Call{"x:Domain/get", map[string]any{"ids": []string{domainID}, "properties": []string{"certificateManagement"}}, "0"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rs[0].Args, &got); err != nil || len(got.List) != 1 {
|
||||||
|
return errors.New("x:Domain/get did not return the domain")
|
||||||
|
}
|
||||||
|
cm := got.List[0].CertificateManagement
|
||||||
|
if cm.Type != "Automatic" || cm.AcmeProviderID == "" {
|
||||||
|
return fmt.Errorf("the domain's certificates are %q, not managed by ACME", cm.Type)
|
||||||
|
}
|
||||||
|
rs, err = c.Do(ctx,
|
||||||
|
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: map[string]any{"certificateManagement": map[string]any{"@type": "Manual"}}}}, "0"},
|
||||||
|
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: automaticCertificates(cm.AcmeProviderID)}}, "1"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, r := range rs {
|
||||||
|
if _, err := decodeSet(r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func automaticCertificates(providerID string) map[string]any {
|
||||||
|
return map[string]any{"certificateManagement": map[string]any{
|
||||||
|
"@type": "Automatic",
|
||||||
|
"acmeProviderId": providerID,
|
||||||
|
// Empty is Stalwart's default set: the mail host plus autoconfig,
|
||||||
|
// autodiscover, mta-sts and ua-auto-config under the domain.
|
||||||
|
"subjectAlternativeNames": map[string]bool{},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificate is what the tool reports about an issued certificate.
|
||||||
|
type Certificate struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
NotValidAfter string `json:"notValidAfter"`
|
||||||
|
SubjectAlternativeNames map[string]bool `json:"subjectAlternativeNames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificates lists the certificates Stalwart holds.
|
||||||
|
func (c *Client) Certificates(ctx context.Context) ([]Certificate, error) {
|
||||||
|
rs, err := c.Do(ctx,
|
||||||
|
Call{"x:Certificate/query", map[string]any{}, "0"},
|
||||||
|
Call{"x:Certificate/get", map[string]any{
|
||||||
|
"#ids": map[string]any{"resultOf": "0", "name": "x:Certificate/query", "path": "/ids"},
|
||||||
|
"properties": []string{"issuer", "notValidAfter", "subjectAlternativeNames"},
|
||||||
|
}, "1"},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
List []Certificate `json:"list"`
|
||||||
|
}
|
||||||
|
return got.List, json.Unmarshal(rs[1].Args, &got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustForwardedFor makes Stalwart take a client's address from
|
||||||
|
// X-Forwarded-For. Its auto-ban works per address: behind Caddy, without this,
|
||||||
|
// one scanner probing for WordPress bans Caddy -- and with it every autoconfig
|
||||||
|
// lookup, DAV client and certificate renewal that comes through it. Seen on
|
||||||
|
// 0.16.22, as was the fix. It applies only once Stalwart restarts.
|
||||||
|
//
|
||||||
|
// Safe here because nothing untrusted reaches Stalwart's HTTP port: it is
|
||||||
|
// published on loopback only, and on the private network the only peers are
|
||||||
|
// Caddy, which sets the header itself, and ihasmail, which sends none.
|
||||||
|
func (c *Client) TrustForwardedFor(ctx context.Context) error {
|
||||||
|
rs, err := c.Do(ctx, Call{"x:Http/set", map[string]any{"update": map[string]any{"singleton": map[string]any{"useXForwarded": true}}}, "0"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = decodeSet(rs[0])
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllowIP exempts an address from the auto-ban. It applies only once Stalwart
|
||||||
|
// restarts.
|
||||||
|
func (c *Client) AllowIP(ctx context.Context, address, reason string) error {
|
||||||
|
rs, err := c.Do(ctx, Call{"x:AllowedIp/set", map[string]any{"create": map[string]any{
|
||||||
|
"allow": map[string]any{"address": address, "reason": reason},
|
||||||
|
}}, "0"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s, err := decodeSet(rs[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = createdID(s, "allow")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates an ordinary mailbox, in the shape ihasmail's own
|
||||||
|
// Administration creates one.
|
||||||
|
func (c *Client) CreateUser(ctx context.Context, name, domainID, password string) (string, error) {
|
||||||
|
rs, err := c.Do(ctx, Call{"x:Account/set", map[string]any{"create": map[string]any{
|
||||||
|
"user": map[string]any{
|
||||||
|
"@type": "User",
|
||||||
|
"name": name,
|
||||||
|
"domainId": domainID,
|
||||||
|
"description": nil,
|
||||||
|
"credentials": map[string]any{"0": map[string]any{"@type": "Password", "secret": password}},
|
||||||
|
"roles": map[string]any{"@type": "User"},
|
||||||
|
"permissions": map[string]any{"@type": "Inherit"},
|
||||||
|
"quotas": map[string]any{},
|
||||||
|
"aliases": map[string]any{},
|
||||||
|
"memberGroupIds": map[string]any{},
|
||||||
|
// Required on create. Turning it on cannot be undone, which is not a
|
||||||
|
// decision for a deploy tool to make on anyone's behalf.
|
||||||
|
"encryptionAtRest": map[string]any{"@type": "Disabled"},
|
||||||
|
},
|
||||||
|
}}, "0"})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s, err := decodeSet(rs[0])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return createdID(s, "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNSZone returns the records Stalwart wants published for the domain, as a
|
||||||
|
// zone file fragment: MX, SPF, DKIM, DMARC, the SRV records, MTA-STS and the
|
||||||
|
// autoconfig names. It has no A or AAAA records; those depend on the host.
|
||||||
|
func (c *Client) DNSZone(ctx context.Context, domainID string) (string, error) {
|
||||||
|
rs, err := c.Do(ctx, Call{"x:Domain/get", map[string]any{"ids": []string{domainID}, "properties": []string{"dnsZoneFile"}}, "0"})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
List []struct {
|
||||||
|
DNSZoneFile string `json:"dnsZoneFile"`
|
||||||
|
} `json:"list"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(rs[0].Args, &got); err != nil || len(got.List) != 1 {
|
||||||
|
return "", errors.New("x:Domain/get did not return the domain")
|
||||||
|
}
|
||||||
|
return got.List[0].DNSZoneFile, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package webmail checks a running ihasmail from the outside, the way a
|
||||||
|
// browser would reach it.
|
||||||
|
package webmail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Health is the part of /api/health the tool reports.
|
||||||
|
type Health struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckHealth reads /api/health once.
|
||||||
|
func CheckHealth(ctx context.Context, baseURL string) (Health, error) {
|
||||||
|
var h Health
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/health", nil)
|
||||||
|
if err != nil {
|
||||||
|
return h, err
|
||||||
|
}
|
||||||
|
res, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return h, err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return h, fmt.Errorf("/api/health answered HTTP %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&h); err != nil {
|
||||||
|
return h, fmt.Errorf("/api/health: %w", err)
|
||||||
|
}
|
||||||
|
if !h.OK {
|
||||||
|
return h, fmt.Errorf("/api/health reports not ok")
|
||||||
|
}
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignIn signs in through ihasmail and straight back out. It is the proof the
|
||||||
|
// two are linked: ihasmail can only accept the credentials by presenting them
|
||||||
|
// to Stalwart over STALWART_URL and getting a JMAP session back.
|
||||||
|
//
|
||||||
|
// It returns whether the session carries Stalwart's own capability, which is
|
||||||
|
// what ihasmail keys its administration features on.
|
||||||
|
func SignIn(ctx context.Context, baseURL, username, password string) (stalwartCapability bool, err error) {
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second, Jar: jar}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"username": username, "password": password})
|
||||||
|
res, err := post(ctx, client, baseURL+"/api/auth/login", body)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
|
||||||
|
res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return false, fmt.Errorf("sign-in as %s answered HTTP %d: %s", username, res.StatusCode, strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
var session struct {
|
||||||
|
Accounts map[string]struct {
|
||||||
|
AccountCapabilities map[string]json.RawMessage `json:"accountCapabilities"`
|
||||||
|
} `json:"accounts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &session); err != nil || len(session.Accounts) == 0 {
|
||||||
|
return false, fmt.Errorf("sign-in as %s did not return a JMAP session", username)
|
||||||
|
}
|
||||||
|
for _, a := range session.Accounts {
|
||||||
|
if _, ok := a.AccountCapabilities["urn:stalwart:jmap"]; ok {
|
||||||
|
stalwartCapability = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not leaving a session behind matters little with sessions in memory, but
|
||||||
|
// it costs one request.
|
||||||
|
if res, err := post(ctx, client, baseURL+"/api/auth/logout", []byte("{}")); err == nil {
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
return stalwartCapability, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func post(ctx context.Context, client *http.Client, url string, body []byte) (*http.Response, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
// ihasmail's CSRF check for a request that is not same-origin by
|
||||||
|
// Sec-Fetch-Site.
|
||||||
|
req.Header.Set("X-Requested-With", "ihasmail")
|
||||||
|
return client.Do(req)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user