Added resources

This commit is contained in:
Maurus Decimus
2026-06-09 18:09:54 +02:00
parent 7be00e9848
commit 69d28e4c25
4 changed files with 791 additions and 0 deletions
+176
View File
@@ -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"
+252
View File
@@ -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"
+65
View File
@@ -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.
+298
View File
@@ -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())