The same licence as ihasmail, which this tool deploys. The network clause is the part that matters for a deploy tool: a modified copy offered as a hosted service that deploys for other people has to make its source available to them, which GPL would not require. Running the tool for your own host is unaffected. v2026.9.13 was released under GPL-3.0-or-later and stays so; this applies from the next release.
311 lines
9.9 KiB
Go
311 lines
9.9 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: AGPL-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
|
|
}
|