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:
@@ -29,10 +29,22 @@ This is early. What is built:
|
|||||||
anything is put in place. `install --install-deps` does the same as part of
|
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.
|
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
|
`upgrade`, `uninstall`. The design is in the inbuxa specification (§6.1 and
|
||||||
the installer draft); the phases are there too.
|
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
|
## Building and testing
|
||||||
|
|
||||||
go build ./cmd/inbuxa
|
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
|
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:
|
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/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/down.sh remove it
|
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
|
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
|
is free to break it and a failure is the installer's rather than the last
|
||||||
|
|||||||
+46
-3
@@ -12,9 +12,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/apply"
|
||||||
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps"
|
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/deps"
|
||||||
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
|
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/host"
|
||||||
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
|
"git.coffeylabs.org/inbuxa/inbuxa-installer/internal/plan"
|
||||||
@@ -131,13 +134,53 @@ func install(args []string) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if !*yes {
|
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
|
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
|
// 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
|
// machine does not have, and -- with --install -- the doing of it. It exists
|
||||||
// separately from install because an operator preparing a machine should be
|
// separately from install because an operator preparing a machine should be
|
||||||
|
|||||||
@@ -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 ]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user