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.
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user