deploy stands up Stalwart 0.16, ihasmail and (for a mail host) Caddy as a compose project: completes Stalwart's bootstrap over x:Bootstrap, links ihasmail over the private network, requests certificates for both Caddy (TLS-ALPN-01) and Stalwart (HTTP-01 through Caddy), makes the auto-ban safe behind the proxy, and proves the link by signing in through the webmail. --local gives a loopback-only pair. certs retries Stalwart's certificate; destroy removes a deployment. e2e/public.sh runs the whole mail-host path against Pebble with no internet involved.
290 lines
10 KiB
Go
290 lines
10 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package stalwart
|
|
|
|
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 Stalwart 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. Stalwart starts the order at once, with no restart.
|
|
//
|
|
// HTTP-01 rather than Stalwart's default TLS-ALPN-01, because Caddy holds 443.
|
|
// Caddy forwards /.well-known/acme-challenge/ on port 80 for Stalwart's names
|
|
// to Stalwart 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 Stalwart'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 Stalwart 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 Stalwart 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 Stalwart restarts.
|
|
//
|
|
// Safe here because nothing untrusted reaches Stalwart'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 ihasmail, 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 Stalwart
|
|
// 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 ihasmail'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 Stalwart 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
|
|
}
|