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:
@@ -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).
|
||||
Reference in New Issue
Block a user