Files
inbuxa-installer/internal/apply/apply.go
T
jcoffey-dev 7141e565ea Install the suite, for real, in containers
The plan now happens. "inbuxa install --local --domain example.test
--install-deps --yes" on a machine with nothing on it ends with a mail
server, a console and a webmail running, an administrator and a first
mailbox created, and the records the domain needs written out.

The sequence is the one ihasmail-oneshot worked out against a running
server, which is why its JMAP client and its Docker handling came across
nearly whole: bring the server up in bootstrap mode with a credential that
lives in an override file for that step only, complete bootstrap, bring the
rest up without it -- so no recovery credential outlives the setup -- exempt
the front ends from the auto-ban, restart for the settings that need it,
create the first account, and write down the password nothing else holds.

New here: three services rather than two. The console is static files that
learn their server's address at start, and the webmail is given the
first-party OAuth client secret that the server is given too.

Twenty checks in the lab, from a bare Debian 13. The two worth having are
the ones that catch an install that looks fine and is not: nothing in the
running server carries a recovery admin any more, and the account the
installer created can sign in to the webmail it installed.

Two bugs the lab caught, both of which would have shipped:

- the private addresses were worked out on a copy of the stack, so the
  server was told the webmail speaks from "", and refused it.
- the console image rewrites index.html when it starts, so a read-only root
  filesystem left it restarting forever. The webmail keeps read_only; the
  console cannot have it until that rewrite moves.
2026-09-22 18:26:31 -07:00

385 lines
13 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
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
}
}
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.ACMEEmail); err != nil {
return res, fmt.Errorf("enabling ACME: %w", err)
}
}
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
}
// 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
}