diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..0c593be --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# Funding platforms shown behind the repository's Sponsor button. +# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository + +github: jcoffey-dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7577f81 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,69 @@ +# Publish a release when a version tag is pushed: test, check for known +# vulnerabilities, build the Linux binaries, attach them with their checksums. +# +# Tags are the date of the commit, as ihasmail's are: v2026.9.13, and +# v2026.9.13.1 for a second release on the same day. +name: Release + +on: + push: + tags: ["v*"] + # For a tag whose run never started. GitHub has queued and then orphaned + # runs before, and a pushed tag has no other way to trigger this again. + workflow_dispatch: + inputs: + tag: + description: "Existing tag to release, e.g. v2026.9.13" + required: true + type: string + +concurrency: + group: release-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + env: + TAG: ${{ github.event.inputs.tag || github.ref_name }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + fetch-depth: 0 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + + - name: Check the tag names a commit on main + run: | + set -euo pipefail + git fetch --quiet origin main + git merge-base --is-ancestor "$(git rev-parse "$TAG^{commit}")" origin/main \ + || { echo "::error::$TAG is not on main"; exit 1; } + + - name: Vet and test + run: | + go vet ./... + go test ./... + + - name: Known vulnerabilities + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + + - name: Build + run: | + SOURCE_DATE_EPOCH="$(git log -1 --format=%ct "$TAG")" scripts/build-release.sh "$TAG" dist + + - name: Publish + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" dist/* --clobber + else + gh release create "$TAG" dist/* --verify-tag --title "$TAG" --generate-notes + fi diff --git a/README.md b/README.md index c0fc059..53915dc 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,189 @@ # 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: +[![Latest release](https://img.shields.io/github/v/release/Coffey-Labs/ihasmail-oneshot?sort=date)](https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest) +[![License: GPL-3.0-or-later](https://img.shields.io/badge/license-GPL--3.0--or--later-blue)](LICENSE) + +**One command that turns a Linux Docker host into a working mail server with +webmail.** It deploys a fresh [Stalwart](https://stalw.art) mail server and a +fresh [ihasmail](https://github.com/Coffey-Labs/ihasmail) webmail, links them +together, gets them certificates, and hands you the administrator password and +the DNS records to publish. ```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. +When it finishes you have a mail server for `example.com`, webmail at +`https://webmail.example.com`, Stalwart's admin UI at +`https://mail.example.com/admin`, and a mailbox for `alice@example.com`. Once +you publish the DNS records it hands you, mail flows in and out. What it +leaves behind is an ordinary `docker compose` project, managed with the usual +commands and not tied to this tool. -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 +- [What it is](#what-it-is) +- [What it does](#what-it-does) +- [Why it exists](#why-it-exists) +- [Requirements](#requirements) +- [Installing](#installing) +- [Trying it locally in two minutes](#trying-it-locally-in-two-minutes) +- [Deploying a mail host, step by step](#deploying-a-mail-host-step-by-step) +- [The deployment directory](#the-deployment-directory) +- [Running it afterwards](#running-it-afterwards) +- [Command reference](#command-reference) +- [How it works](#how-it-works) +- [Security model](#security-model) +- [Troubleshooting](#troubleshooting) +- [Known limits](#known-limits) +- [Development and testing](#development-and-testing) +- [Versions and releases](#versions-and-releases) +- [License](#license) + +--- + +## What it is + +A single, statically linked binary for Linux (amd64 and arm64). You run it on +the Docker host that should become the mail server. It needs Docker and the +compose plugin, and nothing else: no configuration file to write first, no +language runtime, no other scripts. + +It deploys three containers: + +| Container | Image | Role | +| --- | --- | --- | +| **Stalwart** | `stalwartlabs/stalwart` | The mail server: SMTP, IMAP, POP3, JMAP, CalDAV, CardDAV, spam filtering, DKIM. It holds all the mail and all the accounts | +| **ihasmail** | `ghcr.io/coffey-labs/ihasmail` | The webmail: mail, calendars, contacts, files and filters in the browser, talking to Stalwart over JMAP. It holds nothing but sessions | +| **Caddy** | `caddy` | The HTTPS front: certificates and TLS for the webmail and for Stalwart's web side | + +It has 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` | +| **For** | A real mail host on the internet | Trying ihasmail against a real Stalwart on your own machine | +| **Containers** | Stalwart, ihasmail, Caddy | Stalwart, ihasmail | +| **Publishes** | 25, 80, 443, 465, 993, 995, 4190 on every interface | Nothing but two loopback ports | +| **Certificates** | Let's Encrypt, for both Caddy and 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. +## What it does + +In order, in one run: + +1. **Checks the host** before changing anything: Docker and compose are there + and usable, every port it needs is free, the deployment directory is new or + empty, no compose project of the same name exists, and the hostnames resolve. + It reports every problem at once, not the first. +2. **Shows you the plan and asks** before going ahead. `--yes` skips the + question, and is required when there is no terminal to ask on. +3. **Writes the deployment directory**: `compose.yaml`, the `Caddyfile`, and + `.env` holding a freshly generated `APP_SECRET`. +4. **Pulls the images**, pinned to versions tested together. +5. **Starts Stalwart in bootstrap mode** with a one-time administrator whose + password exists only in the tool's memory. +6. **Completes Stalwart's setup** through its API, the same setup its web UI + would otherwise walk you through: hostname, mail domain, DKIM keys, logging. + Stalwart creates the permanent administrator and returns its password, which + is written to `credentials.txt` straight away. +7. **Starts the whole stack.** Stalwart is recreated *without* the one-time + administrator. +8. **Links ihasmail and Stalwart**: exempts ihasmail from Stalwart's automatic + IP bans, tells Stalwart to trust the client addresses Caddy forwards, and + restarts Stalwart so both apply. +9. **Creates the mailboxes** you asked for with `--user`, each with a generated + password. +10. **Proves the link** by signing in *through ihasmail* as the administrator. + That only succeeds if ihasmail can reach Stalwart and Stalwart accepts the + credentials. +11. **Requests certificates** (mail host only): sets up an ACME account in + Stalwart for its IMAP and SMTP certificate, and waits for that and for + Caddy's certificates to arrive. +12. **Writes `dns-records.zone`**, every DNS record Stalwart wants published, + and prints a summary of URLs, credentials and anything still to do. + +## Why it exists + +Stalwart and ihasmail each install easily on their own. Making them a working, +safe mail host *together* takes a series of decisions that are easy to get +wrong, and most of the mistakes don't show until later: + +- **Stalwart 0.16 has no configuration file to template.** Its settings live in + its data store, and a new server starts in a bootstrap mode that expects a + person at a web wizard. Automating that means driving its registry API, and + making sure the bootstrap credential doesn't quietly outlive the setup. +- **Both services want the same ports for certificates.** The webmail needs + HTTPS on 443. Stalwart needs its own certificate for IMAP and SMTP, and its + default way of getting one also wants port 443. Two ACME clients fighting over + one port fail in confusing, intermittent ways. +- **Stalwart's automatic IP bans don't expect a proxy in front of it.** Stalwart + bans addresses that probe for things like WordPress admin pages. Behind a + reverse proxy, every client arrives from the proxy's address, so one bot + scanning your mail host gets *the proxy* banned. Autoconfig, calendar sync and + certificate renewals all stop working, for everyone, with nothing obviously + wrong. This was reproduced on Stalwart 0.16.22 while building this tool, as + was a second trap: the setting that fixes it only takes effect after a + restart. +- **The route from webmail to mail server matters.** Sent over the private + Docker network, it never leaves the host and costs about a third of the + memory per signed-in browser tab that going back out over public HTTPS does. +- **Mail doesn't flow until DNS is right**, and the list of records (MX, SPF, + DKIM, DMARC, SRV, MTA-STS, autoconfig) is long. + +This tool makes each of those decisions once, the same way every time, and +checks the result before telling you it is done. Each one is explained in +[How it works](#how-it-works). ## 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). +**On the host:** -For a mail host, also: +- Linux, amd64 or arm64. +- Docker Engine with the compose plugin (`docker compose version` works), run + as a user allowed to use Docker. + +**For a mail host, additionally:** - 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. +- **Ports 25, 80, 443, 465, 993, 995 and 4190** free on the host and open in any + firewall or cloud security group in front of it. +- **Outbound port 25.** Many cloud and VPS providers block it by default and + unblock it on request. Without it you can receive mail but not send it. +- **Reverse DNS**: a PTR record for the host's IP address naming the mail host + (`mail.example.com`). This is set at your hosting provider, not in your DNS + zone. Many receiving servers reject mail from an address without one. +- A static public IP address. -## Install +**Resources:** ihasmail on its own needs 256 MiB of RAM at minimum. Stalwart's +needs grow with the mail it stores and the number of people using it; size the +host for Stalwart. + +## Installing + +### From a release (recommended) + +Download the archive for your architecture and its checksums, verify, and +unpack: + +```bash +ARCH=amd64 # or arm64 +curl -fsSLO https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest/download/ihasmail-oneshot-linux-$ARCH.tar.gz +curl -fsSLO https://github.com/Coffey-Labs/ihasmail-oneshot/releases/latest/download/SHA256SUMS +sha256sum --ignore-missing -c SHA256SUMS +tar -xzf ihasmail-oneshot-linux-$ARCH.tar.gz +sudo install -m 0755 ihasmail-oneshot /usr/local/bin/ +ihasmail-oneshot version +``` + +Every release is built by the [release workflow](.github/workflows/release.yml) +from a tagged commit on `main`, after the tests and a known-vulnerabilities +check pass. The archives are reproducible: `scripts/build-release.sh` builds the +same bytes from the same commit. + +### From source + +With Go 1.26 or newer: ```bash git clone https://github.com/Coffey-Labs/ihasmail-oneshot.git @@ -54,190 +191,681 @@ 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. +## Trying it locally in two minutes -## Deploying a mail host - -Point DNS at the host first, so certificates can be issued on the first run: - -``` -mail.example.com. A -webmail.example.com. A -``` - -(AAAA records too, if the host has IPv6.) Then: - -```bash -ihasmail-oneshot deploy --domain example.com --email you@example.net --user alice --user bob -``` - -It lists what it will do, checks the host, and asks before changing anything; -`--yes` skips the question, and is required when there is no terminal to ask -on. At the end: - -- **`credentials.txt`** holds the Stalwart administrator (`admin@example.com`) - and a generated password for each `--user`. Readable only by you. The - administrator can sign in to the webmail, where it gets the Administration - menu, and to Stalwart's own admin UI. -- **`dns-records.zone`** holds every record Stalwart wants published: MX, SPF, - DKIM, DMARC, the SRV records, MTA-STS, and the autoconfig names. Publish all - of them. - -If DNS did not point at the host yet, the webmail still comes up and Caddy -keeps retrying its certificates by itself. Stalwart does not retry a failed -order, so once DNS is right: - -```bash -ihasmail-oneshot certs --dir ihasmail-example-com -``` - -## Trying it locally +To see ihasmail against a real Stalwart on your own machine, without a domain +or open ports: ```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 │ - └───────────────────────────────────────────────────────────┘ +```text +==> a local pair for example.test, in /home/you/ihasmail-example-test + ihasmail http://127.0.0.1:8080 + Stalwart http://127.0.0.1:8081 (admin UI; no mail ports published) + images stalwartlabs/stalwart:v0.16.22, ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328 + mailboxes alice +==> preflight + docker 29.8.0, compose 5.5.1 +deploy this? [y/N] y +==> writing /home/you/ihasmail-example-test +==> pulling images +==> starting Stalwart in bootstrap mode +==> setting up Stalwart for example.test (hostname mail.example.test) + administrator admin@example.test, password in credentials.txt +==> starting the stack +==> linking ihasmail and Stalwart + ihasmail (172.31.253.11) exempt from Stalwart's auto-ban + restarting Stalwart to apply them + mailbox alice@example.test created + signed in to ihasmail 2026.9.10+pr328 as admin@example.test: linked +==> done + webmail http://127.0.0.1:8080 + Stalwart http://127.0.0.1:8081/admin + sign in as admin@example.test (password in /home/you/ihasmail-example-test/credentials.txt) + the administrator gets the webmail's Administration menu + mailbox alice@example.test ``` -**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. +Open `http://127.0.0.1:8080` and sign in as `alice@example.test` with the +password from `ihasmail-example-test/credentials.txt`. Nothing outside your +machine can reach the pair, and no mail from outside can be delivered to it. -**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: +When you're done: ```bash -cd ihasmail-example-com -docker compose ps -docker compose logs -f stalwart -docker compose restart ihasmail +ihasmail-oneshot destroy --dir ihasmail-example-test ``` -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. +## Deploying a mail host, step by step -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. +The example uses `example.com`. Substitute your own domain throughout. -To remove a deployment and **everything in it**, mail included: +### 1. Point two names at the host + +Before running anything, create these records at your DNS provider, using the +host's public addresses: + +```dns +mail.example.com. IN A 203.0.113.10 +webmail.example.com. IN A 203.0.113.10 +; and, if the host has IPv6: +mail.example.com. IN AAAA 2001:db8::10 +webmail.example.com. IN AAAA 2001:db8::10 +``` + +Doing this first means certificates are issued during the deploy. If you skip +it, the deploy still completes and tells you how to finish once DNS is in place. + +> If your DNS provider offers a proxy or CDN mode, leave these records +> **DNS-only**. Mail ports cannot be proxied, and certificate validation must +> reach this host directly. + +### 2. Open the ports + +Allow inbound TCP 25, 80, 443, 465, 993, 995 and 4190, and UDP 443 (HTTP/3), +in the host firewall and in any provider firewall. Ask your provider to unblock +**outbound** port 25 if they block it, and set **reverse DNS** for the host's +address to `mail.example.com`. + +### 3. Run the deploy + +On the host: + +```bash +ihasmail-oneshot deploy \ + --domain example.com \ + --email you@example.net \ + --user alice --user bob +``` + +`--email` is the address Let's Encrypt associates with your certificates. Use +one that doesn't depend on this new server working. It defaults to +`postmaster@example.com`. + +The tool prints its plan, checks the host, and asks for confirmation. A run +takes a minute or two, most of it pulling images. It ends with a summary: + +```text +==> done + webmail https://webmail.example.com + Stalwart https://mail.example.com/admin + sign in as admin@example.com (password in /root/ihasmail-example-com/credentials.txt) + the administrator gets the webmail's Administration menu + mailbox alice@example.com + mailbox bob@example.com + DNS /root/ihasmail-example-com/dns-records.zone -- publish every record in it + certificate issued by , valid until + https webmail.example.com: issued by + https mail.example.com: issued by + + Before mail flows: reverse DNS for this host's address should name mail.example.com, + and outbound port 25 must be open -- many providers block it until asked. +``` + +The last three lines are how you know certificates were issued. If any is +replaced by a warning, see [step 5](#5-get-stalwarts-certificate-if-dns-came-late). + +### 4. Publish the DNS records + +`dns-records.zone` holds every record Stalwart wants published, including the +DKIM keys it just generated: + +```dns +v1-ed25519-20260913._domainkey.example.com. IN TXT "v=DKIM1; k=ed25519; h=sha256; p=…" +v1-rsa-20260913._domainkey.example.com. IN TXT ( "v=DKIM1; k=rsa; h=sha256; p=…" ) +mail.example.com. IN TXT "v=spf1 a -all" +example.com. IN TXT "v=spf1 mx -all" +example.com. IN MX 10 mail.example.com. +_dmarc.example.com. IN TXT "v=DMARC1; p=reject; rua=mailto:postmaster@example.com" +_caldavs._tcp.example.com. IN SRV 0 1 443 mail.example.com. +_carddavs._tcp.example.com. IN SRV 0 1 443 mail.example.com. +_imaps._tcp.example.com. IN SRV 0 1 993 mail.example.com. +_jmap._tcp.example.com. IN SRV 0 1 443 mail.example.com. +_pop3s._tcp.example.com. IN SRV 0 1 995 mail.example.com. +_submissions._tcp.example.com. IN SRV 0 1 465 mail.example.com. +mta-sts.example.com. IN CNAME mail.example.com. +_mta-sts.example.com. IN TXT "v=STSv1; id=…" +_smtp._tls.example.com. IN TXT "v=TLSRPTv1; rua=mailto:postmaster@example.com" +autoconfig.example.com. IN CNAME mail.example.com. +autodiscover.example.com. IN CNAME mail.example.com. +… +``` + +Publish all of them. Many DNS providers can import a zone file directly. The +**MX**, **SPF**, **DKIM** and **DMARC** records decide whether your mail is +delivered and whether others' mail reaches you. The rest let mail apps configure +themselves from just an email address. + +> DMARC is published as `p=reject`: receivers are told to reject mail that fails +> SPF and DKIM. That's the right policy for a domain that sends only from this +> server. If other services send mail as your domain, start with `p=none`. + +### 5. Get Stalwart's certificate, if DNS came late + +If the names didn't resolve during the deploy, the summary says Stalwart has no +certificate yet. Caddy keeps retrying its own certificates, but Stalwart doesn't +retry a failed order. Once DNS points at the host: + +```bash +ihasmail-oneshot certs --dir ihasmail-example-com +``` + +This works now that every Stalwart name has a record, including the +`autoconfig`, `autodiscover`, `mta-sts` and `ua-auto-config` CNAMEs from +`dns-records.zone`, because Stalwart's certificate covers all of them. + +### 6. Sign in + +- **Webmail:** `https://webmail.example.com`, as any mailbox. As + `admin@example.com` you also get the Administration menu, for adding people + and domains. +- **Stalwart's admin UI:** `https://mail.example.com/admin`, as + `admin@example.com`, for every server setting. +- **Mail apps** (Thunderbird, Apple Mail, phones): add the account by email + address and password. Once the autoconfig records are published, most apps + find the servers themselves. By hand, they're IMAP on `mail.example.com:993` + (TLS) and SMTP submission on `mail.example.com:465` (TLS). + +**Change the generated passwords** after first sign-in, then delete them from +`credentials.txt` or keep that file somewhere safe. An account with two-factor +authentication turned on signs in to the webmail with an app password created +in Stalwart. + +## The deployment directory + +The deploy writes a directory named after the domain, `./ihasmail-example-com` +by default (`--dir` to choose): + +| File | Mode | What it is | +| --- | --- | --- | +| `compose.yaml` | 0644 | The whole deployment: three services, a private network, named volumes. Commented | +| `Caddyfile` | 0644 | Caddy's configuration: the webmail, Stalwart's web names, and port 80's challenge forwarding | +| `.env` | 0600 | `APP_SECRET`, which seals ihasmail's session cookies. Read by compose | +| `credentials.txt` | 0600 | The administrator's and each mailbox's generated password, plus what `certs` needs to reach Stalwart | +| `dns-records.zone` | 0644 | The DNS records to publish | +| `acme-ca-root.pem`, `ca-bundle.crt` | 0644 | Only with `--acme-ca-root`: the private CA, and the system roots with it added | + +Data lives in Docker named volumes, prefixed with the project name: + +| Volume | Holds | +| --- | --- | +| `…_stalwart-data` | All mail, accounts, calendars, contacts, files, settings, DKIM keys, Stalwart's certificates | +| `…_stalwart-etc` | Stalwart's store location file | +| `…_caddy-data` | Caddy's certificates and ACME account | +| `…_caddy-config` | Caddy's autosaved configuration | + +ihasmail has no volume. It runs read-only and keeps sessions in memory. + +## Running it afterwards + +The directory is a standard compose project. From inside it: + +```bash +docker compose ps # what's running +docker compose logs -f stalwart # follow Stalwart's log +docker compose logs caddy | grep -i error +docker compose restart ihasmail # signs everyone out of the webmail; nothing else is lost +docker compose down # stop everything (data stays in the volumes) +docker compose up -d # start it again +``` + +The containers restart by themselves after a crash or a reboot +(`restart: unless-stopped`). + +### Upgrading + +Change the image tag in `compose.yaml` and apply it: + +```bash +docker compose pull && docker compose up -d +``` + +- **ihasmail** is safe to move to any newer release that supports your + Stalwart version. Its release notes say which. +- **Stalwart**: read its upgrade notes before changing versions. Check that the + ihasmail version you run supports the new Stalwart release first, since + ihasmail validates against one Stalwart release at a time. Back up + `stalwart-data` first. +- **Caddy** minor releases are routine. + +### Backing up + +Everything that can't be recreated is in the `stalwart-data` volume. For a +consistent copy, stop Stalwart briefly: + +```bash +docker compose stop stalwart +docker run --rm -v ihasmail-example-com_stalwart-data:/data -v "$PWD":/backup alpine \ + tar -czf /backup/stalwart-data-$(date +%F).tar.gz -C /data . +docker compose start stalwart +``` + +Keep `caddy-data` too, or certificates are requested again on a rebuild. That's +harmless unless it happens often enough to meet Let's Encrypt's rate limits. Keep +the deployment directory itself, which is small and contains your secrets. + +### Removing a deployment ```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. +This removes the containers, the network, **the volumes with every message and +account**, and the files the tool wrote. It asks first unless given `--yes`. +Files you added to the directory yourself are left in place, and so is the +directory. -## Flags +## Command reference -`ihasmail-oneshot deploy -h` lists them all. +### `deploy` -| Flag | Default | | +```text +ihasmail-oneshot deploy --domain example.com [flags] +ihasmail-oneshot deploy --local [flags] +``` + +| Flag | Default | Meaning | | --- | --- | --- | -| `--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 | +| `--domain` | required, or `example.test` with `--local` | The mail domain this server receives for | +| `--local` | off | The loopback-only shape: no mail ports, no Caddy, no certificates | +| `--mail-host` | `mail.DOMAIN` | Stalwart's hostname. Must be one label under the domain, e.g. `mx.example.com` | +| `--webmail-host` | `webmail.DOMAIN` | The webmail's hostname. Any name except Stalwart's | +| `--email` | `postmaster@DOMAIN` | ACME contact address for both Caddy and Stalwart | +| `--user NAME` | none | Create a mailbox `NAME@DOMAIN` with a generated password. Repeat for more | +| `--dir` | `./PROJECT` | Deployment directory to write. Must be new or empty | +| `--project` | `ihasmail-DOMAIN` (dots as dashes) | Compose project name, which prefixes containers, network and volumes | +| `--stalwart-image` | `stalwartlabs/stalwart:v0.16.22` | Stalwart image | +| `--ihasmail-image` | `ghcr.io/coffey-labs/ihasmail:2026.9.10-pr328` | ihasmail image | +| `--caddy-image` | `caddy:2.11.4` | Caddy image | +| `--webmail-bind` | `127.0.0.1:8080` | Host address for ihasmail's own port, for reaching it without Caddy | +| `--stalwart-bind` | `127.0.0.1:8081` | Host address for Stalwart's plain-HTTP port. The tool configures Stalwart through it | +| `--subnet` | `172.31.253.0/24` | The stack's private network. Change it if it overlaps a network you already have | +| `--acme-directory` | Let's Encrypt | ACME directory URL of a private CA, for both Caddy and Stalwart | +| `--acme-ca-root` | none | PEM file of the root the private CA's HTTPS endpoint is signed by | +| `--yes` | off | Don't ask for confirmation. Required without a terminal | + +The defaults are the versions tested together for this release. + +### `certs` + +```text +ihasmail-oneshot certs --dir DIR +``` + +Starts a new certificate order in Stalwart for the deployment in `DIR` and waits +for it, typically after fixing DNS. If Stalwart already holds a valid +certificate for the mail host, it reports that and does nothing. + +### `destroy` + +```text +ihasmail-oneshot destroy --dir DIR [--yes] +``` + +Removes the deployment in `DIR` with all of its data. See +[Removing a deployment](#removing-a-deployment). + +### `version` + +Prints the tool's version. + +## How it works + +### The shape of the deployment + +```mermaid +flowchart LR + Browser(["Browser"]) + Apps(["Mail apps, other mail servers"]) + + subgraph Host["Docker host"] + subgraph Net["private network 172.31.253.0/24"] + Caddy["Caddy
.10"] + Ihasmail["ihasmail
.11
read-only, no volume"] + Stalwart["Stalwart
.12"] + end + Data[("stalwart-data")] + end + + Browser -- "443 webmail.example.com" --> Caddy + Browser -- "443 mail.example.com/admin, DAV, autoconfig" --> Caddy + Caddy -- "http :8080" --> Ihasmail + Caddy -- "http :8080" --> Stalwart + Ihasmail -- "JMAP over http://stalwart:8080" --> Stalwart + Apps -- "25, 465, 993, 995, 4190" --> Stalwart + Stalwart --- Data +``` + +- **Caddy** is the only thing on ports 80 and 443. It serves the webmail at the + webmail host, and Stalwart's web side (admin UI, JMAP for other clients, + CalDAV, CardDAV, autoconfig, MTA-STS) at the mail host and the + `autoconfig`, `autodiscover`, `mta-sts` and `ua-auto-config` names. +- **Stalwart** publishes its mail ports directly, so SMTP and IMAP clients reach + it with their real addresses. +- **ihasmail** talks to Stalwart over plain HTTP on the private network, as + `http://stalwart:8080`. The browser never talks to Stalwart for webmail; it + talks to ihasmail, and ihasmail makes the JMAP calls. +- The three containers have **fixed addresses**, because Stalwart is told about + two of them (see below), and an address Docker picks afresh on every recreate + couldn't be written into Stalwart's settings. + +### The deploy, as a sequence + +```mermaid +sequenceDiagram + autonumber + participant T as ihasmail-oneshot + participant S as Stalwart + participant I as ihasmail + participant C as Caddy + participant CA as Let's Encrypt + + T->>S: start with a one-time admin (bootstrap mode) + T->>S: x:Bootstrap/set: hostname, domain, DKIM, logging + S-->>T: permanent administrator + password + T->>S: recreate without the one-time admin + T->>I: start + T->>C: start + C->>CA: certificates for its names (TLS-ALPN-01, port 443) + T->>S: allow ihasmail's address; trust Caddy's X-Forwarded-For + T->>S: restart to apply + T->>S: create mailboxes + T->>I: sign in as the administrator + I->>S: JMAP session (proves the link) + T->>S: create ACME account (HTTP-01), domain to automatic certificates + S->>CA: order certificate + CA->>C: HTTP-01 challenge on port 80 + C->>S: forwarded /.well-known/acme-challenge/ + S-->>T: certificate issued + T->>S: read the DNS zone +``` + +### Setting up Stalwart without its web wizard + +Stalwart 0.16 keeps its configuration in its data store rather than a file. A +server started with an empty configuration comes up in **bootstrap mode**: a +temporary administrator and a setup wizard on port 8080. The wizard is a single +registry object, `x:Bootstrap`, written with one JMAP call. + +The tool starts Stalwart with `STALWART_RECOVERY_ADMIN` set to a random password +held only in memory. It's passed through an override file that exists only for +that step, and through the environment of the `docker compose` process, never +written into `compose.yaml`. It then sets: + +- `serverHostname` and `defaultDomain` from `--mail-host` and `--domain`; +- `generateDkimKeys` on, so outgoing mail is signed from the start; +- the log to **stdout**, because Stalwart's default log directory doesn't exist + in the container image and isn't a volume; +- `requestTlsCertificate` **off**, because on 0.16.22 that switch creates no ACME + account and leaves the domain on manual certificates, so it would look like + certificates had been handled when they hadn't. The tool sets ACME up itself, + explicitly. + +Stalwart answers with a permanent administrator, `admin@DOMAIN`, and its +password. The tool writes that to `credentials.txt` before doing anything else, +so a failure later can't lose it. Then it brings the full stack up from +`compose.yaml` alone, which recreates Stalwart without the one-time +administrator, so no fixed recovery credential outlives the setup. + +The tool refuses to set up a Stalwart that isn't in bootstrap mode, so it can +never reconfigure a server that already holds someone's mail. + +### Certificates for two services on one pair of ports + +Caddy needs certificates to serve HTTPS. Stalwart needs its own for IMAP, POP3, +submission and STARTTLS on SMTP. Both need one for the mail host's name, and +both would normally want port 443 to prove they control it. + +They're separated by **ACME challenge type**: + +| | Challenge | Port | How | +| --- | --- | --- | --- | +| **Caddy** | TLS-ALPN-01 | 443 | Configured with `disable_http_challenge` for Stalwart's names | +| **Stalwart** | HTTP-01 | 80 | Caddy forwards `/.well-known/acme-challenge/*` for Stalwart's names to Stalwart, untouched. Everything else on port 80 is redirected to HTTPS | + +Neither ever answers the other's challenge, and neither needs the other's key. +Stalwart's certificate covers the mail host plus `autoconfig`, `autodiscover`, +`mta-sts` and `ua-auto-config` under the domain, the names its own DNS zone +points at the mail host, and Caddy fronts all five so every challenge reaches +it. + +Stalwart starts its order as soon as the ACME account exists, and renews by +itself from then on. If an order fails, usually because DNS isn't in place yet, +Stalwart doesn't try again by itself. `certs` starts a new order. + +### Stalwart's automatic IP bans, behind a proxy + +Stalwart bans an IP address that behaves like an attacker. For example, 30 +requests in a day for scanner paths such as `/wp-admin.php` get an address +banned. That's good protection, but it counts by the address a connection comes +from, and two kinds of traffic reach Stalwart from a single shared address: + +1. **Everything through Caddy** arrives from Caddy's address. Unchanged, one bot + scanning `https://mail.example.com` bans Caddy. That was reproduced on + 0.16.22: after a scan, autoconfig answered `403` to everyone, and so would + calendar sync and certificate renewal. + + **Fix:** Stalwart is told to take the client's address from the + `X-Forwarded-For` header Caddy sets. Re-tested after the change, the scanner + was banned and every other client still got through. This is safe only + because nothing untrusted can reach Stalwart's HTTP port: it's published on + loopback alone, and on the private network its only peers are Caddy, which + sets the header itself, and ihasmail, which sends none. + +2. **Every webmail request** arrives from ihasmail's address: every sign-in, and + every push stream opened and dropped as people open and close tabs. A ban on + that one address would be a ban on everyone's webmail. + + **Fix:** ihasmail's fixed address is added to Stalwart's allowed addresses. + ihasmail limits sign-in attempts per real client itself, so repeated wrong + passwords are still slowed down, just not by banning the webmail. + +On 0.16.22 **neither setting takes effect until Stalwart restarts**. Applied to +a running server, a scan straight afterwards still banned Caddy. So the tool +restarts Stalwart after making both changes, before anything else depends on +them. + +### The webmail's side + +ihasmail is deployed the way its own documentation recommends for this +situation: + +- **Immutable**: read-only root filesystem, no volume, sessions in memory. A + restart signs everyone out and loses nothing else, because everything durable, + each user's settings included, lives in Stalwart. +- **Private route to Stalwart** (`STALWART_URL=http://stalwart:8080`): the + credentials that travel on that leg never leave the host. +- **Push by subscription** (`PUSH_URL=https://webmail.example.com`): Stalwart + posts mailbox changes to ihasmail instead of holding a connection open for + every browser tab. If Stalwart can't reach that URL, every tab uses the + ordinary relay instead and nothing breaks. `/api/health` shows which is in use. +- **Behind a trusted proxy** (`TRUST_PROXY=1`): ihasmail believes Caddy's + forwarded headers, because Caddy is on a private network range, so it sets + secure cookies and attributes sign-in attempts to the real client. + +## Security model + +**What's reachable from outside** (mail host shape): + +| Port | Service | +| --- | --- | +| 25 | SMTP, Stalwart (receiving mail; STARTTLS) | +| 80 | Caddy: redirects to HTTPS, and ACME HTTP-01 challenges for Stalwart | +| 443 (TCP, UDP) | Caddy: the webmail, and Stalwart's web side | +| 465 | SMTP submission with TLS, Stalwart | +| 993 | IMAP with TLS, Stalwart | +| 995 | POP3 with TLS, Stalwart | +| 4190 | ManageSieve, Stalwart | + +Stalwart's plain-HTTP port (8080) and ihasmail's port are published on +`127.0.0.1` only. In `--local` mode, those two loopback ports are all that's +published. + +**Secrets:** + +- `APP_SECRET` is 48 random bytes, in `.env` (0600). It seals ihasmail's session + cookies. +- Generated passwords come from the operating system's cryptographic random + source, letters and digits only, in `credentials.txt` (0600). +- The one-time bootstrap password is never written to disk, and Stalwart is + recreated without it once setup completes. + +**Trust decisions the deployment makes**, each explained above: + +- Stalwart believes `X-Forwarded-For` on its HTTP port, which only Caddy and + ihasmail can reach. +- ihasmail's address is exempt from Stalwart's automatic bans. +- ihasmail believes forwarded headers from private-range peers, which here is + Caddy. + +**What the tool doesn't do:** configure a host firewall, harden the Docker +daemon, set up backups or monitoring, or turn on encryption at rest for +mailboxes. Encryption at rest can't be turned off again once on, which is not a +decision for a deploy tool to make. + +## Troubleshooting + +**`port 443 is already in use on this host`** +Something else, often an existing web server, holds a port the mail host needs. +Free it, or use a separate host. With `--local`, move `--webmail-bind` or +`--stalwart-bind` instead. + +**`project ihasmail-example-com already exists in Docker`** +An earlier run left containers or volumes behind. If it's a failed attempt you +want to discard: `ihasmail-oneshot destroy --dir ihasmail-example-com --yes`, +then deploy again. To keep it and deploy another, pass a different `--project` +and `--dir`. + +**`... already has files in it; give --dir a new or empty directory`** +The tool never writes over an existing deployment. Choose another directory, or +destroy the old deployment first. + +**A deploy stopped partway.** +The error says which step failed and shows the last lines of the relevant +container's log. The stack is left as it was, for you to inspect. To start +again from nothing: `ihasmail-oneshot destroy --dir DIR --yes`, fix the cause, +and deploy again. + +**`Pool overlaps with other one on this address space`** +The default private network `172.31.253.0/24` collides with a Docker network you +already have. Destroy the partial deployment and deploy again with, for +example, `--subnet 172.31.200.0/24`. + +**`Stalwart has no certificate yet`** +Usually DNS: the mail host or one of the `autoconfig`, `autodiscover`, +`mta-sts`, `ua-auto-config` names doesn't resolve to this host yet, or port 80 +isn't reachable from the internet. Check with `dig +short mail.example.com` from +somewhere else, fix it, then run `ihasmail-oneshot certs --dir DIR`. Stalwart's +reasons are in its log: + +```bash +docker compose logs stalwart | grep -i acme +``` + +**`Caddy has no certificate for webmail.example.com yet`** +Same causes. Caddy retries by itself with increasing delays. See +`docker compose logs caddy | grep -i error`. + +**Mail arrives but sent mail never does.** +Outbound port 25 is almost always the cause. Test from the host with +`nc -vz gmail-smtp-in.l.google.com 25`. Stalwart's log names each delivery +failure (`docker compose logs stalwart`). Also check that reverse DNS for the host's address +names the mail host. + +**Sent mail lands in spam.** +Check that the SPF, DKIM and DMARC records from `dns-records.zone` are +published exactly, and that reverse DNS is set. New addresses need a little time +to build a sending reputation. + +**The webmail signs everyone out.** +Expected after `docker compose restart ihasmail`, any ihasmail upgrade, or a +reboot: sessions live in memory. + +**`/api/health` shows push accounts `pending` that never become `verified`.** +Stalwart can't reach `https://webmail.example.com` from inside its container. +Mail still updates in real time over the relay. The usual causes are a host +firewall that drops traffic from Docker's bridge to the host's own public +address, or the webmail name not resolving from the host. ## 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 +- **One mail domain per deploy.** More can be added afterwards in Stalwart or in + ihasmail's Administration. Their certificates and DNS records are then yours + to arrange. +- **Linux Docker hosts only.** The tool drives the local Docker daemon and + publishes ports on the host it runs on. +- **IPv4 on the private network.** Ports are also published on IPv6 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. +- **Fresh deployments only.** It doesn't import an existing Stalwart, or adopt a + deployment it didn't write. +- **Stalwart doesn't retry a failed certificate order** by itself, on a + transient CA error either. `certs` starts a new one. +- **Push by subscription isn't covered by the end-to-end test**, whose lab has no + public DNS for Stalwart to resolve the webmail's name through. Its fallback, + the relay, is what the test exercises. +- Validated against **Stalwart 0.16.22**. Other Stalwart versions may change the + registry objects the setup uses. -## Testing +## Development and testing ```bash -go test ./... -e2e/public.sh +go vet ./... +go test ./... # unit tests: validation, rendered files, the Stalwart client +e2e/public.sh # the whole mail-host deploy, for real, on this machine ``` -`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. +`e2e/public.sh` runs a complete mail-host deployment with no internet involved. +[Pebble](https://github.com/letsencrypt/pebble), the ACME test server, stands in +for Let's Encrypt, and a DNS stub answers every name with the host's own address. +Caddy and Stalwart both obtain real certificates from it, through the real +ports and the real `Caddyfile`. It then checks, among other things, that: + +- the deploy completes, links ihasmail and Stalwart, and reports both kinds of + certificate; +- `credentials.txt` and `.env` are private, and the bootstrap credential is gone + from the Stalwart container; +- Stalwart's IMAPS (993) and submission (465) ports present certificates that + verify for the mail host; +- the webmail, Stalwart's JMAP and autoconfig are served over verified HTTPS, and + a user signs in through the webmail; +- `dns-records.zone` holds the MX and DKIM records, and `certs` recognises an + existing certificate; +- a scanner probing through Caddy is banned by its own address, not Caddy's, and + other clients still get through; +- ihasmail is exempt from bans, and a user still signs in after failed attempts. + +It publishes ports 25, 80, 443, 465, 993, 995 and 4190 on the machine while it +runs, and removes everything it created when it ends, pass or fail. `KEEP=1 +e2e/public.sh` leaves the stack up to inspect. + +The code: + +| Package | Does | +| --- | --- | +| `cmd/ihasmail-oneshot` | Commands, flags, confirmation, the summary | +| `internal/config` | Validates the command line into a plan: names, addresses, images | +| `internal/render` | Renders `compose.yaml` and the `Caddyfile`; writes files without ever overwriting | +| `internal/docker` | Drives the `docker` and `docker compose` CLIs | +| `internal/stalwart` | The JMAP client and every Stalwart registry call: bootstrap, ACME, bans, mailboxes, DNS zone | +| `internal/webmail` | ihasmail's health check and the sign-in that proves the link | +| `internal/deploy` | Preflight, the deploy sequence, `certs`, `destroy` | + +## Versions and releases + +Releases are tagged by date, like ihasmail's: `v2026.9.13`, with `.1`, `.2` +added for another release the same day. Each release pins the Stalwart, +ihasmail and Caddy images it was tested with as its defaults. A newer release +of the tool generally means newer tested versions. + +Binaries for `linux/amd64` and `linux/arm64` and a `SHA256SUMS` file are +attached to every [release](https://github.com/Coffey-Labs/ihasmail-oneshot/releases). + +## Security + +To report a vulnerability, see [SECURITY.md](SECURITY.md). Please don't open a +public issue. ## License diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..79521fb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +## Supported Versions + +Security fixes go into the latest release. Older releases do not receive +backported fixes; a fixed release is a download away, and the tool keeps no +state of its own between runs. + +| Version | Supported | +| --- | --- | +| Latest release | :white_check_mark: | +| Older releases | :x: | + +## Reporting a Vulnerability + +**Please do not open a public GitHub issue for security vulnerabilities.** + +Report them privately by emailing **johnellisATlinuxDOTcom**, with: + +- A description of the issue and its impact +- Steps to reproduce +- The ihasmail-oneshot version (`ihasmail-oneshot version`) +- Whether it is in the tool itself or in the deployment it writes + +You should hear back within a few days. Once a fix is released, disclosure +timing and credit are coordinated with you. + +## Scope + +In scope: + +- Secrets the tool generates or writes: file modes, where they end up, what + outlives the setup +- The deployment it writes: what it publishes, and what it tells Stalwart to + trust (forwarded client addresses, addresses exempt from bans) +- The Caddyfile and compose.yaml it renders + +Out of scope, and best reported upstream: + +- Vulnerabilities in Stalwart itself +- Vulnerabilities in ihasmail itself — see + [its security policy](https://github.com/Coffey-Labs/ihasmail/security/policy) +- Vulnerabilities in Caddy or Docker diff --git a/cmd/ihasmail-oneshot/main.go b/cmd/ihasmail-oneshot/main.go index 075131b..d05f89f 100644 --- a/cmd/ihasmail-oneshot/main.go +++ b/cmd/ihasmail-oneshot/main.go @@ -60,10 +60,11 @@ func main() { fmt.Fprintf(os.Stderr, "unknown command %q\n\n%s", os.Args[1], usageText) os.Exit(2) } + if errors.Is(err, flag.ErrHelp) { + return // -h printed its usage, which is what was asked for + } if err != nil { - if !errors.Is(err, flag.ErrHelp) { - fmt.Fprintf(os.Stderr, "!! %v\n", err) - } + fmt.Fprintf(os.Stderr, "!! %v\n", err) os.Exit(1) } } diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100755 index 0000000..71ba2d2 --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2026 Coffey Labs +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Build the release archives: one per architecture, plus SHA256SUMS. +# +# Usage: scripts/build-release.sh VERSION [OUTDIR] +# scripts/build-release.sh v2026.9.13 dist +# +# The release workflow runs exactly this, so a release can be reproduced -- or +# checked before tagging -- on any machine with Go. Archive names carry no +# version, so .../releases/latest/download/ always means the newest. +set -euo pipefail + +VERSION="${1:?usage: $0 VERSION [OUTDIR]}" +OUT="${2:-dist}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +rm -rf "$OUT" && mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT + +# Linux only: the tool drives Docker on the host it runs on, and deploys a mail +# server that publishes ports there. +for arch in amd64 arm64; do + name="ihasmail-oneshot-linux-$arch" + mkdir -p "$STAGE/$name" + echo "==> building $name ($VERSION)" + (cd "$ROOT" && CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath \ + -ldflags "-s -w -X main.version=$VERSION" -o "$STAGE/$name/ihasmail-oneshot" ./cmd/ihasmail-oneshot) + cp "$ROOT/LICENSE" "$ROOT/README.md" "$STAGE/$name/" + # Fixed owner and time, so the same commit gives the same archive. + tar --sort=name --owner=0 --group=0 --numeric-owner --mtime="@${SOURCE_DATE_EPOCH:-0}" \ + -C "$STAGE/$name" -czf "$OUT/$name.tar.gz" ihasmail-oneshot LICENSE README.md +done + +(cd "$OUT" && sha256sum ./*.tar.gz | sed 's| \./| |' > SHA256SUMS) +echo "==> $OUT:" +(cd "$OUT" && cat SHA256SUMS)