From 7141e565ea48e4e0751c54fa167199a9c1753029 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 22 Sep 2026 18:26:31 -0700 Subject: [PATCH] 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. --- README.md | 22 +- cmd/inbuxa/main.go | 49 ++- e2e/cases/install-local.sh | 78 ++++ internal/apply/apply.go | 384 +++++++++++++++++++ internal/compose/compose.go | 173 +++++++++ internal/compose/templates/Caddyfile.tmpl | 71 ++++ internal/compose/templates/compose.yaml.tmpl | 136 +++++++ internal/docker/docker.go | 188 +++++++++ internal/docker/docker_test.go | 21 + internal/jmap/client.go | 233 +++++++++++ internal/jmap/client_test.go | 124 ++++++ internal/jmap/setup.go | 293 ++++++++++++++ 12 files changed, 1765 insertions(+), 7 deletions(-) create mode 100644 e2e/cases/install-local.sh create mode 100644 internal/apply/apply.go create mode 100644 internal/compose/compose.go create mode 100644 internal/compose/templates/Caddyfile.tmpl create mode 100644 internal/compose/templates/compose.yaml.tmpl create mode 100644 internal/docker/docker.go create mode 100644 internal/docker/docker_test.go create mode 100644 internal/jmap/client.go create mode 100644 internal/jmap/client_test.go create mode 100644 internal/jmap/setup.go diff --git a/README.md b/README.md index b5ebff3..5a7782d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -40,9 +52,11 @@ the installer draft); the phases are there too. 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/down.sh remove it + e2e/vm/up.sh a Debian 13 machine, in qemu, as you + 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 is free to break it and a failure is the installer's rather than the last diff --git a/cmd/inbuxa/main.go b/cmd/inbuxa/main.go index 97ff0c7..4337f91 100644 --- a/cmd/inbuxa/main.go +++ b/cmd/inbuxa/main.go @@ -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") - return 1 + + 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 diff --git a/e2e/cases/install-local.sh b/e2e/cases/install-local.sh new file mode 100644 index 0000000..ac42d10 --- /dev/null +++ b/e2e/cases/install-local.sh @@ -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 ] diff --git a/internal/apply/apply.go b/internal/apply/apply.go new file mode 100644 index 0000000..1ffe5da --- /dev/null +++ b/internal/apply/apply.go @@ -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 +} diff --git a/internal/compose/compose.go b/internal/compose/compose.go new file mode 100644 index 0000000..8e4ac1e --- /dev/null +++ b/internal/compose/compose.go @@ -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 +} diff --git a/internal/compose/templates/Caddyfile.tmpl b/internal/compose/templates/Caddyfile.tmpl new file mode 100644 index 0000000..f7016ac --- /dev/null +++ b/internal/compose/templates/Caddyfile.tmpl @@ -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 + } +} diff --git a/internal/compose/templates/compose.yaml.tmpl b/internal/compose/templates/compose.yaml.tmpl new file mode 100644 index 0000000..9bbc940 --- /dev/null +++ b/internal/compose/templates/compose.yaml.tmpl @@ -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}} diff --git a/internal/docker/docker.go b/internal/docker/docker.go new file mode 100644 index 0000000..42549a0 --- /dev/null +++ b/internal/docker/docker.go @@ -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 +} diff --git a/internal/docker/docker_test.go b/internal/docker/docker_test.go new file mode 100644 index 0000000..70d53c8 --- /dev/null +++ b/internal/docker/docker_test.go @@ -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) + } + } +} diff --git a/internal/jmap/client.go b/internal/jmap/client.go new file mode 100644 index 0000000..3d83e1d --- /dev/null +++ b/internal/jmap/client.go @@ -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 +} diff --git a/internal/jmap/client_test.go b/internal/jmap/client_test.go new file mode 100644 index 0000000..fc48db6 --- /dev/null +++ b/internal/jmap/client_test.go @@ -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":"admin@example.com","secret":"s3cret"}}},"0"]]}`, + }) + a, err := c.Bootstrap(context.Background(), "mail.example.com", "example.com") + if err != nil { + t.Fatal(err) + } + if a.Username != "admin@example.com" || 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", "", "postmaster@example.com") + 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") + } +} diff --git a/internal/jmap/setup.go b/internal/jmap/setup.go new file mode 100644 index 0000000..768cba2 --- /dev/null +++ b/internal/jmap/setup.go @@ -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 +}