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:
2026-09-22 18:26:31 -07:00
parent cc77f62c01
commit 7141e565ea
12 changed files with 1765 additions and 7 deletions
+16 -2
View File
@@ -29,10 +29,22 @@ This is early. What is built:
anything is put in place. `install --install-deps` does the same as part of
a run. A missing dependency is an offer, not a refusal.
Not built yet: applying the plan, the terminal interface, `join`, `status`,
- **`install --yes`** -- carries the plan out, for container shapes: writes
the deployment, fetches the images, brings the mail server up in bootstrap
mode with a credential that exists only for that step, completes bootstrap,
brings the rest up without it, exempts the front ends from the auto-ban,
creates the first mailbox, writes `credentials.txt` and `dns.zone`, and
then checks that all three answer.
Not built yet: host installs, the terminal interface, `join`, `status`,
`upgrade`, `uninstall`. The design is in the inbuxa specification (§6.1 and
the installer draft); the phases are there too.
inbuxa install --local --domain example.test --install-deps --yes
is the shortest thing that works today: the whole suite on loopback, with no
DNS and no certificates, on a machine that starts with nothing.
## Building and testing
go build ./cmd/inbuxa
@@ -41,7 +53,9 @@ The installer writes units, creates users and takes ports 25 and 443, so it
is tested on a throwaway virtual machine rather than on anybody's desk:
e2e/vm/up.sh a Debian 13 machine, in qemu, as you
e2e/vm/run.sh e2e/cases/survey.sh rewind it, then run a case inside
e2e/vm/run.sh e2e/cases/survey.sh what it says about a machine
e2e/vm/run.sh e2e/cases/deps.sh the offer, and taking it
e2e/vm/run.sh e2e/cases/install-local.sh a whole suite, and signing in to it
e2e/vm/down.sh remove it
Each case starts from a copy of the machine taken when it was new, so a run
+45 -2
View File
@@ -12,9 +12,12 @@ import (
"context"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/apply"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
@@ -131,13 +134,53 @@ func install(args []string) int {
return 0
}
if !*yes {
fmt.Fprintln(os.Stderr, "\nnothing has happened yet: applying is not built in this build (pass --dry-run to silence this)")
fmt.Fprintln(os.Stderr, "\nNothing has happened yet. Pass --yes to carry this out.")
return 1
}
fmt.Fprintln(os.Stderr, "\napply is not built yet")
fmt.Println("\nApplying:")
log := &printer{}
res, err := apply.Run(context.Background(), p, log)
if err != nil {
fmt.Fprintln(os.Stderr, "\nstopped: "+err.Error())
return 1
}
fmt.Println("\nChecking it works:")
problems := apply.Verify(context.Background(), res,
o.Shape(plan.Console) != plan.Skip, o.Shape(plan.Webmail) != plan.Skip, log)
for _, why := range problems {
fmt.Fprintln(os.Stderr, " problem: "+why)
}
fmt.Printf("\nDone. %s\n", res.Dir)
fmt.Printf(" administrator %s\n", res.AdminUser)
if res.FirstUser != "" {
fmt.Printf(" first mailbox %s\n", res.FirstUser)
}
fmt.Printf(" passwords %s\n", filepath.Join(res.Dir, "credentials.txt"))
if res.ConsoleURL != "" {
fmt.Printf(" console %s\n", res.ConsoleURL)
}
if res.WebmailURL != "" {
fmt.Printf(" webmail %s\n", res.WebmailURL)
}
if res.ZoneFile != "" {
fmt.Printf(" dns records %s\n", res.ZoneFile)
}
if len(problems) > 0 {
return 1
}
return 0
}
// printer is apply's log on the flag path: steps as lines, everything a
// command says indented under the step that ran it.
type printer struct{}
func (printer) Step(format string, a ...any) { fmt.Printf(" "+format+"\n", a...) }
func (printer) Info(format string, a ...any) { fmt.Printf(" "+format+"\n", a...) }
func (printer) Out() io.Writer { return os.Stdout }
// depsCmd is the offer on its own: what the chosen shapes need that this
// machine does not have, and -- with --install -- the doing of it. It exists
// separately from install because an operator preparing a machine should be
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
# SPDX-FileCopyrightText: 2026 Coffey Labs
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# The whole thing, on a machine with nothing on it: install the suite in
# containers, loopback only, and then prove it is a working mail system rather
# than three containers that happen to be running.
#
# The checks that matter most are the last two: that the credential used to
# bootstrap the server does not outlive the setup, and that the account the
# installer created can actually sign in to the webmail it installed. Either
# one failing means the install looked fine and was not.
#
# Run from the host with: e2e/vm/run.sh e2e/cases/install-local.sh
set -uo pipefail
pass=0; fail=0
ok() { echo " ok $*"; pass=$((pass+1)); }
bad() { echo " FAIL $*"; fail=$((fail+1)); }
has() { grep -q -- "$2" <<<"$1" && ok "$3" || { bad "$3"; echo "$1" | tail -20 | sed 's/^/ /'; }; }
DIR=/var/lib/inbuxa
echo "==> installing"
OUT="$(/tmp/inbuxa install --local --domain example.test --install-deps --yes 2>&1)"; rc=$?
echo "$OUT" | grep -v '^ |' | tail -24 | sed 's/^/ /'
[ $rc -eq 0 ] && ok "installed (exit 0)" || bad "exit $rc"
echo
echo "==> what it left on disk"
[ -f "$DIR/compose.yaml" ] && ok "compose.yaml, which is the deployment" || bad "no compose.yaml"
[ -f "$DIR/credentials.txt" ] && ok "credentials.txt" || bad "no credentials.txt"
[ -f "$DIR/dns.zone" ] && ok "dns.zone" || bad "no dns.zone"
[ "$(stat -c %a "$DIR/.env" 2>/dev/null)" = 600 ] && ok ".env is 600" || bad ".env is $(stat -c %a "$DIR/.env" 2>/dev/null), not 600"
[ "$(stat -c %a "$DIR/credentials.txt")" = 600 ] && ok "credentials.txt is 600" || bad "credentials.txt is $(stat -c %a "$DIR/credentials.txt"), not 600"
grep -q "MX" "$DIR/dns.zone" && ok "the zone file has the MX record" || bad "no MX in the zone file"
grep -q "_domainkey" "$DIR/dns.zone" && ok "and the DKIM key it generated" || bad "no DKIM record"
echo
echo "==> what it left running"
STATE="$(docker compose -f $DIR/compose.yaml ps --format '{{.Service}} {{.State}}')"
for s in server console webmail; do
grep -q "^$s running" <<<"$STATE" && ok "$s is running" || { bad "$s is not running"; echo "$STATE" | sed 's/^/ /'; }
done
echo
echo "==> and it answers"
curl -fsS -o /dev/null http://127.0.0.1:8081/.well-known/jmap -w '' 2>/dev/null
[ $? -le 22 ] && ok "the mail server answers on its loopback bind" || bad "the mail server does not answer"
curl -fsS http://127.0.0.1:8082/ 2>/dev/null | grep -q 'api-base-url' && ok "the console is served, pointed at the server" || bad "the console is not served"
curl -fsS http://127.0.0.1:8080/api/health 2>/dev/null | grep -q '"ok":true' && ok "the webmail is healthy" || bad "the webmail is not healthy"
echo
echo "==> the bootstrap credential did not outlive the setup"
ENVOUT="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$(docker compose -f $DIR/compose.yaml ps -q server)")"
grep -q "RECOVERY_ADMIN" <<<"$ENVOUT" && { bad "the server still carries a recovery admin"; echo "$ENVOUT" | grep RECOVERY | sed 's/^/ /'; } || ok "no recovery admin in the running server"
grep -q "INBUXA_WEBMAIL_CLIENT_SECRET" <<<"$ENVOUT" && ok "the webmail's client secret is where it belongs" || bad "the server has no webmail client secret"
echo
echo "==> the account it created can sign in to the webmail it installed"
USER=$(awk '/^first mailbox/ {print $3}' "$DIR/credentials.txt")
PASS=$(awk '/^first mailbox/{getline; print $2}' "$DIR/credentials.txt")
[ -n "$USER" ] && ok "credentials.txt names the first mailbox ($USER)" || bad "no first mailbox in credentials.txt"
CODE=$(curl -s -o /tmp/login.json -c /tmp/jar -w '%{http_code}' -X POST http://127.0.0.1:8080/api/auth/login \
-H 'Content-Type: application/json' -H 'X-Requested-With: ihasmail' \
-d "{\"username\":\"$USER\",\"password\":\"$PASS\",\"remember\":true}")
[ "$CODE" = 200 ] && ok "sign-in succeeds" || { bad "sign-in answered $CODE"; head -c 200 /tmp/login.json | sed 's/^/ /'; }
grep -q "urn:ietf:params:jmap:mail" /tmp/login.json && ok "and the session carries the mail capability" || bad "no mail capability in the session"
echo
echo "==> running it again converges rather than duplicating"
OUT="$(/tmp/inbuxa install --local --domain example.test --yes 2>&1)"; rc=$?
COUNT=$(docker compose -f $DIR/compose.yaml ps --format '{{.Service}}' | sort -u | wc -l)
[ "$COUNT" = 3 ] && ok "still three services, not six" || bad "$COUNT services after a second run"
echo
echo "==> $pass passed, $fail failed"
[ "$fail" -eq 0 ]
+384
View File
@@ -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
}
+173
View File
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package compose writes the deployment: a compose file, a Caddyfile, and the
// .env beside them that holds the secrets those two refer to.
//
// The files are the deployment. Once they are written, `docker compose up -d`
// in that directory is the whole of it, with or without this installer -- an
// operator who never runs `inbuxa` again still has something they can read,
// edit and bring up by hand.
package compose
import (
"crypto/rand"
"embed"
"encoding/base64"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"text/template"
)
//go:embed templates/*.tmpl
var templates embed.FS
// Stack is everything the two templates need. It is flat on purpose: a
// template that reaches through three structs to find a hostname is a
// template nobody can check against the file it produces.
type Stack struct {
Version string
Project string
Domain string
Email string
Local bool
MailHost string
ConsoleHost string
WebmailHost string
Console bool
Webmail bool
Proxy bool
ServerImage string
ConsoleImage string
WebmailImage string
CaddyImage string
ServerBind string // host address for the server's plain HTTP
ConsoleBind string
WebmailBind string
Subnet string
ServerIP string
ConsoleIP string
WebmailIP string
CaddyIP string
MailPorts []int // published as-is: 25, 465, 993, 995, 4190
// URLs as a browser will use them. Local installs have no proxy and no
// certificates, so they are the loopback binds instead.
ServerPublicURL string
ConsoleURL string
WebmailURL string
ACMEDirectory string
ACMECARoot string
CABundle bool
}
// ServerNames is every name the server's certificate and the proxy need.
func (s Stack) ServerNames() []string {
names := []string{s.MailHost}
for _, n := range []string{"autoconfig", "autodiscover", "mta-sts"} {
names = append(names, n+"."+s.Domain)
}
return names
}
// Addresses fills in the private network from the subnet, so the compose file
// has fixed addresses rather than whatever order the daemon started things in.
func (s *Stack) Addresses() error {
ip, network, err := net.ParseCIDR(s.Subnet)
if err != nil {
return fmt.Errorf("subnet %q: %w", s.Subnet, err)
}
ip = ip.To4()
if ip == nil {
return fmt.Errorf("subnet %q is not IPv4", s.Subnet)
}
at := func(n byte) string {
a := make(net.IP, len(ip))
copy(a, ip)
a[3] += n
if !network.Contains(a) {
return ""
}
return a.String()
}
s.ServerIP, s.ConsoleIP, s.WebmailIP, s.CaddyIP = at(10), at(11), at(12), at(13)
if s.ServerIP == "" || s.CaddyIP == "" {
return fmt.Errorf("subnet %q is too small for the stack", s.Subnet)
}
return nil
}
// Write renders the deployment into dir. It returns the secrets it generated,
// because the caller has to hand one of them to the server as well.
type Secrets struct {
AppSecret string // the webmail's session key
WebmailOAuth string // the first-party client secret both sides hold
}
// Write takes a pointer because it fills in the private addresses as it
// goes, and the caller needs them: the mail server is told which addresses
// the front ends speak from, and passing a copy meant telling it "".
func Write(dir string, s *Stack) (Secrets, error) {
var sec Secrets
if err := os.MkdirAll(dir, 0o750); err != nil {
return sec, err
}
if err := s.Addresses(); err != nil {
return sec, err
}
var err error
if sec.AppSecret, err = secret(); err != nil {
return sec, err
}
if sec.WebmailOAuth, err = secret(); err != nil {
return sec, err
}
funcs := template.FuncMap{"join": strings.Join}
for name, out := range map[string]string{
"compose.yaml.tmpl": "compose.yaml",
"Caddyfile.tmpl": "Caddyfile",
} {
if out == "Caddyfile" && !s.Proxy {
continue
}
t, err := template.New(name).Funcs(funcs).ParseFS(templates, "templates/"+name)
if err != nil {
return sec, err
}
var b strings.Builder
if err := t.Execute(&b, s); err != nil {
return sec, fmt.Errorf("%s: %w", name, err)
}
if err := os.WriteFile(filepath.Join(dir, out), []byte(b.String()), 0o640); err != nil {
return sec, err
}
}
// The secrets live beside the compose file, not in it, so the compose
// file can be read to somebody, pasted into an issue, or committed.
env := fmt.Sprintf("APP_SECRET=%s\nWEBMAIL_CLIENT_SECRET=%s\n", sec.AppSecret, sec.WebmailOAuth)
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(env), 0o600); err != nil {
return sec, err
}
return sec, nil
}
func secret() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
+71
View File
@@ -0,0 +1,71 @@
# Written by the inbuxa installer {{.Version}} for {{.Domain}}.
#
# Caddy holds 80 and 443 for two programs that both want certificates for some
# of the same names: Caddy itself, to serve HTTPS, and the mail server, whose
# IMAP and SMTP listeners need a certificate of their own. They are kept apart
# by challenge type rather than by name:
#
# Caddy TLS-ALPN-01 on 443 -- for the server's names it never uses 80.
# the server HTTP-01 on 80, which Caddy forwards to it untouched.
#
# So neither answers the other's challenge, and neither needs the other's key.
{
email {{.Email}}
{{- if .ACMEDirectory}}
acme_ca {{.ACMEDirectory}}
{{- end}}
{{- if .ACMECARoot}}
acme_ca_root /etc/caddy/acme-ca-root.pem
{{- end}}
}
{{- if .Webmail}}
# The webmail. Push arrives as Server-Sent Events, so responses are flushed as
# they are written rather than buffered.
{{.WebmailHost}} {
encode zstd gzip
reverse_proxy webmail:8080 {
flush_interval -1
}
}
{{- end}}
{{- if .Console}}
# The console. Static files: it talks to the mail server from the browser, so
# nothing here proxies the API.
{{.ConsoleHost}} {
encode zstd gzip
reverse_proxy console:8080
}
{{- end}}
# The mail server's web side: JMAP for the front ends and any other client,
# CalDAV, CardDAV, autoconfig and MTA-STS. The server is told to believe the
# X-Forwarded-For Caddy sets here, so a scanner is banned by its own address
# rather than by Caddy's.
{{join .ServerNames ", "}} {
tls {
issuer acme {
{{- if .ACMEDirectory}}
dir {{.ACMEDirectory}}
{{- end}}
{{- if .ACMECARoot}}
trusted_roots /etc/caddy/acme-ca-root.pem
{{- end}}
email {{.Email}}
disable_http_challenge
}
}
reverse_proxy server:8080
}
# Port 80 for the server's names is its challenge path and a redirect.
{{range $i, $n := .ServerNames}}{{if $i}}, {{end}}http://{{$n}}{{end}} {
handle /.well-known/acme-challenge/* {
reverse_proxy server:8080
}
handle {
redir https://{host}{uri} 308
}
}
@@ -0,0 +1,136 @@
# Written by the inbuxa installer {{.Version}} for {{.Domain}}.
#
# This is the whole deployment. Bring it up again with `docker compose up -d`
# from this directory. Secrets are in .env beside it and the administrator's
# password is in credentials.txt -- both readable only by root.
#
# The mail server's plain HTTP port is published on loopback only and reached
# over the private network below, which is why the front ends talk to it as
# http://: that leg never leaves this machine.
name: {{.Project}}
services:
server:
image: {{.ServerImage}}
hostname: {{.MailHost}}
restart: unless-stopped
ports:
- "{{.ServerBind}}:8080"
{{- range .MailPorts}}
- "{{.}}:{{.}}"
{{- end}}
volumes:
- inbuxa-etc:/opt/stalwart/etc
- inbuxa-data:/opt/stalwart/data
{{- if .CABundle}}
# The system roots plus the private ACME CA, so the server can reach it.
- ./ca-bundle.crt:/etc/ssl/certs/ca-certificates.crt:ro
{{- end}}
environment:
# The front ends, which is what registers their OAuth clients and sets
# the cross-origin allowlist (contract C-6 and C-14). The webmail's
# secret is generated by the installer and given to both sides.
{{- if .ConsoleURL}}
INBUXA_ADMIN_URL: {{.ConsoleURL}}
{{- end}}
{{- if .WebmailURL}}
INBUXA_WEBMAIL_URL: {{.WebmailURL}}
INBUXA_WEBMAIL_CLIENT_SECRET: ${WEBMAIL_CLIENT_SECRET:?WEBMAIL_CLIENT_SECRET is missing from .env}
{{- end}}
networks:
stack:
ipv4_address: {{.ServerIP}}
{{- if .Console}}
console:
image: {{.ConsoleImage}}
restart: unless-stopped
depends_on: [server]
# Not read-only, unlike the webmail: this image writes the api-base-url
# into index.html when it starts, which is what lets one image serve any
# installation. A read-only root makes it restart forever instead.
tmpfs: [/tmp, /var/cache/nginx, /var/run]
ports:
- "{{.ConsoleBind}}:8080"
environment:
# Written into index.html at start, so one image serves any
# installation. The browser talks to the server directly from here.
API_BASE_URL: {{.ServerPublicURL}}
networks:
stack:
ipv4_address: {{.ConsoleIP}}
{{- end}}
{{- if .Webmail}}
webmail:
image: {{.WebmailImage}}
restart: unless-stopped
depends_on: [server]
# Immutable: read-only root, no volume, sessions in memory. A restart
# signs everyone out; nothing else is lost, because nothing else is kept.
read_only: true
tmpfs: [/tmp]
ports:
- "{{.WebmailBind}}:8080"
environment:
MAIL_SERVER_URL: http://server:8080
APP_SECRET: ${APP_SECRET:?APP_SECRET is missing from .env}
APP_NAME: inbuxa
IMMUTABLE: "1"
SESSION_FILE: ""
TRUST_PROXY: "1"
IMAGE_PROXY: "1"
{{- if not .Local}}
# Sign-in goes through the mail server's own page, as the first-party
# client the server registered for this URL.
OAUTH_CLIENT_ID: ihasmail-inbuxa
OAUTH_CLIENT_SECRET: ${WEBMAIL_CLIENT_SECRET:?WEBMAIL_CLIENT_SECRET is missing from .env}
PUBLIC_URL: {{.WebmailURL}}
{{- if .ConsoleURL}}
ADMIN_URL: {{.ConsoleURL}}
{{- end}}
# The server pushes changes here instead of holding a connection per
# tab. If it cannot reach it, every tab falls back to the relay.
PUSH_URL: {{.WebmailURL}}
{{- end}}
networks:
stack:
ipv4_address: {{.WebmailIP}}
{{- end}}
{{- if .Proxy}}
caddy:
image: {{.CaddyImage}}
restart: unless-stopped
depends_on: [server]
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
{{- if .ACMECARoot}}
- ./acme-ca-root.pem:/etc/caddy/acme-ca-root.pem:ro
{{- end}}
networks:
stack:
ipv4_address: {{.CaddyIP}}
{{- end}}
networks:
stack:
ipam:
config:
- subnet: {{.Subnet}}
volumes:
inbuxa-etc:
inbuxa-data:
{{- if .Proxy}}
# Certificates and the ACME account. Losing this means asking for every
# certificate again, which is how rate limits are reached.
caddy-data:
caddy-config:
{{- end}}
+188
View File
@@ -0,0 +1,188 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package docker drives the docker CLI. The CLI rather than the Engine API,
// because compose is the thing being driven and the CLI is how it ships: the
// deployment the tool leaves behind is one an operator manages with the same
// commands.
package docker
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// Output runs docker with args and returns its standard output.
func Output(ctx context.Context, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "docker", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("docker %s: %s", strings.Join(args, " "), msg)
}
return strings.TrimSpace(stdout.String()), nil
}
// Pull pulls one image. Quiet, because it runs before the plan is confirmed
// and the step already says what it is fetching.
func Pull(ctx context.Context, image string) error {
_, err := Output(ctx, "pull", "--quiet", image)
return err
}
// ImageID is the local ID of an image, which is the same for two references
// only when they are the same image.
func ImageID(ctx context.Context, image string) (string, error) {
return Output(ctx, "image", "inspect", "--format", "{{.Id}}", image)
}
// ImageEnv is the value an image's configuration gives an environment
// variable, or "" when it sets none.
func ImageEnv(ctx context.Context, image, name string) (string, error) {
out, err := Output(ctx, "image", "inspect", "--format", "{{range .Config.Env}}{{println .}}{{end}}", image)
if err != nil {
return "", err
}
return envValue(out, name), nil
}
func envValue(env, name string) string {
for _, line := range strings.Split(env, "\n") {
if v, ok := strings.CutPrefix(line, name+"="); ok {
return v
}
}
return ""
}
// RepoDigest is an image's by-digest reference in one repository, e.g.
// registry.coffeylabs.org/coffey-labs/ihasmail@sha256:..., which names exactly that image for
// as long as the registry keeps it.
func RepoDigest(ctx context.Context, image, repository string) (string, error) {
out, err := Output(ctx, "image", "inspect", "--format", "{{range .RepoDigests}}{{println .}}{{end}}", image)
if err != nil {
return "", err
}
for _, d := range strings.Fields(out) {
if strings.HasPrefix(d, repository+"@") {
return d, nil
}
}
return "", fmt.Errorf("%s has no digest from %s", image, repository)
}
// Versions returns the engine and compose versions, which is also the check
// that both are installed and this user may use them.
func Versions(ctx context.Context) (engine, compose string, err error) {
if _, err := exec.LookPath("docker"); err != nil {
return "", "", errors.New("docker is not installed, or not on PATH")
}
if engine, err = Output(ctx, "version", "--format", "{{.Server.Version}}"); err != nil {
return "", "", fmt.Errorf("cannot talk to the Docker daemon -- is it running, and may this user use it? (%w)", err)
}
if compose, err = Output(ctx, "compose", "version", "--short"); err != nil {
return engine, "", fmt.Errorf("the docker compose plugin is not installed (%w)", err)
}
return engine, compose, nil
}
// ProjectLeftovers lists containers, volumes and networks already labeled
// with a compose project name. Any at all means an earlier run of the same
// project, and its volumes would hand a "fresh" deployment an old server.
func ProjectLeftovers(ctx context.Context, project string) ([]string, error) {
filter := "label=com.docker.compose.project=" + project
var found []string
for _, kind := range []struct{ name, format string }{
{"container", "{{.Names}}"},
{"volume", "{{.Name}}"},
{"network", "{{.Name}}"},
} {
args := []string{kind.name, "ls", "--filter", filter, "--format", kind.format}
if kind.name == "container" {
args = []string{"ps", "-a", "--filter", filter, "--format", kind.format}
}
out, err := Output(ctx, args...)
if err != nil {
return nil, err
}
for _, line := range strings.Fields(out) {
found = append(found, kind.name+" "+line)
}
}
return found, nil
}
// Compose runs docker compose against one deployment directory.
type Compose struct {
Dir string
Files []string // extra -f files after compose.yaml, e.g. the bootstrap override
Env []string // added to the environment compose interpolates from
Out io.Writer
}
// Run runs a compose command with its output passed through: pulling images
// takes long enough that silence would look like a hang. Everything but a pull
// is quiet, because without a terminal compose prints each container's every
// state change twice and the tool already says what step it is on.
func (c Compose) Run(ctx context.Context, args ...string) error {
full := []string{"compose", "--project-directory", c.Dir, "-f", c.Dir + "/compose.yaml"}
if len(args) > 0 && args[0] != "pull" {
full = append(full, "--progress", "quiet")
}
for _, f := range c.Files {
full = append(full, "-f", f)
}
full = append(full, args...)
cmd := exec.CommandContext(ctx, "docker", full...)
cmd.Env = append(os.Environ(), c.Env...)
cmd.Stdout, cmd.Stderr = c.Out, c.Out
if err := cmd.Run(); err != nil {
return fmt.Errorf("docker compose %s: %w", strings.Join(args, " "), err)
}
return nil
}
// Running reports whether a service has a running container.
func (c Compose) Running(ctx context.Context, service string) bool {
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
"ps", "--status", "running", "--services")
if err != nil {
return false
}
for _, s := range strings.Fields(out) {
if s == service {
return true
}
}
return false
}
// Logs returns the last lines of one service's log, for a failure report.
func (c Compose) Logs(ctx context.Context, service string, lines int) string {
out, err := Output(ctx, "compose", "--project-directory", c.Dir, "-f", c.Dir+"/compose.yaml",
"logs", "--no-color", "--tail", fmt.Sprint(lines), service)
if err != nil {
return err.Error()
}
return out
}
// SystemCABundle reads the CA bundle out of an image, so a private CA can be
// added to the roots the image already trusts rather than replacing them.
func SystemCABundle(ctx context.Context, image string) ([]byte, error) {
out, err := Output(ctx, "run", "--rm", "--entrypoint", "cat", image, "/etc/ssl/certs/ca-certificates.crt")
if err != nil {
return nil, err
}
return []byte(out + "\n"), nil
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
package docker
import "testing"
func TestEnvValue(t *testing.T) {
env := "PATH=/usr/local/bin:/usr/bin\nNODE_ENV=production\nIHASMAIL_VERSION=2026.9.13+pr344\nEMPTY=\n"
for name, want := range map[string]string{
"IHASMAIL_VERSION": "2026.9.13+pr344",
"NODE_ENV": "production",
"EMPTY": "",
"MISSING": "",
"IHASMAIL": "", // a prefix of a name is not the name
} {
if got := envValue(env, name); got != want {
t.Errorf("envValue(%q) = %q, want %q", name, got, want)
}
}
}
+233
View File
@@ -0,0 +1,233 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package jmap talks to an inbuxa server over JMAP.
//
// There is no REST management API and no configuration file to template: a
// server with an empty configuration directory starts in bootstrap mode, and
// everything from the first administrator to the ACME account is a registry
// object read and written with x: methods. This client came from
// ihasmail-oneshot, where every call in it was worked out against a running
// server rather than from documentation.
package jmap
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
var using = []string{"urn:ietf:params:jmap:core", "urn:stalwart:jmap"}
// Client calls one Stalwart as one account. The zero HTTP uses a client with a
// timeout, so a hung server fails a step instead of hanging the tool.
type Client struct {
BaseURL string // e.g. http://127.0.0.1:8081, no trailing slash
Username string
Password string
HTTP *http.Client
}
// Call is one method call in a request.
type Call struct {
Method string
Args any
ID string
}
// Response is one method response.
type Response struct {
Method string
Args json.RawMessage
ID string
}
// MethodError is a JMAP method-level error: the request was fine, the call was
// refused.
type MethodError struct {
Method string
Type string
Description string
}
func (e *MethodError) Error() string {
if e.Description != "" {
return fmt.Sprintf("%s: %s: %s", e.Method, e.Type, e.Description)
}
return fmt.Sprintf("%s: %s", e.Method, e.Type)
}
// HTTPError is a response that was not a JMAP response at all.
type HTTPError struct {
Status int
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, strings.TrimSpace(e.Body))
}
func (c *Client) httpClient() *http.Client {
if c.HTTP != nil {
return c.HTTP
}
return &http.Client{Timeout: 30 * time.Second}
}
// Do sends calls in one request and returns their responses in order. A method
// error in any of them is returned as a *MethodError, after the responses
// before it -- JMAP stops nothing on an error, but every caller here needs
// all of its calls to have worked.
func (c *Client) Do(ctx context.Context, calls ...Call) ([]Response, error) {
mc := make([][3]any, len(calls))
for i, call := range calls {
mc[i] = [3]any{call.Method, call.Args, call.ID}
}
body, err := json.Marshal(map[string]any{"using": using, "methodCalls": mc})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/jmap/", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.SetBasicAuth(c.Username, c.Password)
req.Header.Set("Content-Type", "application/json")
res, err := c.httpClient().Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 16<<20))
if err != nil {
return nil, err
}
if res.StatusCode != http.StatusOK {
return nil, &HTTPError{Status: res.StatusCode, Body: string(raw)}
}
var envelope struct {
MethodResponses [][3]json.RawMessage `json:"methodResponses"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
return nil, fmt.Errorf("not a JMAP response: %w", err)
}
out := make([]Response, 0, len(envelope.MethodResponses))
for _, mr := range envelope.MethodResponses {
var r Response
if err := json.Unmarshal(mr[0], &r.Method); err != nil {
return nil, fmt.Errorf("not a JMAP response: %w", err)
}
if err := json.Unmarshal(mr[2], &r.ID); err != nil {
return nil, fmt.Errorf("not a JMAP response: %w", err)
}
r.Args = mr[1]
if r.Method == "error" {
var e struct {
Type string `json:"type"`
Description string `json:"description"`
}
_ = json.Unmarshal(r.Args, &e)
method := r.ID
for _, call := range calls {
if call.ID == r.ID {
method = call.Method
}
}
return out, &MethodError{Method: method, Type: e.Type, Description: e.Description}
}
out = append(out, r)
}
if len(out) != len(calls) {
return out, fmt.Errorf("sent %d method calls and got %d responses", len(calls), len(out))
}
return out, nil
}
// SetError is one entry of notCreated, notUpdated or notDestroyed.
type SetError struct {
Type string `json:"type"`
Description string `json:"description"`
Properties []string `json:"properties"`
}
func (e SetError) Error() string {
s := e.Type
if e.Description != "" {
s += ": " + e.Description
}
if len(e.Properties) > 0 {
s += " (" + strings.Join(e.Properties, ", ") + ")"
}
return s
}
// SetResult is the part of a /set response every caller here reads.
type SetResult struct {
Created map[string]json.RawMessage `json:"created"`
Updated map[string]json.RawMessage `json:"updated"`
NotCreated map[string]SetError `json:"notCreated"`
NotUpdated map[string]SetError `json:"notUpdated"`
NotDestroyed map[string]SetError `json:"notDestroyed"`
}
// Refused returns the first refusal in the result, if any.
func (r SetResult) Refused() error {
for _, m := range []map[string]SetError{r.NotCreated, r.NotUpdated, r.NotDestroyed} {
for id, e := range m {
return fmt.Errorf("%s: %w", id, e)
}
}
return nil
}
// DecodeSet reads a /set response, turning a not-created or not-updated
// entry into an error rather than a silent success.
func DecodeSet(r Response) (SetResult, error) {
var s SetResult
if err := json.Unmarshal(r.Args, &s); err != nil {
return s, fmt.Errorf("%s: %w", r.Method, err)
}
if err := s.Refused(); err != nil {
return s, fmt.Errorf("%s refused %w", r.Method, err)
}
return s, nil
}
// CreatedID reads the server-assigned id of a created object.
func CreatedID(s SetResult, key string) (string, error) {
raw, ok := s.Created[key]
if !ok {
return "", errors.New("the server did not confirm the create")
}
var obj struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &obj); err != nil || obj.ID == "" {
return "", errors.New("the server confirmed the create without an id")
}
return obj.ID, nil
}
// Live reports whether Stalwart answers its liveness probe, which it does in
// bootstrap mode too.
func Live(ctx context.Context, baseURL string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/healthz/live", nil)
if err != nil {
return err
}
res, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
return err
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
return &HTTPError{Status: res.StatusCode}
}
return nil
}
+124
View File
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
package jmap
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fake answers each request with the response registered for its first
// method, and records what it was sent.
func fake(t *testing.T, responses map[string]string) (*Client, *[]map[string]any) {
t.Helper()
var seen []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u, p, _ := r.BasicAuth(); u != "admin" || p != "pw" {
w.WriteHeader(http.StatusUnauthorized)
return
}
var req struct {
Using []string `json:"using"`
MethodCalls [][3]json.RawMessage `json:"methodCalls"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("bad request body: %v", err)
}
var method string
_ = json.Unmarshal(req.MethodCalls[0][0], &method)
var args map[string]any
_ = json.Unmarshal(req.MethodCalls[0][1], &args)
seen = append(seen, map[string]any{"method": method, "args": args, "using": req.Using})
body, ok := responses[method]
if !ok {
t.Errorf("unexpected method %s", method)
}
w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return &Client{BaseURL: srv.URL, Username: "admin", Password: "pw"}, &seen
}
func TestBootstrapReturnsTheAdministrator(t *testing.T) {
c, seen := fake(t, map[string]string{
"x:Bootstrap/set": `{"methodResponses":[["x:Bootstrap/set",{"updated":{"singleton":{"username":"[email protected]","secret":"s3cret"}}},"0"]]}`,
})
a, err := c.Bootstrap(context.Background(), "mail.example.com", "example.com")
if err != nil {
t.Fatal(err)
}
if a.Username != "[email protected]" || a.Secret != "s3cret" {
t.Errorf("admin = %+v", a)
}
update := (*seen)[0]["args"].(map[string]any)["update"].(map[string]any)["singleton"].(map[string]any)
if update["requestTlsCertificate"] != false || update["tracer"].(map[string]any)["@type"] != "Stdout" {
t.Errorf("bootstrap sent %v", update)
}
if using := (*seen)[0]["using"].([]string); len(using) != 2 || using[1] != "urn:stalwart:jmap" {
t.Errorf("using = %v", using)
}
}
func TestSetRefusalIsAnError(t *testing.T) {
c, _ := fake(t, map[string]string{
"x:Account/set": `{"methodResponses":[["x:Account/set",{"notCreated":{"user":{"type":"invalidPatch","description":"Missing or invalid '@type' property in object","properties":["roles"]}}},"0"]]}`,
})
_, err := c.CreateUser(context.Background(), "alice", "b", "pw")
if err == nil || !strings.Contains(err.Error(), "invalidPatch") || !strings.Contains(err.Error(), "roles") {
t.Fatalf("err = %v", err)
}
}
func TestMethodErrorNamesTheMethod(t *testing.T) {
c, _ := fake(t, map[string]string{
"x:Bootstrap/get": `{"methodResponses":[["error",{"type":"unknownMethod"},"0"]]}`,
})
err := c.CheckBootstrapMode(context.Background())
var me *MethodError
if !errors.As(err, &me) || me.Method != "x:Bootstrap/get" || me.Type != "unknownMethod" {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "not in bootstrap mode") {
t.Errorf("err = %v", err)
}
}
func TestConfiguredServerRefusesBootstrapCredentials(t *testing.T) {
c, _ := fake(t, nil)
c.Password = "the-bootstrap-password"
if err := c.CheckBootstrapMode(context.Background()); err == nil || !strings.Contains(err.Error(), "not in bootstrap mode") {
t.Fatalf("err = %v", err)
}
}
func TestEnableACMEUsesHTTP01AndTheBackReference(t *testing.T) {
c, seen := fake(t, map[string]string{
"x:AcmeProvider/set": `{"methodResponses":[["x:AcmeProvider/set",{"created":{"acme":{"id":"p1"}}},"0"],["x:Domain/set",{"updated":{"b":null}},"1"]]}`,
})
id, err := c.EnableACME(context.Background(), "b", "", "[email protected]")
if err != nil || id != "p1" {
t.Fatalf("id %q, err %v", id, err)
}
provider := (*seen)[0]["args"].(map[string]any)["create"].(map[string]any)["acme"].(map[string]any)
if provider["challengeType"] != "Http01" {
t.Errorf("challengeType = %v", provider["challengeType"])
}
if _, set := provider["directory"]; set {
t.Error("directory sent without --acme-directory; Stalwart's default is Let's Encrypt")
}
}
func TestDomainIDNotFound(t *testing.T) {
c, _ := fake(t, map[string]string{
"x:Domain/query": `{"methodResponses":[["x:Domain/query",{"ids":["b"]},"0"],["x:Domain/get",{"list":[{"id":"b","name":"other.test"}]},"1"]]}`,
})
if _, err := c.DomainID(context.Background(), "example.com"); err == nil {
t.Fatal("found a domain that is not there")
}
}
+293
View File
@@ -0,0 +1,293 @@
// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-3.0-or-later
package jmap
// This file drives an inbuxa server's first boot over JMAP: bootstrap,
// certificates, the proxy's address, and the first account. It came from
// the webmail-oneshot and keeps its sequence, which SPEC.md 6.2 describes.
import (
"context"
"encoding/json"
"errors"
"fmt"
)
// Admin is the permanent administrator bootstrap provisions.
type Admin struct {
Username string `json:"username"`
Secret string `json:"secret"`
}
// CheckBootstrapMode confirms the server is fresh. x:Bootstrap exists only in
// bootstrap mode, so a server that has been set up before -- a volume left
// over from an earlier run, say -- refuses the call, and the tool stops before
// writing anything into someone's configured mail server.
func (c *Client) CheckBootstrapMode(ctx context.Context) error {
_, err := c.Do(ctx, Call{"x:Bootstrap/get", map[string]any{"ids": []string{"singleton"}, "properties": []string{"id"}}, "0"})
var me *MethodError
var he *HTTPError
// A configured server either refuses the method or, having no bootstrap
// account any more, the credentials.
if errors.As(err, &me) || (errors.As(err, &he) && he.Status == 401) {
return fmt.Errorf("this server is not in bootstrap mode, so it has been configured before (%w)", err)
}
return err
}
// Bootstrap completes the setup wizard the web UI would otherwise walk someone
// through, and returns the administrator it creates. The temporary bootstrap
// account stops working once the server restarts out of bootstrap mode.
func (c *Client) Bootstrap(ctx context.Context, hostname, domain string) (Admin, error) {
update := map[string]any{
"serverHostname": hostname,
"defaultDomain": domain,
// Off, and done explicitly afterwards (see EnableACME): on 0.16.22 this
// flag creates no ACME provider and leaves the domain on manual
// certificates, so turning it on would only look like it had worked.
"requestTlsCertificate": false,
"generateDkimKeys": true,
// The default logs to /var/log/stalwart, which does not exist in the
// image and is not a volume. A container logs to stdout.
"tracer": map[string]any{
"@type": "Stdout", "enable": true, "level": "info",
"ansi": false, "multiline": false, "lossy": false,
"events": map[string]any{}, "eventsPolicy": "exclude",
},
}
rs, err := c.Do(ctx, Call{"x:Bootstrap/set", map[string]any{"update": map[string]any{"singleton": update}}, "0"})
if err != nil {
return Admin{}, err
}
s, err := DecodeSet(rs[0])
if err != nil {
return Admin{}, err
}
var a Admin
if err := json.Unmarshal(s.Updated["singleton"], &a); err != nil || a.Username == "" || a.Secret == "" {
return Admin{}, errors.New("x:Bootstrap/set did not return the administrator it created")
}
return a, nil
}
// DomainID finds a domain by name.
func (c *Client) DomainID(ctx context.Context, name string) (string, error) {
rs, err := c.Do(ctx,
Call{"x:Domain/query", map[string]any{}, "0"},
Call{"x:Domain/get", map[string]any{
"#ids": map[string]any{"resultOf": "0", "name": "x:Domain/query", "path": "/ids"},
"properties": []string{"name"},
}, "1"},
)
if err != nil {
return "", err
}
var got struct {
List []struct{ ID, Name string } `json:"list"`
}
if err := json.Unmarshal(rs[1].Args, &got); err != nil {
return "", err
}
for _, d := range got.List {
if d.Name == name {
return d.ID, nil
}
}
return "", fmt.Errorf("domain %s does not exist on this server", name)
}
// EnableACME creates an ACME account using HTTP-01 and moves the domain's
// certificates onto it. the server starts the order at once, with no restart.
//
// HTTP-01 rather than the server's default TLS-ALPN-01, because Caddy holds 443.
// Caddy forwards /.well-known/acme-challenge/ on port 80 for the server's names
// to the server and uses TLS-ALPN-01 itself, so the two never compete.
func (c *Client) EnableACME(ctx context.Context, domainID, directory, contact string) (string, error) {
provider := map[string]any{
"challengeType": "Http01",
"contact": map[string]bool{contact: true},
"renewBefore": "R23",
"maxRetries": 10,
"reuseKey": false,
}
if directory != "" {
provider["directory"] = directory
}
rs, err := c.Do(ctx,
Call{"x:AcmeProvider/set", map[string]any{"create": map[string]any{"acme": provider}}, "0"},
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: automaticCertificates("#acme")}}, "1"},
)
if err != nil {
return "", err
}
s, err := DecodeSet(rs[0])
if err != nil {
return "", err
}
id, err := CreatedID(s, "acme")
if err != nil {
return "", fmt.Errorf("x:AcmeProvider/set: %w", err)
}
if _, err := DecodeSet(rs[1]); err != nil {
return id, err
}
return id, nil
}
// RetryCertificates starts a fresh ACME order for the domain. A failed order
// is not retried on a restart; moving the domain to manual and straight back
// is what starts a new one.
func (c *Client) RetryCertificates(ctx context.Context, domainID string) error {
var got struct {
List []struct {
CertificateManagement struct {
Type string `json:"@type"`
AcmeProviderID string `json:"acmeProviderId"`
} `json:"certificateManagement"`
} `json:"list"`
}
rs, err := c.Do(ctx, Call{"x:Domain/get", map[string]any{"ids": []string{domainID}, "properties": []string{"certificateManagement"}}, "0"})
if err != nil {
return err
}
if err := json.Unmarshal(rs[0].Args, &got); err != nil || len(got.List) != 1 {
return errors.New("x:Domain/get did not return the domain")
}
cm := got.List[0].CertificateManagement
if cm.Type != "Automatic" || cm.AcmeProviderID == "" {
return fmt.Errorf("the domain's certificates are %q, not managed by ACME", cm.Type)
}
rs, err = c.Do(ctx,
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: map[string]any{"certificateManagement": map[string]any{"@type": "Manual"}}}}, "0"},
Call{"x:Domain/set", map[string]any{"update": map[string]any{domainID: automaticCertificates(cm.AcmeProviderID)}}, "1"},
)
if err != nil {
return err
}
for _, r := range rs {
if _, err := DecodeSet(r); err != nil {
return err
}
}
return nil
}
func automaticCertificates(providerID string) map[string]any {
return map[string]any{"certificateManagement": map[string]any{
"@type": "Automatic",
"acmeProviderId": providerID,
// Empty is the server's default set: the mail host plus autoconfig,
// autodiscover, mta-sts and ua-auto-config under the domain.
"subjectAlternativeNames": map[string]bool{},
}}
}
// Certificate is what the tool reports about an issued certificate.
type Certificate struct {
Issuer string `json:"issuer"`
NotValidAfter string `json:"notValidAfter"`
SubjectAlternativeNames map[string]bool `json:"subjectAlternativeNames"`
}
// Certificates lists the certificates the server holds.
func (c *Client) Certificates(ctx context.Context) ([]Certificate, error) {
rs, err := c.Do(ctx,
Call{"x:Certificate/query", map[string]any{}, "0"},
Call{"x:Certificate/get", map[string]any{
"#ids": map[string]any{"resultOf": "0", "name": "x:Certificate/query", "path": "/ids"},
"properties": []string{"issuer", "notValidAfter", "subjectAlternativeNames"},
}, "1"},
)
if err != nil {
return nil, err
}
var got struct {
List []Certificate `json:"list"`
}
return got.List, json.Unmarshal(rs[1].Args, &got)
}
// TrustForwardedFor makes the server take a client's address from
// X-Forwarded-For. Its auto-ban works per address: behind Caddy, without this,
// one scanner probing for WordPress bans Caddy -- and with it every autoconfig
// lookup, DAV client and certificate renewal that comes through it. Seen on
// 0.16.22, as was the fix. It applies only once the server restarts.
//
// Safe here because nothing untrusted reaches the server's HTTP port: it is
// published on loopback only, and on the private network the only peers are
// Caddy, which sets the header itself, and the webmail, which sends none.
func (c *Client) TrustForwardedFor(ctx context.Context) error {
rs, err := c.Do(ctx, Call{"x:Http/set", map[string]any{"update": map[string]any{"singleton": map[string]any{"useXForwarded": true}}}, "0"})
if err != nil {
return err
}
_, err = DecodeSet(rs[0])
return err
}
// AllowIP exempts an address from the auto-ban. It applies only once the server
// restarts.
func (c *Client) AllowIP(ctx context.Context, address, reason string) error {
rs, err := c.Do(ctx, Call{"x:AllowedIp/set", map[string]any{"create": map[string]any{
"allow": map[string]any{"address": address, "reason": reason},
}}, "0"})
if err != nil {
return err
}
s, err := DecodeSet(rs[0])
if err != nil {
return err
}
_, err = CreatedID(s, "allow")
return err
}
// CreateUser creates an ordinary mailbox, in the shape the webmail's own
// Administration creates one.
func (c *Client) CreateUser(ctx context.Context, name, domainID, password string) (string, error) {
rs, err := c.Do(ctx, Call{"x:Account/set", map[string]any{"create": map[string]any{
"user": map[string]any{
"@type": "User",
"name": name,
"domainId": domainID,
"description": nil,
"credentials": map[string]any{"0": map[string]any{"@type": "Password", "secret": password}},
"roles": map[string]any{"@type": "User"},
"permissions": map[string]any{"@type": "Inherit"},
"quotas": map[string]any{},
"aliases": map[string]any{},
"memberGroupIds": map[string]any{},
// Required on create. Turning it on cannot be undone, which is not a
// decision for a deploy tool to make on anyone's behalf.
"encryptionAtRest": map[string]any{"@type": "Disabled"},
},
}}, "0"})
if err != nil {
return "", err
}
s, err := DecodeSet(rs[0])
if err != nil {
return "", err
}
return CreatedID(s, "user")
}
// DNSZone returns the records the server wants published for the domain, as a
// zone file fragment: MX, SPF, DKIM, DMARC, the SRV records, MTA-STS and the
// autoconfig names. It has no A or AAAA records; those depend on the host.
func (c *Client) DNSZone(ctx context.Context, domainID string) (string, error) {
rs, err := c.Do(ctx, Call{"x:Domain/get", map[string]any{"ids": []string{domainID}, "properties": []string{"dnsZoneFile"}}, "0"})
if err != nil {
return "", err
}
var got struct {
List []struct {
DNSZoneFile string `json:"dnsZoneFile"`
} `json:"list"`
}
if err := json.Unmarshal(rs[0].Args, &got); err != nil || len(got.List) != 1 {
return "", errors.New("x:Domain/get did not return the domain")
}
return got.List[0].DNSZoneFile, nil
}