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:
2026-09-13 22:02:40 -07:00
commit d19696dec3
18 changed files with 3711 additions and 0 deletions
+120
View File
@@ -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()
}
+122
View File
@@ -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")
}
}
+59
View File
@@ -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}}