Files
inbuxa-installer/internal/apply/apply.go
T
jcoffey-dev 8725d8c11b Obtain certificates, and wait for the one that matters
The public shape now works end to end: real ports, Caddy in front, and both
programs that need certificates getting them from the same CA -- Caddy for
the front ends over TLS-ALPN-01, the mail server for its own names over
HTTP-01, which Caddy forwards on port 80.

Proved in the lab against Pebble, with a DNS stub answering every name with
the machine's own address, so no public name or public CA is involved:
twenty checks, ending with IMAPS and submissions presenting a certificate
for the mail host that verifies against the CA, and the webmail sending
sign-in to the server as the first-party client the server registered.

Two things the test found, both of which would have shipped:

- The proxy fronted four of the server's five names. The server puts
  ua-auto-config in its own certificate too, so its challenge was never
  forwarded, one name failed, and the whole order failed with it -- leaving
  the mail ports on a self-signed certificate while everything else looked
  healthy. The list now matches what the server asks for.

- Nothing waited for the certificate. An order that fails is not retried on
  its own and a restart does not start a new one, so the install declared
  itself finished over a self-signed certificate. It now waits, asks again
  every 45 seconds, and reports the issuer -- or says plainly that the
  server will keep trying once the domain resolves here, which is the
  ordinary case on a first install.

--acme-directory and --acme-ca-root are what let a private CA be used: the
root is added to the server image's own bundle and given to Caddy, because
neither sees the other's trust store.
2026-09-22 19:11:52 -07:00

