Files
inbuxa-server/tools/fork/cutover-rehearsal/phase1.py
T
jcoffey-dev 966241070e
ci / fork-checks (pull_request) Skipped
ci / build (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 6m29s
Name Coffey Labs LLC as the copyright holder
Coffey Labs is now Coffey Labs LLC, an Arizona limited liability company. Copyright lines and SPDX-FileCopyrightText headers naming Coffey Labs or John Coffey now name Coffey Labs LLC. Upstream copyright notices are unchanged.
2026-10-05 23:33:00 -07:00

195 lines
8.4 KiB
Python
Executable File

#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Coffey Labs LLC
# SPDX-License-Identifier: AGPL-3.0-only
"""Phase 1: build the "old" install from nothing and record what must survive.
Upstream stalwart 0.16.22 stands in for the Enterprise install: bootstrapped,
then given a domain, three accounts with known passwords, an alias, a tenant
with its own administrator, and mail that arrived from outside and mail sent
from inside. Writes before.json — the fingerprint phase 2 compares against.
Starts by destroying anything from an earlier run, so it is repeatable.
"""
import imaplib, json, os, subprocess, sys, time
from email.message import EmailMessage
import smtplib
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import lib
HERE = os.path.dirname(os.path.abspath(__file__))
ADMIN_PW = "cutover-rehearsal-pw"
PEOPLE = {"alice": "alice-pw-7f21", "bob": "bob-pw-93c4", "carol": "carol-pw-15ab"}
TENANT_ADMIN_PW = "tadmin-pw-4b81"
TENANT_USER_PW = "tuser-pw-9e02"
def fresh():
lib.stop("old")
lib.stop("new")
subprocess.run(["rm", "-rf", lib.BASE], check=False)
for w in ("old", "new"):
for sub in ("etc", "data"):
os.makedirs(f"{lib.INSTALLS[w]['dir']}/{sub}", exist_ok=True)
with open(f"{lib.INSTALLS['old']['dir']}/env", "w") as f:
f.write(f"STALWART_RECOVERY_ADMIN=admin:{ADMIN_PW}\n")
f.write(f"STALWART_PUBLIC_URL=http://127.0.0.1:{lib.HTTP}\n")
def main():
print("== phase 1: the old install (upstream stalwart 0.16.22)")
fresh()
print(" booting into bootstrap mode ...")
if not lib.start("old"):
sys.exit("old install did not come up:\n" + lib.logs("old"))
got = lib.one("admin", ADMIN_PW, "x:Bootstrap/get", {"ids": None})
sid = got[1]["list"][0]["id"]
res = lib.one("admin", ADMIN_PW, "x:Bootstrap/set", {"update": {sid: {
"serverHostname": lib.HOSTNAME, "defaultDomain": lib.DOMAIN,
"requestTlsCertificate": False}}})
upd = res[1].get("updated", {}).get(sid)
if not upd:
sys.exit("bootstrap failed: " + json.dumps(res)[:500])
user, pw = upd["username"], upd["secret"]
print(" bootstrapped:", user)
# Bootstrap mode only ends on the next start, and the recovery admin
# stops authenticating once there is a real administrator.
print(" restarting, holding the mail ports ...")
lib.stop("old")
if not lib.start("old"):
sys.exit("old install did not come back up:\n" + lib.logs("old"))
ids = lib.one(user, pw, "x:Account/query", {"filter": {"name": "admin"}})[1].get("ids", [])
lib.one(user, pw, "x:Account/set", {"update": {ids[0]: {
"credentials": {"0": {"@type": "Password", "secret": ADMIN_PW}}}}})
pw = ADMIN_PW
def create(kind, obj):
r = lib.one(user, pw, f"x:{kind}/set", {"create": {"n": obj}})
c = r[1].get("created", {}).get("n")
if not c:
sys.exit(f"create {kind} failed: {json.dumps(r)[:400]}")
return c["id"]
domain = lib.one(user, pw, "x:Domain/query", {})[1]["ids"][0]
accounts = {}
for name, secret in PEOPLE.items():
obj = {"@type": "User", "name": name, "domainId": domain,
"description": f"{name.title()} Example",
"credentials": {"0": {"@type": "Password", "secret": secret}}}
if name == "alice":
obj["aliases"] = {"0": {"name": "sales", "domainId": domain, "enabled": True,
"description": "Sales"}}
accounts[name] = create("Account", obj)
print(" accounts:", accounts)
# A tenant, with membership set after create (it is refused on create,
# and does not come down from the domain). Without memberTenantId an
# account is server-wide and a tenant "admin" is a server administrator.
tenant = create("Tenant", {"name": "Acme"})
tdomain = create("Domain", {"name": f"acme.{lib.DOMAIN}", "memberTenantId": tenant,
"certificateManagement": {"@type": "Manual"},
"dnsManagement": {"@type": "Manual"}})
tadmin = create("Account", {"@type": "User", "name": "tadmin", "domainId": tdomain,
"roles": {"@type": "Admin"},
"credentials": {"0": {"@type": "Password", "secret": TENANT_ADMIN_PW}}})
tuser = create("Account", {"@type": "User", "name": "tuser", "domainId": tdomain,
"credentials": {"0": {"@type": "Password", "secret": TENANT_USER_PW}}})
for acct in (tadmin, tuser):
lib.one(user, pw, "x:Account/set", {"update": {acct: {"memberTenantId": tenant}}})
print(f" tenant={tenant} domain={tdomain} admin={tadmin} user={tuser}")
ctx = lib.tls_context()
for rcpt, subject in ((f"alice@{lib.DOMAIN}", "from the outside world"),
(f"sales@{lib.DOMAIN}", "to the alias"),
(f"bob@{lib.DOMAIN}", "second delivery")):
msg = EmailMessage()
msg["From"], msg["To"], msg["Subject"] = "[email protected]", rcpt, subject
msg.set_content("rehearsal body")
with smtplib.SMTP("127.0.0.1", lib.SMTP, timeout=30) as s:
s.send_message(msg)
msg = EmailMessage()
msg["From"], msg["To"], msg["Subject"] = f"alice@{lib.DOMAIN}", f"bob@{lib.DOMAIN}", "from inside"
msg.set_content("sent before the cutover")
with smtplib.SMTP_SSL("127.0.0.1", lib.SUBMISSIONS, context=ctx, timeout=30) as s:
s.login(f"alice@{lib.DOMAIN}", PEOPLE["alice"])
s.send_message(msg)
print(" waiting for delivery ...")
time.sleep(8)
before = {"admin": user, "domain": domain, "accounts": accounts,
"tenant": {"id": tenant, "domain": tdomain, "admin": tadmin, "user": tuser},
"mail": {}, "tenantView": tenant_view(user, pw)}
for name, secret in PEOPLE.items():
before["mail"][name] = mailbox(f"{name}@{lib.DOMAIN}", secret, accounts[name])
# IMAP's INBOX is not JMAP's whole account — the spam filter files
# some of this mail elsewhere. Record both, so phase 2 compares each
# against itself rather than against the other.
before["mail"][name]["inbox"] = imap_count(f"{name}@{lib.DOMAIN}", secret)
print(f" {name}: {before['mail'][name]['count']} message(s) "
f"(INBOX {before['mail'][name]['inbox']}) {before['mail'][name]['subjects']}")
json.dump(before, open(f"{HERE}/before.json", "w"), indent=1)
print(" wrote before.json")
def imap_count(addr, secret):
"""Messages in INBOX over IMAP, or -1 if the sign-in itself failed."""
try:
with imaplib.IMAP4_SSL("127.0.0.1", lib.IMAPS, ssl_context=lib.tls_context()) as m:
m.login(addr, secret)
typ, data = m.select("INBOX")
return int(data[0]) if typ == "OK" else -1
except Exception:
return -1
def mailbox(addr, secret, account_id):
q = lib.jmap(addr, secret, [["Email/query", {"accountId": account_id}, "0"]])
ids = q[0][1].get("ids", [])
subs = []
if ids:
g = lib.jmap(addr, secret, [["Email/get", {"accountId": account_id, "ids": ids,
"properties": ["subject"]}, "0"]])
subs = sorted(e.get("subject") or "" for e in g[0][1]["list"])
return {"count": len(ids), "subjects": subs}
def tenant_view(server_user, server_pw):
"""What the tenant administrator can see, recorded from the old server."""
ta = f"tadmin@acme.{lib.DOMAIN}"
def ids_of(method, args=None):
r = lib.one(ta, TENANT_ADMIN_PW, method, args or {})
if r[0] == "error":
return {"error": r[1].get("type")}
body = r[1]
if "ids" in body:
return sorted(body["ids"])
if "list" in body:
return sorted(x["id"] for x in body["list"])
return {"unexpected": list(body.keys())}
view = {
"accounts": ids_of("x:Account/query"),
"domains": ids_of("x:Domain/query"),
"tenants": ids_of("x:Tenant/get", {"ids": None}),
"listeners": ids_of("x:NetworkListener/get", {"ids": None}),
"serverAccounts": sorted(lib.one(server_user, server_pw,
"x:Account/query", {})[1]["ids"]),
}
print(" tenant admin sees accounts:", view["accounts"], "domains:", view["domains"])
print(" tenant admin listeners:", view["listeners"])
print(" server admin sees accounts:", view["serverAccounts"])
return view
if __name__ == "__main__":
main()