mirror of
https://github.com/stalwartlabs/proxy.git
synced 2026-10-30 16:15:29 +00:00
Added resources
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# ===========================================================================
|
||||
# Migration proxy: Dovecot + Postfix to Stalwart sample configuration.
|
||||
#
|
||||
# This configuration fronts a legacy mail stack (Dovecot serving IMAP, POP3 and
|
||||
# ManageSieve, Postfix handling SMTP submission) and a new Stalwart server at the
|
||||
# same time, migrating accounts from the legacy stack to Stalwart one at a time.
|
||||
# Clients keep talking to a single hostname on the usual ports; the proxy decides
|
||||
# per account which backend currently holds the mailbox.
|
||||
#
|
||||
# The legacy backend speaks the Dovecot and Postfix conventions: it expects the
|
||||
# real client address through the XCLIENT command (and the IMAP ID command), not
|
||||
# through a PROXY protocol header. The legacy destination therefore uses
|
||||
# forwarding = "xclient", while the Stalwart destination uses forwarding =
|
||||
# "proxy". For XCLIENT to be honoured, Dovecot and Postfix must be told to trust
|
||||
# the proxy; see the companion file resources/dovecot-postfix-trust.conf.
|
||||
#
|
||||
# Inbound mail on port 25 is handed to Stalwart, which performs split delivery:
|
||||
# mail for a migrated account is delivered locally, and mail for an account still
|
||||
# on the legacy stack is relayed back to Postfix.
|
||||
#
|
||||
# Replace every value marked CHANGE ME and review the rest before starting.
|
||||
# ===========================================================================
|
||||
|
||||
[server]
|
||||
hostname = "mail.example.com" # CHANGE ME (public hostname clients use)
|
||||
shutdown_grace = "30s"
|
||||
log_level = "info"
|
||||
|
||||
# TLS certificate the proxy presents to clients (the public hostname's cert).
|
||||
[tls.certificate.default]
|
||||
cert = "/etc/proxy/tls/fullchain.pem" # CHANGE ME
|
||||
key = "/etc/proxy/tls/privkey.pem" # CHANGE ME
|
||||
default = true
|
||||
|
||||
[tls.protocols]
|
||||
min_version = "1.2"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing.
|
||||
#
|
||||
# default_destination is "legacy" so that accounts not yet migrated resolve to
|
||||
# the Dovecot/Postfix stack. As each account is migrated, an entry pointing it at
|
||||
# "stalwart" is added to the mapping file.
|
||||
#
|
||||
# smtp_passthrough_destination is "stalwart": port 25 carries no login to route
|
||||
# on, so it is handed whole to the new server, which performs split delivery and
|
||||
# relays not-yet-migrated recipients back to Postfix.
|
||||
# ---------------------------------------------------------------------------
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
smtp_passthrough_destination = "stalwart"
|
||||
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
positive_ttl = "10m"
|
||||
negative_ttl = "30s"
|
||||
|
||||
[mapping.file]
|
||||
# Whitespace-separated "identifier destination" lines. A migrated account gets a
|
||||
# line pointing it at "stalwart"; everything else falls through to "legacy". For
|
||||
# example:
|
||||
# alice@example.com stalwart
|
||||
path = "/etc/proxy/mappings.tsv" # CHANGE ME
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy Dovecot + Postfix backend. Announced to the backend over XCLIENT and the
|
||||
# IMAP ID command (forwarding = "xclient"), which is what Dovecot and Postfix
|
||||
# understand. The backend leg uses STARTTLS on the classic plaintext ports.
|
||||
#
|
||||
# The legacy server presents a self-signed certificate. Pin it by fingerprint
|
||||
# with tls_pinned_cert_sha256 (recommended), or, on a trusted internal network,
|
||||
# set tls_allow_invalid_certs = true.
|
||||
# ---------------------------------------------------------------------------
|
||||
[destination.legacy]
|
||||
host = "legacy.internal.example.com" # CHANGE ME (internal address of the legacy stack)
|
||||
tls_server_name = "legacy.internal.example.com"
|
||||
forwarding = "xclient"
|
||||
# tls_pinned_cert_sha256 = "aa:bb:...:99" # CHANGE ME (legacy cert SHA-256), or:
|
||||
tls_allow_invalid_certs = true
|
||||
|
||||
[destination.legacy.protocol.imap]
|
||||
port = 143
|
||||
tls = "starttls"
|
||||
[destination.legacy.protocol.pop3]
|
||||
port = 110
|
||||
tls = "starttls"
|
||||
[destination.legacy.protocol.submission]
|
||||
port = 587
|
||||
tls = "starttls"
|
||||
[destination.legacy.protocol.managesieve]
|
||||
port = 4190
|
||||
tls = "starttls"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New Stalwart backend. Announced over the PROXY protocol (forwarding = "proxy",
|
||||
# proxy_protocol = true); Stalwart is configured to trust the proxy's address.
|
||||
# Declares an smtp endpoint because it is the pass-through destination for port 25.
|
||||
# ---------------------------------------------------------------------------
|
||||
[destination.stalwart]
|
||||
host = "new.internal.example.com" # CHANGE ME (internal address of the new server)
|
||||
tls_server_name = "new.internal.example.com"
|
||||
forwarding = "proxy"
|
||||
proxy_protocol = true
|
||||
forwarded = true
|
||||
# tls_allow_invalid_certs = true # if the new server uses a self-signed cert
|
||||
|
||||
[destination.stalwart.protocol.imap]
|
||||
port = 993
|
||||
tls = "implicit"
|
||||
[destination.stalwart.protocol.pop3]
|
||||
port = 995
|
||||
tls = "implicit"
|
||||
[destination.stalwart.protocol.submission]
|
||||
port = 587
|
||||
tls = "starttls"
|
||||
[destination.stalwart.protocol.managesieve]
|
||||
port = 4190
|
||||
tls = "starttls"
|
||||
[destination.stalwart.protocol.smtp]
|
||||
port = 25
|
||||
tls = "plain"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client-facing listeners on the familiar ports. Port 25 is a raw pass-through to
|
||||
# Stalwart and therefore plain (a pass-through listener cannot offer STARTTLS).
|
||||
#
|
||||
# Note: Dovecot and Postfix serve no HTTP or JMAP, so no HTTP listener is defined
|
||||
# here. Once accounts are on Stalwart, JMAP and the web interface are served by
|
||||
# the new server; an HTTP listener routing to "stalwart" can be added at that
|
||||
# point if a single hostname for web access is wanted.
|
||||
# ---------------------------------------------------------------------------
|
||||
[listener.imaps]
|
||||
protocol = "imap"
|
||||
bind = ["0.0.0.0:993", "[::]:993"]
|
||||
tls = "implicit"
|
||||
[listener.imap]
|
||||
protocol = "imap"
|
||||
bind = ["0.0.0.0:143", "[::]:143"]
|
||||
tls = "starttls"
|
||||
[listener.pop3s]
|
||||
protocol = "pop3"
|
||||
bind = ["0.0.0.0:995", "[::]:995"]
|
||||
tls = "implicit"
|
||||
[listener.pop3]
|
||||
protocol = "pop3"
|
||||
bind = ["0.0.0.0:110", "[::]:110"]
|
||||
tls = "starttls"
|
||||
[listener.submission]
|
||||
protocol = "submission"
|
||||
bind = ["0.0.0.0:587", "[::]:587"]
|
||||
tls = "starttls"
|
||||
[listener.submissions]
|
||||
protocol = "submission"
|
||||
bind = ["0.0.0.0:465", "[::]:465"]
|
||||
tls = "implicit"
|
||||
[listener.managesieve]
|
||||
protocol = "managesieve"
|
||||
bind = ["0.0.0.0:4190", "[::]:4190"]
|
||||
tls = "starttls"
|
||||
[listener.smtp]
|
||||
protocol = "smtp"
|
||||
bind = ["0.0.0.0:25", "[::]:25"]
|
||||
tls = "plain"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management API: mapping changes, cache invalidation and connection control.
|
||||
# TLS is mandatory. Bind to loopback or a trusted management network only.
|
||||
# ---------------------------------------------------------------------------
|
||||
[admin]
|
||||
bind = "127.0.0.1:9443"
|
||||
tls = "implicit"
|
||||
bearer_token_file = "/etc/proxy/admin.token" # CHANGE ME
|
||||
min_token_len = 32
|
||||
lockout_threshold = 5
|
||||
lockout_duration = "5m"
|
||||
@@ -0,0 +1,252 @@
|
||||
# ===========================================================================
|
||||
# Migration proxy: Stalwart upgrade (old -> new) sample configuration.
|
||||
#
|
||||
# This configuration fronts two Stalwart deployments at the same time: an older
|
||||
# one ("old", typically v0.15 or below) that still holds most accounts, and a
|
||||
# new one ("new", the latest release) that accounts are migrated onto one by
|
||||
# one. Clients keep talking to a single hostname on the usual ports; the proxy
|
||||
# decides per account which deployment currently holds the mailbox.
|
||||
#
|
||||
# Before each account is migrated, it resolves to "old". Inbound mail on port 25
|
||||
# is always handed to "new", which performs split delivery: mail for an account
|
||||
# that already lives on "new" is delivered locally, while mail for an account
|
||||
# still on "old" is relayed there. As each account is moved its mapping is
|
||||
# flipped to "new".
|
||||
#
|
||||
# Replace every value marked CHANGE ME and review the rest before starting.
|
||||
# ===========================================================================
|
||||
|
||||
[server]
|
||||
# Public hostname clients connect to. Advertised in protocol greetings.
|
||||
hostname = "mail.example.org" # CHANGE ME
|
||||
shutdown_grace = "30s"
|
||||
log_level = "info"
|
||||
|
||||
# TLS certificate the proxy presents to clients. This is the certificate for
|
||||
# the public hostname above; it terminates implicit TLS and STARTTLS.
|
||||
[tls.certificate.default]
|
||||
cert = "/etc/proxy/tls/fullchain.pem" # CHANGE ME
|
||||
key = "/etc/proxy/tls/privkey.pem" # CHANGE ME
|
||||
default = true
|
||||
|
||||
[tls.protocols]
|
||||
min_version = "1.2"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing.
|
||||
#
|
||||
# default_destination is where accounts with no explicit mapping resolve. While
|
||||
# the migration is in progress this must be "old", so that accounts that have
|
||||
# not been moved yet keep reaching their current mailbox.
|
||||
#
|
||||
# smtp_passthrough_destination is the fixed target for raw SMTP on port 25 and
|
||||
# LMTP on port 24. It must be "new", because the new deployment is the one that
|
||||
# performs split delivery and relays not-yet-migrated recipients back to old.
|
||||
# ---------------------------------------------------------------------------
|
||||
[routing]
|
||||
default_destination = "old"
|
||||
smtp_passthrough_destination = "new"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mapping store: identifier -> destination. A migrated account gets a line
|
||||
# pointing it at "new"; everything else falls through to default_destination.
|
||||
# The identifier is the login the client presents (the e-mail address, or for
|
||||
# older servers the bare account name), normalized to lowercase.
|
||||
# ---------------------------------------------------------------------------
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
positive_ttl = "10m"
|
||||
negative_ttl = "30s"
|
||||
|
||||
[mapping.file]
|
||||
# A whitespace-separated file of "identifier destination" lines, for example:
|
||||
# alice@example.org new
|
||||
# alice new
|
||||
# List both the e-mail address and the bare account name when the old server
|
||||
# allowed logging in with either form. New entries take effect after a mapping
|
||||
# reload (see the management API) or a cache expiry.
|
||||
path = "/etc/proxy/mappings.tsv" # CHANGE ME
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Destinations.
|
||||
#
|
||||
# Both backends are Stalwart and both are configured to accept the PROXY
|
||||
# protocol on their listeners, so the proxy announces the real client over a
|
||||
# PROXY v2 header (forwarding = "proxy", proxy_protocol = true) on every mail
|
||||
# leg, and appends Forwarded / X-Forwarded-For on the HTTP leg (forwarded =
|
||||
# true). The old server is the single exception: its port 25 must NOT expect a
|
||||
# PROXY header, because the new server relays to it as an ordinary SMTP client.
|
||||
# That exception is configured on the old server, not here.
|
||||
#
|
||||
# host is the internal address each deployment now listens on. tls_server_name
|
||||
# is the name presented for SNI and certificate matching on the backend leg.
|
||||
# If a backend uses a self-signed or internal certificate, either pin it with
|
||||
# tls_pinned_cert_sha256 or, for a trusted internal network only, uncomment
|
||||
# tls_allow_invalid_certs.
|
||||
# ---------------------------------------------------------------------------
|
||||
[destination.old]
|
||||
host = "old.internal.example.org" # CHANGE ME
|
||||
tls_server_name = "old.internal.example.org"
|
||||
forwarding = "proxy"
|
||||
proxy_protocol = true
|
||||
forwarded = true
|
||||
# tls_allow_invalid_certs = true
|
||||
|
||||
[destination.old.protocol.imap]
|
||||
port = 993
|
||||
tls = "implicit"
|
||||
[destination.old.protocol.pop3]
|
||||
port = 995
|
||||
tls = "implicit"
|
||||
[destination.old.protocol.submission]
|
||||
port = 587
|
||||
tls = "starttls"
|
||||
[destination.old.protocol.managesieve]
|
||||
port = 4190
|
||||
tls = "starttls"
|
||||
[destination.old.protocol.http]
|
||||
port = 443
|
||||
tls = "implicit"
|
||||
|
||||
[destination.new]
|
||||
host = "new.internal.example.org" # CHANGE ME
|
||||
tls_server_name = "new.internal.example.org"
|
||||
forwarding = "proxy"
|
||||
proxy_protocol = true
|
||||
forwarded = true
|
||||
# tls_allow_invalid_certs = true
|
||||
|
||||
[destination.new.protocol.imap]
|
||||
port = 993
|
||||
tls = "implicit"
|
||||
[destination.new.protocol.pop3]
|
||||
port = 995
|
||||
tls = "implicit"
|
||||
[destination.new.protocol.submission]
|
||||
port = 587
|
||||
tls = "starttls"
|
||||
[destination.new.protocol.managesieve]
|
||||
port = 4190
|
||||
tls = "starttls"
|
||||
[destination.new.protocol.http]
|
||||
port = 443
|
||||
tls = "implicit"
|
||||
# Required because new is the smtp_passthrough_destination (port 25 / LMTP).
|
||||
[destination.new.protocol.smtp]
|
||||
port = 25
|
||||
tls = "plain"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP routing. Rules are evaluated top to bottom and the first match wins.
|
||||
#
|
||||
# The web interfaces and login endpoints differ between the two versions, so a
|
||||
# handful of paths that are unique to each version are pinned to the backend
|
||||
# that serves them. The OAuth token exchange is shared, so it is split by the
|
||||
# client_id present in the request body. Everything else, including JMAP,
|
||||
# WebDAV and the JMAP session resource, carries credentials and is routed by
|
||||
# the account those credentials identify, falling back to the default
|
||||
# destination when no account can be determined.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Endpoints served only by the old deployment.
|
||||
[[http.route]]
|
||||
match = "/api/oauth"
|
||||
destination = "old"
|
||||
[[http.route]]
|
||||
match = "/authorize"
|
||||
destination = "old"
|
||||
[[http.route]]
|
||||
match = "/authorize/**"
|
||||
destination = "old"
|
||||
|
||||
# Endpoints served only by the new deployment.
|
||||
[[http.route]]
|
||||
match = "/api/auth"
|
||||
destination = "new"
|
||||
[[http.route]]
|
||||
match = "/api/discover/**"
|
||||
destination = "new"
|
||||
[[http.route]]
|
||||
match = "/admin/**"
|
||||
destination = "new"
|
||||
[[http.route]]
|
||||
match = "/account/**"
|
||||
destination = "new"
|
||||
[[http.route]]
|
||||
match = "/login"
|
||||
destination = "new"
|
||||
[[http.route]]
|
||||
match = "/device"
|
||||
destination = "new"
|
||||
|
||||
# OAuth token exchange is shared; route it by the web client that requested it.
|
||||
# Add matching mapping entries: webadmin old and stalwart-webui new
|
||||
[[http.route]]
|
||||
match = "/auth/token"
|
||||
extract = { from = "body", regex = "client_id=([^&]+)" }
|
||||
fallback = "old"
|
||||
|
||||
# Everything else: route by the authenticated account, fall back to default.
|
||||
[[http.route]]
|
||||
match = "/**"
|
||||
extract = { from = "authorization" }
|
||||
fallback = "default"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Listeners: the public ports clients connect to. These take over the ports the
|
||||
# old server used to answer on directly.
|
||||
# ---------------------------------------------------------------------------
|
||||
[listener.imaps]
|
||||
protocol = "imap"
|
||||
bind = ["0.0.0.0:993", "[::]:993"]
|
||||
tls = "implicit"
|
||||
[listener.imap]
|
||||
protocol = "imap"
|
||||
bind = ["0.0.0.0:143", "[::]:143"]
|
||||
tls = "starttls"
|
||||
[listener.pop3s]
|
||||
protocol = "pop3"
|
||||
bind = ["0.0.0.0:995", "[::]:995"]
|
||||
tls = "implicit"
|
||||
[listener.pop3]
|
||||
protocol = "pop3"
|
||||
bind = ["0.0.0.0:110", "[::]:110"]
|
||||
tls = "starttls"
|
||||
[listener.submission]
|
||||
protocol = "submission"
|
||||
bind = ["0.0.0.0:587", "[::]:587"]
|
||||
tls = "starttls"
|
||||
[listener.submissions]
|
||||
protocol = "submission"
|
||||
bind = ["0.0.0.0:465", "[::]:465"]
|
||||
tls = "implicit"
|
||||
[listener.managesieve]
|
||||
protocol = "managesieve"
|
||||
bind = ["0.0.0.0:4190", "[::]:4190"]
|
||||
tls = "starttls"
|
||||
[listener.https]
|
||||
protocol = "http"
|
||||
bind = ["0.0.0.0:443", "[::]:443"]
|
||||
tls = "implicit"
|
||||
# Strip any Forwarded / X-Forwarded-For headers arriving from clients rather
|
||||
# than trusting them; the proxy sets its own based on the real peer.
|
||||
forwarded = "off"
|
||||
# Raw SMTP. Pass-through to new (smtp_passthrough_destination). Must be plain:
|
||||
# a pass-through listener cannot offer STARTTLS.
|
||||
[listener.smtp]
|
||||
protocol = "smtp"
|
||||
bind = ["0.0.0.0:25", "[::]:25"]
|
||||
tls = "plain"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Management API: mapping changes, cache invalidation and connection control.
|
||||
# TLS is mandatory. Bind it to loopback or a trusted management network only.
|
||||
# ---------------------------------------------------------------------------
|
||||
[admin]
|
||||
bind = "127.0.0.1:9443"
|
||||
tls = "implicit"
|
||||
bearer_token_file = "/etc/proxy/admin.token" # CHANGE ME
|
||||
min_token_len = 32
|
||||
lockout_threshold = 5
|
||||
lockout_duration = "5m"
|
||||
@@ -0,0 +1,65 @@
|
||||
# ===========================================================================
|
||||
# Legacy backend trust: Dovecot + Postfix settings for the migration proxy.
|
||||
#
|
||||
# These are the changes the legacy Dovecot and Postfix stack needs so that the
|
||||
# migration proxy can front it. They do two things:
|
||||
#
|
||||
# 1. Trust the proxy to announce the real client address. The proxy forwards it
|
||||
# over the XCLIENT command (Postfix submission and Dovecot ManageSieve/POP3)
|
||||
# and the IMAP ID command, instead of a PROXY protocol header. Without this
|
||||
# trust the backend records the proxy's address as the client; logins still
|
||||
# work, but the real client address is lost.
|
||||
#
|
||||
# 2. Provide a master user, so the migration tool (Vandelay) can read any
|
||||
# mailbox over IMAP with a single administrative credential rather than each
|
||||
# user's password.
|
||||
#
|
||||
# Replace the address 203.0.113.10 with the proxy's actual address. Restrict the
|
||||
# trust to the proxy, not to the whole network, so that arbitrary hosts cannot
|
||||
# spoof a client address.
|
||||
# ===========================================================================
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dovecot: /etc/dovecot/dovecot.conf (or a file under /etc/dovecot/conf.d)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Trust the proxy to forward the real client address via XCLIENT and the IMAP ID
|
||||
# command. List the proxy's address only.
|
||||
login_trusted_networks = 203.0.113.10/32
|
||||
|
||||
# Master user support, used by Vandelay to read any mailbox during migration. The
|
||||
# login presented is "<user>%<master-user>" (here the separator is "%"), with the
|
||||
# master user's password. For example, reading alice's mailbox uses the login
|
||||
# "alice@example.com%vmadmin" and vmadmin's password.
|
||||
auth_master_user_separator = %
|
||||
|
||||
passdb {
|
||||
driver = passwd-file
|
||||
master = yes
|
||||
args = scheme=PLAIN username_format=%u /etc/dovecot/master-users
|
||||
}
|
||||
|
||||
# /etc/dovecot/master-users (one line; use a real password hash in production,
|
||||
# for example one produced by "doveadm pw -s SHA512-CRYPT"):
|
||||
# vmadmin:{PLAIN}ChangeThisMasterPassword
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Postfix: apply with postconf, or add to /etc/postfix/main.cf
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Trust the proxy to announce the real client over XCLIENT on the SMTP and
|
||||
# submission services. List the proxy's address only.
|
||||
#
|
||||
# postconf -e "smtpd_authorized_xclient_hosts=203.0.113.10/32"
|
||||
#
|
||||
# main.cf equivalent:
|
||||
# smtpd_authorized_xclient_hosts = 203.0.113.10/32
|
||||
|
||||
# After changing either server, reload it:
|
||||
# doveadm reload # Dovecot
|
||||
# postfix reload # Postfix
|
||||
#
|
||||
# To confirm XCLIENT is being honoured, sign in through the proxy and check the
|
||||
# Dovecot login line: the "rip=" field should show the real client address rather
|
||||
# than the proxy's. If it shows the proxy's address, login_trusted_networks does
|
||||
# not include the proxy.
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a Stalwart CLI migration plan from a Stalwart v0.15 server.
|
||||
|
||||
This reads principal (account) definitions from a running Stalwart v0.15
|
||||
server through its management API and emits an NDJSON plan that the Stalwart
|
||||
CLI can apply against a new (latest) Stalwart server to recreate those
|
||||
accounts:
|
||||
|
||||
stalwart-cli apply --file migration-plan.ndjson
|
||||
|
||||
Each line of the plan is a create operation in the form consumed by
|
||||
`stalwart-cli apply`:
|
||||
|
||||
{"@type":"create","object":"Domain","value":{"<ref>":{...}}}
|
||||
{"@type":"create","object":"Account","value":{"<ref>":{"@type":"User",...}}}
|
||||
|
||||
Accounts reference their domain through a plan-local "#<ref>" placeholder that
|
||||
the CLI resolves while applying. When a domain already exists on the target
|
||||
server, pass --existing-domain NAME=ID so accounts reference the real id
|
||||
instead of trying to recreate the domain.
|
||||
|
||||
Password hashes are migrated verbatim, so users keep their existing passwords.
|
||||
App passwords and one-time-password (TOTP) secrets are not migrated.
|
||||
|
||||
If a username is given, only that account is exported; otherwise every
|
||||
individual (optionally every group with --include-groups) is exported.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
PAGE_SIZE = 200
|
||||
REQUEST_TIMEOUT = 60
|
||||
|
||||
|
||||
class ApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class StalwartV015Client:
|
||||
def __init__(self, url, token=None, username=None, password=None, insecure=False):
|
||||
self.base = url.rstrip("/")
|
||||
if token:
|
||||
self.auth = "Bearer " + token
|
||||
elif username is not None:
|
||||
raw = f"{username}:{password or ''}".encode()
|
||||
self.auth = "Basic " + base64.b64encode(raw).decode()
|
||||
else:
|
||||
raise ValueError("either a token or a username must be provided")
|
||||
self.ctx = None
|
||||
if insecure:
|
||||
self.ctx = ssl.create_default_context()
|
||||
self.ctx.check_hostname = False
|
||||
self.ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
def get(self, path, params=None):
|
||||
url = self.base + path
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, method="GET")
|
||||
req.add_header("Authorization", self.auth)
|
||||
req.add_header("Accept", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT, context=self.ctx) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode(errors="replace")
|
||||
raise ApiError(f"{exc.code} {exc.reason} for {path}: {body}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise ApiError(f"cannot reach {url}: {exc.reason}") from exc
|
||||
if isinstance(payload, dict) and "data" in payload:
|
||||
return payload["data"]
|
||||
return payload
|
||||
|
||||
def list_principal_names(self, types):
|
||||
names = []
|
||||
for kind in types:
|
||||
page = 1
|
||||
while True:
|
||||
data = self.get(
|
||||
"/api/principal",
|
||||
{"page": page, "limit": PAGE_SIZE, "types": kind},
|
||||
)
|
||||
items = data.get("items") or []
|
||||
for item in items:
|
||||
name = principal_name(item)
|
||||
if name:
|
||||
names.append(name)
|
||||
total = data.get("total") or 0
|
||||
if page * PAGE_SIZE >= total or not items:
|
||||
break
|
||||
page += 1
|
||||
return names
|
||||
|
||||
def fetch_principal(self, name):
|
||||
return self.get("/api/principal/" + urllib.parse.quote(name, safe=""))
|
||||
|
||||
|
||||
def principal_name(record):
|
||||
name = record.get("name")
|
||||
if isinstance(name, dict):
|
||||
return name.get("string")
|
||||
return name
|
||||
|
||||
|
||||
def scalar_list(value):
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
if "stringList" in value:
|
||||
return value["stringList"]
|
||||
if "string" in value:
|
||||
return [value["string"]]
|
||||
return list(value.values())
|
||||
return [value]
|
||||
|
||||
|
||||
def scalar(value):
|
||||
if isinstance(value, dict):
|
||||
for key in ("string", "integer"):
|
||||
if key in value:
|
||||
return value[key]
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def domain_ref(domain):
|
||||
safe = "".join(c if c.isalnum() else "_" for c in domain)
|
||||
return "domain_" + safe
|
||||
|
||||
|
||||
def split_address(record):
|
||||
name = principal_name(record) or ""
|
||||
emails = [e for e in scalar_list(record.get("emails")) if e]
|
||||
primary = emails[0] if emails else name
|
||||
if "@" in primary:
|
||||
local, _, domain = primary.partition("@")
|
||||
elif "@" in name:
|
||||
local, _, domain = name.partition("@")
|
||||
primary = name
|
||||
else:
|
||||
local, domain = name, None
|
||||
return local, domain, primary, emails
|
||||
|
||||
|
||||
def build_credentials(record):
|
||||
creds = {}
|
||||
index = 0
|
||||
for secret in scalar_list(record.get("secrets")):
|
||||
if not isinstance(secret, str) or not secret:
|
||||
continue
|
||||
if secret.startswith("$app$"):
|
||||
continue
|
||||
if secret.startswith("otpauth://"):
|
||||
continue
|
||||
creds[str(index)] = {"@type": "Password", "secret": secret}
|
||||
index += 1
|
||||
return creds
|
||||
|
||||
|
||||
def build_aliases(primary, emails, domain_refs, existing):
|
||||
aliases = {}
|
||||
index = 0
|
||||
for email in emails:
|
||||
if email == primary or "@" not in email:
|
||||
continue
|
||||
local, _, domain = email.partition("@")
|
||||
ref = existing.get(domain) or ("#" + domain_ref(domain))
|
||||
aliases[str(index)] = {"name": local, "domainId": ref}
|
||||
domain_refs.add(domain)
|
||||
index += 1
|
||||
return aliases
|
||||
|
||||
|
||||
def build_account(record, domain_value, existing):
|
||||
kind = scalar(record.get("type"))
|
||||
local, domain, primary, emails = split_address(record)
|
||||
if not domain:
|
||||
raise ApiError(
|
||||
f"principal {principal_name(record)!r} has no email domain; cannot map it"
|
||||
)
|
||||
seen_domains = {domain}
|
||||
domain_id = existing.get(domain) or ("#" + domain_ref(domain))
|
||||
|
||||
account = {
|
||||
"@type": "Group" if kind == "group" else "User",
|
||||
"name": local,
|
||||
"domainId": domain_id,
|
||||
}
|
||||
description = scalar(record.get("description"))
|
||||
if description:
|
||||
account["description"] = description
|
||||
creds = build_credentials(record)
|
||||
if creds and kind != "group":
|
||||
account["credentials"] = creds
|
||||
if kind != "group":
|
||||
account["roles"] = {"@type": "User"}
|
||||
account["encryptionAtRest"] = {"@type": "Disabled"}
|
||||
aliases = build_aliases(primary, emails, seen_domains, existing)
|
||||
if aliases:
|
||||
account["aliases"] = aliases
|
||||
|
||||
for dom in seen_domains:
|
||||
if dom not in existing:
|
||||
domain_value.setdefault(domain_ref(dom), {"name": dom})
|
||||
return account
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build a Stalwart CLI migration plan from a Stalwart v0.15 server.",
|
||||
)
|
||||
parser.add_argument("username", nargs="?", help="export only this account (default: all)")
|
||||
parser.add_argument("--url", required=True, help="base URL of the v0.15 server")
|
||||
parser.add_argument("--token", help="admin bearer token")
|
||||
parser.add_argument("--user", help="admin username (HTTP basic auth)")
|
||||
parser.add_argument("--password", help="admin password (HTTP basic auth)")
|
||||
parser.add_argument("--insecure", action="store_true", help="skip TLS verification")
|
||||
parser.add_argument("--include-groups", action="store_true", help="also export groups")
|
||||
parser.add_argument(
|
||||
"--existing-domain",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="NAME=ID",
|
||||
help="reference an existing target domain by id instead of creating it",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
help="write the plan to this file (default: stdout)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.token and args.user is None:
|
||||
parser.error("provide --token or --user/--password")
|
||||
|
||||
existing = {}
|
||||
for entry in args.existing_domain:
|
||||
if "=" not in entry:
|
||||
parser.error(f"--existing-domain expects NAME=ID, got {entry!r}")
|
||||
name, _, value = entry.partition("=")
|
||||
existing[name.strip()] = value.strip()
|
||||
|
||||
client = StalwartV015Client(
|
||||
args.url,
|
||||
token=args.token,
|
||||
username=args.user,
|
||||
password=args.password,
|
||||
insecure=args.insecure,
|
||||
)
|
||||
|
||||
if args.username:
|
||||
names = [args.username]
|
||||
else:
|
||||
types = ["individual"]
|
||||
if args.include_groups:
|
||||
types.append("group")
|
||||
names = client.list_principal_names(types)
|
||||
|
||||
if not names:
|
||||
sys.stderr.write("no accounts to export\n")
|
||||
return 1
|
||||
|
||||
domain_value = {}
|
||||
account_value = {}
|
||||
for name in names:
|
||||
record = client.fetch_principal(name)
|
||||
account = build_account(record, domain_value, existing)
|
||||
account_value["acct_" + str(scalar(record.get("id")) or name)] = account
|
||||
|
||||
ops = []
|
||||
if domain_value:
|
||||
ops.append({"@type": "create", "object": "Domain", "value": domain_value})
|
||||
ops.append({"@type": "create", "object": "Account", "value": account_value})
|
||||
|
||||
out = open(args.output, "w") if args.output else sys.stdout
|
||||
try:
|
||||
for op in ops:
|
||||
out.write(json.dumps(op) + "\n")
|
||||
finally:
|
||||
if args.output:
|
||||
out.close()
|
||||
|
||||
sys.stderr.write(
|
||||
f"exported {len(account_value)} account(s), {len(domain_value)} domain(s)\n"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user