Files
jcoffey-dev e71545bbcf Relicense under AGPL-3.0-or-later
The same licence as ihasmail, which this tool deploys. The network clause
is the part that matters for a deploy tool: a modified copy offered as a
hosted service that deploys for other people has to make its source
available to them, which GPL would not require. Running the tool for your
own host is unaffected.

v2026.9.13 was released under GPL-3.0-or-later and stays so; this applies
from the next release.
2026-09-13 22:36:17 -07:00

290 lines
10 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: AGPL-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
}