Files
ihasmail-oneshot/internal/config/config.go
T
jcoffey-dev e71545bbcf Relicense under AGPL-3.0-or-later
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.
2026-09-13 22:36:17 -07:00

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
}