456 lines
16 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package apply carries out a plan, for the shapes that are containers.
//
// The sequence is SPEC.md 6.2's, which ihasmail-oneshot worked out against a
// running server: bring the mail server up in bootstrap mode with a
// credential that exists only for this step, complete bootstrap, bring the
// rest of the stack up without that credential, exempt the front ends from
// the auto-ban, restart so the settings take, create the first account, and
// write down what nobody can recover later.
//
// Every step is one of the plan's steps, in the plan's order. A step that
// fails stops the run with the service's own last lines, because "compose
// exited 1" is not a reason.
package apply
import (
"context"
"crypto/rand"
"fmt"
"io"
"math/big"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/compose"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/docker"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/jmap"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
)
// Images are the defaults. They are tags rather than digests for now: the
// registry is ours and the tags are immutable releases, and pinning digests
// here would mean this program needing a release of its own for every one of
// theirs.
const (
DefaultServerImage = "registry.coffeylabs.org/inbuxa/inbuxa-server:2026.9.23"
DefaultConsoleImage = "registry.coffeylabs.org/inbuxa/inbuxa-admin:2026.9.21.2"
DefaultWebmailImage = "registry.coffeylabs.org/inbuxa/ihasmail-inbuxa:2026.9.22-gc2f13d6"
DefaultCaddyImage = "docker.io/library/caddy:2-alpine"
DefaultSubnet = "172.31.253.0/24"
)
// Result is what the operator is left holding.
type Result struct {
Dir string
AdminUser string
AdminPass string
FirstUser string
FirstPass string
ZoneFile string
Certificate string // what issued the mail server's certificate, when one arrived
ConsoleURL string
WebmailURL string
ServerURL string
}
// Log is how apply reports progress. The interface will draw ticks from it;
// the flag path prints it.
type Log interface {
Step(format string, a ...any)
Info(format string, a ...any)
Out() io.Writer
}
// Run carries out p. It refuses anything but container shapes for now, and
// says so rather than pretending a host install happened.
func Run(ctx context.Context, p plan.Plan, log Log) (*Result, error) {
o := p.Options
for _, c := range []plan.Component{plan.Server, plan.Console, plan.Webmail} {
if o.Shape(c) == plan.Host {
return nil, fmt.Errorf("host installs are not built yet: %s was asked for as a host install", c)
}
}
if o.Shape(plan.Server) != plan.Container {
return nil, fmt.Errorf("this build installs the front ends only alongside a server it installs too; use join once that is built")
}
if len(p.Needs) > 0 && o.InstallDeps {
log.Step("installing what this machine is missing")
if err := deps.Resolve(ctx, log.Out(), p.Needs); err != nil {
return nil, err
}
}
dir := o.Dir
stack := compose.Stack{
Version: "dev",
Project: "inbuxa",
Domain: o.Domain,
Email: o.ACMEEmail,
Local: o.Local,
MailHost: o.MailHost,
ConsoleHost: o.ConsoleHost,
WebmailHost: o.WebmailHost,
Console: o.Shape(plan.Console) == plan.Container,
Webmail: o.Shape(plan.Webmail) == plan.Container,
Proxy: !o.Local && o.Proxy == "caddy",
ServerImage: DefaultServerImage,
ConsoleImage: DefaultConsoleImage,
WebmailImage: DefaultWebmailImage,
CaddyImage: DefaultCaddyImage,
ServerBind: "127.0.0.1:8081",
ConsoleBind: "127.0.0.1:8082",
WebmailBind: "127.0.0.1:8080",
Subnet: DefaultSubnet,
}
if o.Local {
// Nothing is published and nothing is certified: the addresses are
// the loopback binds, which is what the front ends are told to use.
stack.ServerPublicURL = "http://127.0.0.1:8081"
if stack.Console {
stack.ConsoleURL = "http://127.0.0.1:8082"
}
if stack.Webmail {
stack.WebmailURL = "http://127.0.0.1:8080"
}
} else {
stack.MailPorts = []int{25, 465, 993, 995, 4190}
stack.ServerPublicURL = "https://" + o.MailHost
if stack.Console {
stack.ConsoleURL = "https://" + o.ConsoleHost
}
if stack.Webmail {
stack.WebmailURL = "https://" + o.WebmailHost
}
}
// A private ACME CA has to be trusted by two programs that never see each
// other's trust store: the mail server, which asks for its own
// certificate over HTTP-01, and Caddy, which asks for the front ends'.
// The server gets the image's own roots plus this one as a file it mounts
// over its bundle; Caddy gets the root on its own.
if o.ACMECARoot != "" {
root, err := os.ReadFile(o.ACMECARoot)
if err != nil {
return nil, fmt.Errorf("reading the ACME CA root: %w", err)
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
bundle, err := docker.SystemCABundle(ctx, stack.ServerImage)
if err != nil {
return nil, fmt.Errorf("reading the server image's CA bundle: %w", err)
}
if err := os.WriteFile(filepath.Join(dir, "ca-bundle.crt"), append(bundle, root...), 0o644); err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(dir, "acme-ca-root.pem"), root, 0o644); err != nil {
return nil, err
}
stack.CABundle = true
stack.ACMECARoot = "acme-ca-root.pem"
}
stack.ACMEDirectory = o.ACMEDirectory
log.Step("writing the deployment into %s", dir)
if _, err := compose.Write(dir, &stack); err != nil {
return nil, err
}
log.Info("compose.yaml, .env%s", map[bool]string{true: " and Caddyfile", false: ""}[stack.Proxy])
cmp := docker.Compose{Dir: dir}
log.Step("fetching the images")
if err := cmp.Run(ctx, "pull", "--quiet"); err != nil {
return nil, err
}
// The bootstrap credential lives in an override file for this step only,
// and its password only in this process. Bringing the stack up afterwards
// without the override recreates the server without the variable, so no
// fixed recovery credential outlives the setup.
bootPass, err := password(24)
if err != nil {
return nil, err
}
override := filepath.Join(os.TempDir(), "inbuxa-bootstrap.yaml")
if err := os.WriteFile(override, []byte(
"services:\n server:\n environment:\n INBUXA_RECOVERY_ADMIN: ${INBUXA_BOOTSTRAP_ADMIN:?}\n"), 0o600); err != nil {
return nil, err
}
defer os.Remove(override)
log.Step("starting the mail server in bootstrap mode")
boot := cmp
boot.Files = []string{override}
boot.Env = []string{"INBUXA_BOOTSTRAP_ADMIN=admin:" + bootPass}
if err := boot.Run(ctx, "up", "-d", "server"); err != nil {
return nil, err
}
serverURL := "http://" + stack.ServerBind
if err := waitFor(ctx, log, "the mail server", 120*time.Second, func(ctx context.Context) error {
return live(ctx, serverURL)
}); err != nil {
return nil, withLogs(ctx, err, cmp, "server")
}
bootClient := &jmap.Client{BaseURL: serverURL, Username: "admin", Password: bootPass}
if err := bootClient.CheckBootstrapMode(ctx); err != nil {
return nil, err
}
log.Step("setting up %s (hostname %s)", o.Domain, o.MailHost)
admin, err := bootClient.Bootstrap(ctx, o.MailHost, o.Domain)
if err != nil {
return nil, fmt.Errorf("bootstrap: %w", err)
}
res := &Result{
Dir: dir, AdminUser: admin.Username, AdminPass: admin.Secret,
ConsoleURL: stack.ConsoleURL, WebmailURL: stack.WebmailURL, ServerURL: stack.ServerPublicURL,
}
// 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(dir, res); err != nil {
return res, err
}
log.Info("administrator %s, password in %s", admin.Username, filepath.Join(dir, "credentials.txt"))
log.Step("starting the rest of the stack")
if err := cmp.Run(ctx, "up", "-d"); err != nil {
return res, err
}
srv := &jmap.Client{BaseURL: serverURL, Username: admin.Username, Password: admin.Secret}
if err := waitFor(ctx, log, "the server to come back configured", 120*time.Second, func(ctx context.Context) error {
_, err := srv.DomainID(ctx, o.Domain)
return err
}); err != nil {
return res, withLogs(ctx, err, cmp, "server")
}
domainID, err := srv.DomainID(ctx, o.Domain)
if err != nil {
return res, err
}
// Every request the webmail makes arrives from one address: every
// sign-in, every push stream opened and dropped as tabs come and go. The
// server bans per address, so a ban on that one is a ban on everybody's
// webmail. The webmail rate-limits sign-ins per real client itself.
if stack.Webmail {
log.Step("exempting the webmail from the auto-ban")
if err := srv.AllowIP(ctx, stack.WebmailIP, "the webmail: every request arrives from this address"); err != nil {
return res, fmt.Errorf("exempting the webmail: %w", err)
}
}
if stack.Proxy {
log.Step("trusting the proxy's X-Forwarded-For")
if err := srv.TrustForwardedFor(ctx); err != nil {
return res, fmt.Errorf("trusting the proxy: %w", err)
}
if err := srv.AllowIP(ctx, stack.CaddyIP, "the proxy: certificate renewals and autoconfig arrive from this address"); err != nil {
return res, fmt.Errorf("exempting the proxy: %w", err)
}
}
if stack.Webmail || stack.Proxy {
// Neither setting takes effect on a running server.
log.Info("restarting the server so those take effect")
if err := cmp.Run(ctx, "restart", "server"); err != nil {
return res, err
}
if err := waitFor(ctx, log, "the server to restart", 120*time.Second, func(ctx context.Context) error {
_, err := srv.DomainID(ctx, o.Domain)
return err
}); err != nil {
return res, withLogs(ctx, err, cmp, "server")
}
}
if !o.Local {
log.Step("turning on certificates")
if _, err := srv.EnableACME(ctx, domainID, o.ACMEDirectory, o.ACMEEmail); err != nil {
return res, fmt.Errorf("enabling ACME: %w", err)
}
// An order that fails is not retried on its own, and a restart does
// not start a new one: moving the domain to manual and back is what
// does. So this waits, and asks again every so often, rather than
// declaring the install finished over a self-signed certificate that
// every mail client will refuse.
if issuer := waitForCertificate(ctx, srv, log, domainID, o.MailHost, 3*time.Minute); issuer != "" {
res.Certificate = issuer
log.Info("certificate for %s issued by %s", o.MailHost, issuer)
} else {
log.Info("no certificate yet for %s: the server keeps trying, and will succeed once %s resolves to this machine and port 80 reaches it",
o.MailHost, o.MailHost)
}
}
log.Step("creating the first account")
first := "postmaster"
pass, err := password(20)
if err != nil {
return res, err
}
if _, err := srv.CreateUser(ctx, first, domainID, pass); err != nil {
return res, fmt.Errorf("creating %s@%s: %w", first, o.Domain, err)
}
res.FirstUser, res.FirstPass = first+"@"+o.Domain, pass
if err := writeCredentials(dir, res); err != nil {
return res, err
}
zone, err := srv.DNSZone(ctx, domainID)
if err == nil && zone != "" {
res.ZoneFile = filepath.Join(dir, "dns.zone")
if err := os.WriteFile(res.ZoneFile, []byte(zone), 0o644); err != nil {
return res, err
}
log.Info("the records this domain needs are in %s", res.ZoneFile)
}
return res, nil
}
// waitForCertificate waits for the mail server's own certificate to arrive,
// asking for a fresh order every 45 seconds. It returns the issuer, or ""
// when none arrived in time -- which is not a failure: on a real install the
// domain often does not point here yet, and the server goes on trying.
func waitForCertificate(ctx context.Context, srv *jmap.Client, log Log, domainID, host string, limit time.Duration) string {
deadline := time.Now().Add(limit)
nextRetry := time.Now().Add(45 * time.Second)
for time.Now().Before(deadline) {
certs, err := srv.Certificates(ctx)
if err == nil {
for _, c := range certs {
if c.SubjectAlternativeNames[host] && !strings.Contains(c.Issuer, "self signed") {
return c.Issuer
}
}
}
if time.Now().After(nextRetry) {
log.Info("asking for the certificate again")
_ = srv.RetryCertificates(ctx, domainID)
nextRetry = time.Now().Add(45 * time.Second)
}
select {
case <-ctx.Done():
return ""
case <-time.After(3 * time.Second):
}
}
return ""
}
// Verify is the last step: does what was installed actually answer?
func Verify(ctx context.Context, res *Result, stackConsole, stackWebmail bool, log Log) []string {
var problems []string
check := func(what, url string, want string) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := (&http.Client{}).Do(req)
if err != nil {
problems = append(problems, fmt.Sprintf("%s did not answer at %s: %v", what, url, err))
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if want != "" && !strings.Contains(string(body), want) {
problems = append(problems, fmt.Sprintf("%s answered at %s but did not look like itself", what, url))
return
}
log.Info("%s answers", what)
}
check("the mail server", "http://127.0.0.1:8081/.well-known/jmap", "")
if stackConsole {
check("the console", "http://127.0.0.1:8082/", "api-base-url")
}
if stackWebmail {
check("the webmail", "http://127.0.0.1:8080/api/health", "\"ok\":true")
}
return problems
}
func writeCredentials(dir string, r *Result) error {
var b strings.Builder
b.WriteString("# inbuxa -- written by the installer. Keep this file.\n")
b.WriteString("# The administrator's password is not recoverable: nothing else holds it.\n\n")
fmt.Fprintf(&b, "administrator %s\npassword %s\n", r.AdminUser, r.AdminPass)
if r.FirstUser != "" {
fmt.Fprintf(&b, "\nfirst mailbox %s\npassword %s\n", r.FirstUser, r.FirstPass)
}
if r.ConsoleURL != "" {
fmt.Fprintf(&b, "\nconsole %s\n", r.ConsoleURL)
}
if r.WebmailURL != "" {
fmt.Fprintf(&b, "webmail %s\n", r.WebmailURL)
}
return os.WriteFile(filepath.Join(dir, "credentials.txt"), []byte(b.String()), 0o600)
}
func live(ctx context.Context, base string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/jmap", nil)
if err != nil {
return err
}
resp, err := (&http.Client{}).Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
// Unauthenticated, so 401 is the healthy answer: something is listening
// and it is a JMAP server rather than a proxy error page.
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusOK {
return nil
}
return fmt.Errorf("answered %s", resp.Status)
}
func waitFor(ctx context.Context, log Log, what string, limit time.Duration, probe func(context.Context) error) error {
deadline := time.Now().Add(limit)
var last error
for time.Now().Before(deadline) {
if err := probe(ctx); err == nil {
return nil
} else {
last = err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
return fmt.Errorf("waited %s for %s: %w", limit, what, last)
}
func withLogs(ctx context.Context, err error, c docker.Compose, service string) error {
logs := c.Logs(ctx, service, 25)
if strings.TrimSpace(logs) == "" {
return err
}
return fmt.Errorf("%w\n\nlast lines from %s:\n%s", err, service, logs)
}
func password(n int) (string, error) {
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
b := make([]byte, n)
for i := range b {
x, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
if err != nil {
return "", err
}
b[i] = alphabet[x.Int64()]
}
return string(b), nil
}