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.
601 lines
21 KiB
Go
601 lines
21 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package deploy is the one shot: preflight, write the directory, bootstrap
|
|
// Stalwart, bring the stack up, link and verify it.
|
|
package deploy
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/config"
|
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/docker"
|
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/render"
|
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/stalwart"
|
|
"github.com/Coffey-Labs/ihasmail-oneshot/internal/webmail"
|
|
)
|
|
|
|
// Log is where progress goes. Each step says what it is doing before it does
|
|
// it, and every wait longer than a few seconds says so while it waits.
|
|
type Log struct{ W io.Writer }
|
|
|
|
func (l Log) Step(format string, a ...any) { fmt.Fprintf(l.W, "==> "+format+"\n", a...) }
|
|
func (l Log) Info(format string, a ...any) { fmt.Fprintf(l.W, " "+format+"\n", a...) }
|
|
func (l Log) Warn(format string, a ...any) { fmt.Fprintf(l.W, "!! "+format+"\n", a...) }
|
|
|
|
// Preflight checks everything that can be checked without changing anything.
|
|
// Warnings are things that will not stop the deployment but will stop it
|
|
// being useful until they are fixed, like DNS that does not point here yet.
|
|
func Preflight(ctx context.Context, p config.Plan, log Log) (warnings []string, err error) {
|
|
engine, compose, err := docker.Versions(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log.Info("docker %s, compose %s", engine, compose)
|
|
|
|
var problems []error
|
|
if leftovers, err := docker.ProjectLeftovers(ctx, p.Project); err != nil {
|
|
problems = append(problems, err)
|
|
} else if len(leftovers) > 0 {
|
|
problems = append(problems, fmt.Errorf("project %s already exists in Docker (%s); destroy it first or choose another --project", p.Project, strings.Join(leftovers, ", ")))
|
|
}
|
|
|
|
if entries, err := os.ReadDir(p.Dir); err == nil && len(entries) > 0 {
|
|
problems = append(problems, fmt.Errorf("%s already has files in it; give --dir a new or empty directory", p.Dir))
|
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
problems = append(problems, err)
|
|
}
|
|
|
|
if p.ACMECARoot != "" {
|
|
if _, err := os.Stat(p.ACMECARoot); err != nil {
|
|
problems = append(problems, fmt.Errorf("--acme-ca-root: %w", err))
|
|
}
|
|
}
|
|
|
|
addrs := []string{p.WebmailBind, p.StalwartBind}
|
|
for _, port := range p.PublishedPorts() {
|
|
addrs = append(addrs, fmt.Sprintf(":%d", port))
|
|
}
|
|
for _, a := range addrs {
|
|
if err := portFree(a); err != nil {
|
|
problems = append(problems, err)
|
|
}
|
|
}
|
|
|
|
if !p.Local {
|
|
for _, host := range []string{p.WebmailHost, p.MailHost} {
|
|
if ips, err := net.DefaultResolver.LookupHost(ctx, host); err != nil || len(ips) == 0 {
|
|
warnings = append(warnings, fmt.Sprintf("%s does not resolve yet: its certificate cannot be issued until it points at this host", host))
|
|
}
|
|
}
|
|
}
|
|
return warnings, errors.Join(problems...)
|
|
}
|
|
|
|
// portFree tries to bind an address. A permission error means an unprivileged
|
|
// user asking about a low port, which says nothing about whether Docker can
|
|
// have it, so it is not reported.
|
|
func portFree(addr string) error {
|
|
l, err := net.Listen("tcp", addr)
|
|
if err == nil {
|
|
return l.Close()
|
|
}
|
|
if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) {
|
|
return nil
|
|
}
|
|
if errors.Is(err, syscall.EADDRINUSE) {
|
|
return fmt.Errorf("port %s is already in use on this host", strings.TrimPrefix(addr, ":"))
|
|
}
|
|
return fmt.Errorf("cannot bind %s: %w", addr, err)
|
|
}
|
|
|
|
// Result is what a successful deployment reports.
|
|
type Result struct {
|
|
Admin stalwart.Admin
|
|
Mailboxes map[string]string
|
|
IhasmailVersion string
|
|
AdminInWebmail bool
|
|
Certificate *stalwart.Certificate
|
|
// Caddy's certificates, by hostname, as the issuer's name. A name missing
|
|
// here had none by the time the tool stopped waiting.
|
|
CaddyCertificates map[string]string
|
|
}
|
|
|
|
// Deploy runs the whole thing. On an error after containers exist, it leaves
|
|
// them as they are for inspection and says how to start over.
|
|
func Deploy(ctx context.Context, p config.Plan, version string, log Log) (*Result, error) {
|
|
log.Step("writing %s", p.Dir)
|
|
if err := render.PrepareDir(p.Dir); err != nil {
|
|
return nil, err
|
|
}
|
|
appSecret, err := randomBase64(48)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
bootPassword := randomPassword(32)
|
|
|
|
caBundle := p.ACMECARoot != ""
|
|
composeYAML, err := render.Compose(p, version, caBundle)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := render.WriteFile(p.Dir, render.ComposeFile, composeYAML, false); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := render.WriteFile(p.Dir, render.EnvFile, render.Env(appSecret), true); err != nil {
|
|
return nil, err
|
|
}
|
|
if !p.Local {
|
|
caddyfile, err := render.Caddy(p, version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := render.WriteFile(p.Dir, render.Caddyfile, caddyfile, false); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
compose := docker.Compose{Dir: p.Dir, Out: indent(log.W)}
|
|
|
|
log.Step("pulling images")
|
|
if err := compose.Run(ctx, "pull", "--quiet"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if caBundle {
|
|
root, err := os.ReadFile(p.ACMECARoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
system, err := docker.SystemCABundle(ctx, p.StalwartImage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading the CA bundle out of %s: %w", p.StalwartImage, err)
|
|
}
|
|
if err := render.WriteFile(p.Dir, render.CARootFile, root, false); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := render.WriteFile(p.Dir, render.CABundleFile, append(system, root...), false); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// From here on there are containers, so a failure says how to clear them.
|
|
res, err := bringUp(ctx, p, compose, bootPassword, log)
|
|
if err != nil {
|
|
return res, fmt.Errorf("%w\n\nThe stack is left as it is, to look at. To start again from nothing:\n ihasmail-oneshot destroy --dir %s --yes", err, p.Dir)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func bringUp(ctx context.Context, p config.Plan, compose docker.Compose, bootPassword string, log Log) (*Result, error) {
|
|
stalwartURL := "http://" + p.StalwartBind
|
|
res := &Result{Mailboxes: map[string]string{}, CaddyCertificates: map[string]string{}}
|
|
|
|
// --- bootstrap -----------------------------------------------------------
|
|
// The bootstrap account comes from an override file that lives only for
|
|
// this step, and its password only in this process's environment. Bringing
|
|
// the stack up afterwards without the override recreates Stalwart without
|
|
// the variable, so no fixed recovery credential outlives the setup.
|
|
override, err := os.CreateTemp("", "ihasmail-oneshot-bootstrap-*.yaml")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer os.Remove(override.Name())
|
|
if _, err := override.WriteString("services:\n stalwart:\n environment:\n STALWART_RECOVERY_ADMIN: ${ONESHOT_BOOTSTRAP_ADMIN:?}\n"); err != nil {
|
|
return nil, err
|
|
}
|
|
override.Close()
|
|
|
|
log.Step("starting Stalwart in bootstrap mode")
|
|
boot := compose
|
|
boot.Files = []string{override.Name()}
|
|
boot.Env = []string{"ONESHOT_BOOTSTRAP_ADMIN=admin:" + bootPassword}
|
|
if err := boot.Run(ctx, "up", "-d", "stalwart"); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := waitFor(ctx, log, "Stalwart", 90*time.Second, func(ctx context.Context) error {
|
|
return stalwart.Live(ctx, stalwartURL)
|
|
}); err != nil {
|
|
return nil, withLogs(ctx, err, compose, "stalwart")
|
|
}
|
|
|
|
bootClient := &stalwart.Client{BaseURL: stalwartURL, Username: "admin", Password: bootPassword}
|
|
if err := bootClient.CheckBootstrapMode(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
log.Step("setting up Stalwart for %s (hostname %s)", p.Domain, p.MailHost)
|
|
admin, err := bootClient.Bootstrap(ctx, p.MailHost, p.Domain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bootstrap: %w", err)
|
|
}
|
|
res.Admin = admin
|
|
// Written now, not at the end: from this moment the password exists nowhere
|
|
// else, and a failure in a later step must not lose it.
|
|
if err := writeCredentials(p, admin); err != nil {
|
|
return res, err
|
|
}
|
|
log.Info("administrator %s, password in %s", admin.Username, render.CredentialsFile)
|
|
|
|
// --- the whole stack -----------------------------------------------------
|
|
log.Step("starting the stack")
|
|
if err := compose.Run(ctx, "up", "-d"); err != nil {
|
|
return res, err
|
|
}
|
|
sw := &stalwart.Client{BaseURL: stalwartURL, Username: admin.Username, Password: admin.Secret}
|
|
if err := waitFor(ctx, log, "Stalwart to restart configured", 90*time.Second, func(ctx context.Context) error {
|
|
_, err := sw.DomainID(ctx, p.Domain)
|
|
return err
|
|
}); err != nil {
|
|
return res, withLogs(ctx, err, compose, "stalwart")
|
|
}
|
|
domainID, err := sw.DomainID(ctx, p.Domain)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
// --- linking -------------------------------------------------------------
|
|
log.Step("linking ihasmail and Stalwart")
|
|
// Every request the webmail makes reaches Stalwart from ihasmail's one
|
|
// address -- every sign-in, every push stream opened and dropped as tabs
|
|
// come and go. Stalwart bans per address, so a ban on that one would be a
|
|
// ban on everybody's webmail. ihasmail rate-limits sign-ins per real client
|
|
// itself. (Checked on 0.16.22: failed sign-ins were refused per session but
|
|
// never became an address ban; scans and dropped connections are counted,
|
|
// and this is not a limit worth finding by losing the webmail to it.)
|
|
if err := sw.AllowIP(ctx, p.IhasmailIP.String(), "ihasmail: every webmail request arrives from this address"); err != nil {
|
|
return res, fmt.Errorf("exempting ihasmail from the auto-ban: %w", err)
|
|
}
|
|
log.Info("ihasmail (%s) exempt from Stalwart's auto-ban", p.IhasmailIP)
|
|
if !p.Local {
|
|
if err := sw.TrustForwardedFor(ctx); err != nil {
|
|
return res, fmt.Errorf("trusting Caddy's X-Forwarded-For: %w", err)
|
|
}
|
|
log.Info("Stalwart takes client addresses from Caddy's X-Forwarded-For")
|
|
}
|
|
// Neither setting takes effect on a running Stalwart: on 0.16.22 a scan
|
|
// through Caddy straight after setting them still banned Caddy, and the
|
|
// same scan after a restart banned the scanner. Nor does lifting a ban.
|
|
log.Info("restarting Stalwart to apply them")
|
|
if err := compose.Run(ctx, "restart", "stalwart"); err != nil {
|
|
return res, err
|
|
}
|
|
if err := waitFor(ctx, log, "Stalwart to restart", 90*time.Second, func(ctx context.Context) error {
|
|
_, err := sw.DomainID(ctx, p.Domain)
|
|
return err
|
|
}); err != nil {
|
|
return res, withLogs(ctx, err, compose, "stalwart")
|
|
}
|
|
|
|
for _, name := range p.Users {
|
|
password := randomPassword(24)
|
|
if _, err := sw.CreateUser(ctx, name, domainID, password); err != nil {
|
|
return res, fmt.Errorf("creating mailbox %s@%s: %w", name, p.Domain, err)
|
|
}
|
|
address := name + "@" + p.Domain
|
|
res.Mailboxes[address] = password
|
|
if err := render.AppendFile(p.Dir, render.CredentialsFile, []byte(fmt.Sprintf("mailbox %s = %s\n", address, password))); err != nil {
|
|
return res, err
|
|
}
|
|
log.Info("mailbox %s created", address)
|
|
}
|
|
|
|
webmailURL := "http://" + p.WebmailBind
|
|
if err := waitFor(ctx, log, "ihasmail", 60*time.Second, func(ctx context.Context) error {
|
|
h, err := webmail.CheckHealth(ctx, webmailURL)
|
|
res.IhasmailVersion = h.Version
|
|
return err
|
|
}); err != nil {
|
|
return res, withLogs(ctx, err, compose, "ihasmail")
|
|
}
|
|
// Retried briefly: ihasmail can report healthy a moment before its first
|
|
// session discovery against a Stalwart that has only just restarted.
|
|
if err := waitFor(ctx, log, "a sign-in through ihasmail", 30*time.Second, func(ctx context.Context) error {
|
|
ok, err := webmail.SignIn(ctx, webmailURL, admin.Username, admin.Secret)
|
|
res.AdminInWebmail = ok
|
|
return err
|
|
}); err != nil {
|
|
return res, withLogs(ctx, err, compose, "ihasmail")
|
|
}
|
|
log.Info("signed in to ihasmail %s as %s: linked", res.IhasmailVersion, admin.Username)
|
|
|
|
if p.Local {
|
|
return res, nil
|
|
}
|
|
|
|
// --- certificates and DNS -----------------------------------------------
|
|
log.Step("requesting Stalwart's certificate")
|
|
if err := waitFor(ctx, log, "Caddy", 30*time.Second, func(ctx context.Context) error {
|
|
if !compose.Running(ctx, "caddy") {
|
|
return errors.New("not running")
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return res, withLogs(ctx, err, compose, "caddy")
|
|
}
|
|
if _, err := sw.EnableACME(ctx, domainID, p.ACMEDirectory, p.Email); err != nil {
|
|
return res, fmt.Errorf("enabling ACME: %w", err)
|
|
}
|
|
// Not an error if it does not arrive: DNS that does not point here yet is
|
|
// the usual reason, and the fix is DNS and then `certs`, not a redeploy.
|
|
_ = waitFor(ctx, log, "the certificate", 90*time.Second, func(ctx context.Context) error {
|
|
certs, err := sw.Certificates(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, c := range certs {
|
|
if c.SubjectAlternativeNames[p.MailHost] {
|
|
res.Certificate = &c
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("not issued yet")
|
|
})
|
|
|
|
// Caddy obtains its own in the background. Waited for, so that "done"
|
|
// means the HTTPS it prints works -- and, like Stalwart's, not an error
|
|
// when it does not arrive.
|
|
for _, host := range []string{p.WebmailHost, p.MailHost} {
|
|
_ = waitFor(ctx, log, "Caddy's certificate for "+host, 60*time.Second, func(ctx context.Context) error {
|
|
issuer, err := servedCertificate(ctx, host)
|
|
if err == nil {
|
|
res.CaddyCertificates[host] = issuer
|
|
}
|
|
return err
|
|
})
|
|
}
|
|
|
|
zone, err := sw.DNSZone(ctx, domainID)
|
|
if err != nil {
|
|
return res, fmt.Errorf("reading the DNS records: %w", err)
|
|
}
|
|
if err := render.WriteFile(p.Dir, render.DNSFile, []byte(dnsFile(p, zone)), false); err != nil {
|
|
return res, err
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// servedCertificate reports the issuer of the certificate Caddy presents for
|
|
// host on this machine's port 443, without trusting it: the question is
|
|
// whether Caddy has one yet, not whether this host trusts the CA. Until it has
|
|
// one the handshake fails outright.
|
|
func servedCertificate(ctx context.Context, host string) (string, error) {
|
|
d := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}}
|
|
conn, err := d.DialContext(ctx, "tcp", "127.0.0.1:443")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer conn.Close()
|
|
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
|
|
if len(certs) == 0 {
|
|
return "", errors.New("no certificate presented")
|
|
}
|
|
if err := certs[0].VerifyHostname(host); err != nil {
|
|
return "", err
|
|
}
|
|
return certs[0].Issuer.String(), nil
|
|
}
|
|
|
|
// waitFor retries check until it passes or timeout passes, saying every ten
|
|
// seconds that it is still waiting and what the last answer was.
|
|
func waitFor(ctx context.Context, log Log, what string, timeout time.Duration, check func(context.Context) error) error {
|
|
start := time.Now()
|
|
deadline := start.Add(timeout)
|
|
lastReport := start
|
|
for {
|
|
attempt, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
err := check(attempt)
|
|
cancel()
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return fmt.Errorf("gave up waiting for %s after %s: %w", what, timeout, err)
|
|
}
|
|
if time.Since(lastReport) >= 10*time.Second {
|
|
log.Info("still waiting for %s (%s): %v", what, time.Since(start).Round(time.Second), err)
|
|
lastReport = time.Now()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-time.After(time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
func withLogs(ctx context.Context, err error, compose docker.Compose, service string) error {
|
|
return fmt.Errorf("%w\n\nlast lines of %s's log:\n%s", err, service, compose.Logs(ctx, service, 25))
|
|
}
|
|
|
|
func writeCredentials(p config.Plan, a stalwart.Admin) error {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "# ihasmail-oneshot credentials for %s, written %s.\n", p.Domain, time.Now().UTC().Format(time.RFC3339))
|
|
b.WriteString("# Keep this file private. The administrator can sign in to the webmail too,\n")
|
|
b.WriteString("# and to Stalwart's own admin UI. Change the passwords after first sign-in.\n")
|
|
fmt.Fprintf(&b, "domain = %s\n", p.Domain)
|
|
fmt.Fprintf(&b, "mail_host = %s\n", p.MailHost)
|
|
fmt.Fprintf(&b, "stalwart_url = http://%s\n", p.StalwartBind)
|
|
fmt.Fprintf(&b, "admin = %s\n", a.Username)
|
|
fmt.Fprintf(&b, "admin_password = %s\n", a.Secret)
|
|
return render.WriteFile(p.Dir, render.CredentialsFile, []byte(b.String()), true)
|
|
}
|
|
|
|
func dnsFile(p config.Plan, zone string) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "; DNS records for %s, from Stalwart. Publish all of them.\n", p.Domain)
|
|
b.WriteString(";\n; Two more that Stalwart cannot know, because they are this host's address:\n")
|
|
for _, h := range []string{p.MailHost, p.WebmailHost} {
|
|
fmt.Fprintf(&b, "; %s. IN A <this host's IPv4 address>\n", h)
|
|
fmt.Fprintf(&b, "; %s. IN AAAA <this host's IPv6 address, if it has one>\n", h)
|
|
}
|
|
fmt.Fprintf(&b, ";\n; And one at your hosting provider rather than in this zone: reverse DNS (PTR)\n; for this host's address, pointing at %s.\n\n", p.MailHost)
|
|
b.WriteString(zone)
|
|
if !strings.HasSuffix(zone, "\n") {
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Destroy removes the stack, its volumes, and the files the tool wrote. Mail,
|
|
// accounts and certificates go with the volumes; that is the point of it.
|
|
func Destroy(ctx context.Context, dir string, log Log) error {
|
|
if _, err := os.Stat(filepath.Join(dir, render.ComposeFile)); err != nil {
|
|
return fmt.Errorf("%s is not a deployment directory: %w", dir, err)
|
|
}
|
|
log.Step("removing containers, networks and volumes")
|
|
compose := docker.Compose{Dir: dir, Out: indent(log.W)}
|
|
// APP_SECRET is required by compose.yaml's interpolation, and a missing
|
|
// .env must not make the stack impossible to remove.
|
|
compose.Env = []string{"APP_SECRET=unused-by-down"}
|
|
if err := compose.Run(ctx, "down", "--volumes", "--remove-orphans"); err != nil {
|
|
return err
|
|
}
|
|
log.Step("removing the files it wrote")
|
|
for _, name := range render.Written {
|
|
if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
if err := os.Remove(dir); err != nil {
|
|
log.Info("left %s in place: it holds files this tool did not write", dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Certs starts a new certificate order, for after DNS has been fixed.
|
|
func Certs(ctx context.Context, dir string, log Log) error {
|
|
creds, err := ReadCredentials(filepath.Join(dir, render.CredentialsFile))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sw := &stalwart.Client{BaseURL: creds["stalwart_url"], Username: creds["admin"], Password: creds["admin_password"]}
|
|
domainID, err := sw.DomainID(ctx, creds["domain"])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
covering := func(ctx context.Context) (*stalwart.Certificate, error) {
|
|
certs, err := sw.Certificates(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, c := range certs {
|
|
if c.SubjectAlternativeNames[creds["mail_host"]] {
|
|
return &c, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
// A new order while a certificate is still valid is refused by Stalwart as
|
|
// "renewal not due", so asking would only look like it had done something.
|
|
if c, err := covering(ctx); err != nil {
|
|
return err
|
|
} else if c != nil {
|
|
log.Info("Stalwart already holds a certificate for %s, issued by %s, valid until %s", creds["mail_host"], c.Issuer, c.NotValidAfter)
|
|
return nil
|
|
}
|
|
log.Step("starting a new certificate order for %s", creds["domain"])
|
|
if err := sw.RetryCertificates(ctx, domainID); err != nil {
|
|
return err
|
|
}
|
|
var got *stalwart.Certificate
|
|
err = waitFor(ctx, log, "the certificate", 90*time.Second, func(ctx context.Context) error {
|
|
c, err := covering(ctx)
|
|
if err == nil && c == nil {
|
|
err = errors.New("not issued yet")
|
|
}
|
|
got = c
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("%w\n Stalwart's log says why: docker compose --project-directory %s logs stalwart | grep -i acme", err, dir)
|
|
}
|
|
log.Info("issued by %s, valid until %s", got.Issuer, got.NotValidAfter)
|
|
return nil
|
|
}
|
|
|
|
// ReadCredentials parses credentials.txt's "key = value" lines.
|
|
func ReadCredentials(path string) (map[string]string, error) {
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]string{}
|
|
for _, line := range strings.Split(string(raw), "\n") {
|
|
if line = strings.TrimSpace(line); line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
if k, v, ok := strings.Cut(line, " = "); ok {
|
|
out[k] = v
|
|
}
|
|
}
|
|
for _, k := range []string{"domain", "mail_host", "stalwart_url", "admin", "admin_password"} {
|
|
if out[k] == "" {
|
|
return nil, fmt.Errorf("%s has no %s", path, k)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func randomBase64(n int) (string, error) {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(b), nil
|
|
}
|
|
|
|
// randomPassword is letters and digits only, so it survives YAML, an env file,
|
|
// a shell and being read aloud without any quoting.
|
|
func randomPassword(n int) string {
|
|
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
out := make([]byte, n)
|
|
buf := make([]byte, 1)
|
|
for i := 0; i < n; {
|
|
if _, err := rand.Read(buf); err != nil {
|
|
panic(err) // crypto/rand.Read does not fail on supported platforms
|
|
}
|
|
// Rejection sampling keeps every character equally likely.
|
|
if int(buf[0]) < 256-256%len(alphabet) {
|
|
out[i] = alphabet[int(buf[0])%len(alphabet)]
|
|
i++
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
type indentWriter struct {
|
|
w io.Writer
|
|
bol bool
|
|
}
|
|
|
|
func (iw *indentWriter) Write(p []byte) (int, error) {
|
|
for _, c := range p {
|
|
if iw.bol {
|
|
if _, err := iw.w.Write([]byte(" ")); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
if _, err := iw.w.Write([]byte{c}); err != nil {
|
|
return 0, err
|
|
}
|
|
iw.bol = c == '\n'
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
// indent passes docker's own output through, indented under the step it
|
|
// belongs to.
|
|
func indent(w io.Writer) io.Writer { return &indentWriter{w: w, bol: true} }
|