Install the suite, for real, in containers
The plan now happens. "inbuxa install --local --domain example.test --install-deps --yes" on a machine with nothing on it ends with a mail server, a console and a webmail running, an administrator and a first mailbox created, and the records the domain needs written out. The sequence is the one ihasmail-oneshot worked out against a running server, which is why its JMAP client and its Docker handling came across nearly whole: bring the server up in bootstrap mode with a credential that lives in an override file for that step only, complete bootstrap, bring the rest up without it -- so no recovery credential outlives the setup -- exempt the front ends from the auto-ban, restart for the settings that need it, create the first account, and write down the password nothing else holds. New here: three services rather than two. The console is static files that learn their server's address at start, and the webmail is given the first-party OAuth client secret that the server is given too. Twenty checks in the lab, from a bare Debian 13. The two worth having are the ones that catch an install that looks fine and is not: nothing in the running server carries a recovery admin any more, and the account the installer created can sign in to the webmail it installed. Two bugs the lab caught, both of which would have shipped: - the private addresses were worked out on a copy of the stack, so the server was told the webmail speaks from "", and refused it. - the console image rewrites index.html when it starts, so a read-only root filesystem left it restarting forever. The webmail keeps read_only; the console cannot have it until that rewrite moves.
This commit is contained in:
@@ -0,0 +1,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}}
|
||||
Reference in New Issue
Block a user