Files
ihasmail-oneshot/internal/config/config.go
T
jcoffey-dev d19696dec3 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.
2026-09-13 22:02:40 -07:00

311 lines
9.9 KiB
Go

// 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
}