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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user