Prove the switch on a running server, not just in unit tests
tests/e2e/legacy_protocols.py boots the debug binary in a container, turns the switch off and on, and checks the ports themselves. Everything below it was unit-tested and none of it could have told us this worked. What it establishes: IMAP and POP3 stop answering while inbound SMTP, submission and JMAP keep going (LP-1, LP-2, LP-3); the listeners are saved whole (LP-1); a restart does not reopen them, which is the point of taking the objects away rather than only the sockets; both come back on their own without a restart (LP-5); savedListeners empties; and asking to close submission is overruled to false and reported, with 465 still answering (LP-21, acceptance test 18). wouldClose named imaps, pop3s and sieve, and those were exactly the three that closed (LP-16). One caveat about the method, because it nearly produced a false pass in reverse. A published Docker port accepts connections whether or not anything is listening in the container, so connecting proves nothing. The first run of this script reported IMAP still open after the switch, and that was the script being wrong, not the server. Each port now has to speak: a TLS handshake on 993, 995 and 465, a greeting on 25. It lives under tests/ because target/ is ignored and this is worth keeping. It derives its own root, needs Docker and a debug build, and clears its state directory first -- a half-bootstrapped one from an earlier run is no longer in bootstrap mode and the recovery admin stops working.
This commit is contained in:
Executable
+251
@@ -0,0 +1,251 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local end-to-end check of the legacy-protocols switch.
|
||||||
|
|
||||||
|
Run it with `python3 tests/e2e/legacy_protocols.py` after
|
||||||
|
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
|
||||||
|
|
||||||
|
Boots the debug binary, turns the switch off, and checks that the IMAP and
|
||||||
|
POP3 ports really stop accepting while SMTP, submission and JMAP keep going.
|
||||||
|
Then turns it back on and checks the ports come back.
|
||||||
|
|
||||||
|
This is the part unit tests cannot reach: whether a socket actually closes on
|
||||||
|
a running server (LP-2), and whether a listener put back actually binds again
|
||||||
|
(LP-5). Acceptance tests 15, 17 and 18.
|
||||||
|
|
||||||
|
Passwords are generated into files under target/e2e and never printed.
|
||||||
|
Everything is removed afterwards unless KEEP=1.
|
||||||
|
"""
|
||||||
|
import base64, json, os, secrets, shutil, socket, ssl, subprocess, sys, time, urllib.request, urllib.error
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
DIR = f"{ROOT}/target/e2e"
|
||||||
|
NAME = "inbuxa-legacy"
|
||||||
|
HTTP = "http://127.0.0.1:18080"
|
||||||
|
# port -> how to tell a live server from Docker's proxy. Publishing a port
|
||||||
|
# makes the host side accept connections whether or not anything is listening
|
||||||
|
# inside the container, so a bare connect proves nothing: each port has to be
|
||||||
|
# made to speak.
|
||||||
|
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025}
|
||||||
|
TLS_PORTS = {18993, 18995, 18465}
|
||||||
|
INBUXA = "urn:inbuxa:jmap"
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, what):
|
||||||
|
print(("ok " if cond else "FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
failures.append(what)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_file(name, value=None):
|
||||||
|
path = f"{DIR}/secrets/{name}"
|
||||||
|
if value is None:
|
||||||
|
value = secrets.token_urlsafe(24)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(value)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def docker(*args, check_rc=True):
|
||||||
|
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
|
||||||
|
|
||||||
|
|
||||||
|
def start(env_file=None):
|
||||||
|
args = ["run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
|
||||||
|
"--entrypoint", "/usr/local/bin/inbuxa",
|
||||||
|
"-v", f"{ROOT}/target/debug/inbuxa:/usr/local/bin/inbuxa:ro",
|
||||||
|
"-v", f"{DIR}/etc-legacy:/etc/inbuxa", "-v", f"{DIR}/data-legacy:/var/lib/inbuxa",
|
||||||
|
"-p", "127.0.0.1:18080:8080",
|
||||||
|
"-p", f"127.0.0.1:{PORTS['submissions']}:465",
|
||||||
|
"-p", f"127.0.0.1:{PORTS['imap']}:993",
|
||||||
|
"-p", f"127.0.0.1:{PORTS['pop3']}:995",
|
||||||
|
"-p", f"127.0.0.1:{PORTS['smtp']}:25"]
|
||||||
|
if env_file:
|
||||||
|
args += ["--env-file", env_file]
|
||||||
|
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
||||||
|
docker(*args)
|
||||||
|
for _ in range(120):
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(f"{HTTP}/.well-known/jmap", timeout=2)
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
sys.exit("server didn't come up: " + docker("logs", "--tail", "40", NAME, check_rc=False).stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def stop():
|
||||||
|
docker("rm", "-f", NAME, check_rc=False)
|
||||||
|
|
||||||
|
|
||||||
|
def jmap(user, password, calls, using=("urn:ietf:params:jmap:core", "urn:stalwart:jmap", INBUXA)):
|
||||||
|
body = json.dumps({"using": list(using), "methodCalls": calls}).encode()
|
||||||
|
req = urllib.request.Request(f"{HTTP}/jmap/", data=body, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode())
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.load(resp)["methodResponses"]
|
||||||
|
|
||||||
|
|
||||||
|
def one(user, password, method, args):
|
||||||
|
return jmap(user, password, [[method, args, "0"]])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def session(user, password):
|
||||||
|
req = urllib.request.Request(f"{HTTP}/jmap/session")
|
||||||
|
req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode())
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.load(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def accepts(port, timeout=5):
|
||||||
|
"""Whether a server is really answering on this port.
|
||||||
|
|
||||||
|
Docker's published port accepts and then closes when nothing is listening
|
||||||
|
in the container, so connecting is not enough. A TLS port must complete a
|
||||||
|
handshake; a plain one must send its greeting.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as raw:
|
||||||
|
if port in TLS_PORTS:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
with ctx.wrap_socket(raw):
|
||||||
|
return True
|
||||||
|
raw.settimeout(timeout)
|
||||||
|
return bool(raw.recv(1))
|
||||||
|
except (OSError, ssl.SSLError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def settle(port, want, tries=30):
|
||||||
|
"""Wait for a port to reach the wanted state, so the check is not a race."""
|
||||||
|
for _ in range(tries):
|
||||||
|
if accepts(port) == want:
|
||||||
|
return True
|
||||||
|
time.sleep(0.5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
stop()
|
||||||
|
# Start from nothing. A half-bootstrapped data directory left by an
|
||||||
|
# earlier run is no longer in bootstrap mode, and the recovery admin
|
||||||
|
# stops authenticating the moment a real admin exists.
|
||||||
|
for sub in ("etc-legacy", "data-legacy"):
|
||||||
|
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
|
||||||
|
for sub in ("etc-legacy", "data-legacy", "secrets"):
|
||||||
|
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
|
||||||
|
os.chmod(f"{DIR}/secrets", 0o700)
|
||||||
|
stop()
|
||||||
|
|
||||||
|
# First boot, with a recovery admin from an env file.
|
||||||
|
recovery = secret_file("legacy-recovery")
|
||||||
|
env_file = f"{DIR}/secrets/legacy-env"
|
||||||
|
with open(env_file, "w") as f:
|
||||||
|
f.write(f"INBUXA_RECOVERY_ADMIN=admin:{recovery}\n")
|
||||||
|
os.chmod(env_file, 0o600)
|
||||||
|
start(env_file)
|
||||||
|
|
||||||
|
got = one("admin", recovery, "x:Bootstrap/get", {"ids": None})
|
||||||
|
singleton = got[1]["list"][0]["id"]
|
||||||
|
res = one("admin", recovery, "x:Bootstrap/set", {"update": {singleton: {
|
||||||
|
"serverHostname": "mail.legacy.test", "defaultDomain": "legacy.test",
|
||||||
|
"requestTlsCertificate": False}}})
|
||||||
|
updated = res[1].get("updated", {}).get(singleton)
|
||||||
|
check(bool(updated), "bootstrap completed")
|
||||||
|
if not updated:
|
||||||
|
sys.exit(json.dumps(res))
|
||||||
|
admin, admin_pw = updated["username"], secret_file("legacy-admin", updated["secret"])
|
||||||
|
|
||||||
|
stop()
|
||||||
|
start()
|
||||||
|
|
||||||
|
sess = session(admin, admin_pw)
|
||||||
|
account = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0]
|
||||||
|
policy_get = {"accountId": account, "ids": None}
|
||||||
|
policy_set = lambda update: {"accountId": account, "update": {"singleton": update}}
|
||||||
|
|
||||||
|
# The ports we expect a default install to be accepting on.
|
||||||
|
check(accepts(PORTS["imap"]), "IMAP accepts before the switch")
|
||||||
|
check(accepts(PORTS["pop3"]), "POP3 accepts before the switch")
|
||||||
|
check(accepts(PORTS["submissions"]), "submission accepts before the switch")
|
||||||
|
check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch")
|
||||||
|
|
||||||
|
# What the screen reads: the locked set and what would close (LP-16, LP-21).
|
||||||
|
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||||
|
if got[0] != "inbuxa:ProtocolPolicy/get":
|
||||||
|
sys.exit("ProtocolPolicy/get failed: " + json.dumps(got))
|
||||||
|
policy = got[1]["list"][0]
|
||||||
|
check(policy["legacyProtocols"] == "enabled", "switch starts enabled")
|
||||||
|
check(set(policy["lockedProtocols"]) >= {"smtp", "http"},
|
||||||
|
"SMTP and JMAP report as locked (LP-21)")
|
||||||
|
would = {l["id"] for l in policy["wouldClose"]}
|
||||||
|
print(" wouldClose:", sorted(would))
|
||||||
|
check(would, "wouldClose names the listeners that would close (LP-16)")
|
||||||
|
|
||||||
|
# Turn it off, and ask for submission to close too: the lock must overrule.
|
||||||
|
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||||
|
policy_set({"legacyProtocols": "disabled", "closeSubmission": True}))
|
||||||
|
if not res[1].get("updated"):
|
||||||
|
sys.exit("ProtocolPolicy/set failed: " + json.dumps(res))
|
||||||
|
overruled = res[1]["updated"].get("singleton")
|
||||||
|
check(overruled is not None and overruled.get("closeSubmission") is False,
|
||||||
|
"closeSubmission overruled to false and reported (LP-21, test 18)")
|
||||||
|
|
||||||
|
# The ports themselves (LP-1, LP-2, LP-3, test 15).
|
||||||
|
check(settle(PORTS["imap"], False), "IMAP stopped accepting")
|
||||||
|
check(settle(PORTS["pop3"], False), "POP3 stopped accepting")
|
||||||
|
check(accepts(PORTS["smtp"]), "inbound SMTP still accepts (LP-3)")
|
||||||
|
check(accepts(PORTS["submissions"]), "submission still accepts, being locked (LP-21)")
|
||||||
|
|
||||||
|
# JMAP still works, which is the whole point of locking it.
|
||||||
|
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||||
|
check(got[0] == "inbuxa:ProtocolPolicy/get", "JMAP still works while the switch is off")
|
||||||
|
policy = got[1]["list"][0]
|
||||||
|
check(policy["legacyProtocols"] == "disabled", "switch reads back disabled")
|
||||||
|
saved = {l["id"] for l in policy["savedListeners"]}
|
||||||
|
print(" savedListeners:", sorted(saved))
|
||||||
|
check(saved, "the closed listeners were saved (LP-1)")
|
||||||
|
|
||||||
|
# A restart must not reopen them: the objects are gone, not just the sockets.
|
||||||
|
stop()
|
||||||
|
start()
|
||||||
|
check(settle(PORTS["imap"], False), "IMAP still closed after a restart")
|
||||||
|
check(accepts(PORTS["smtp"]), "inbound SMTP still accepts after a restart")
|
||||||
|
|
||||||
|
# Turn it back on: the listeners come back and bind again (LP-5).
|
||||||
|
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||||
|
policy_set({"legacyProtocols": "enabled"}))
|
||||||
|
if "updated" not in res[1]:
|
||||||
|
sys.exit("ProtocolPolicy/set back on failed: " + json.dumps(res))
|
||||||
|
check(settle(PORTS["imap"], True), "IMAP accepts again without a restart (LP-5)")
|
||||||
|
check(settle(PORTS["pop3"], True), "POP3 accepts again without a restart (LP-5)")
|
||||||
|
|
||||||
|
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||||
|
policy = got[1]["list"][0]
|
||||||
|
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
|
||||||
|
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failures:
|
||||||
|
print(f"{len(failures)} FAILED:")
|
||||||
|
for f in failures:
|
||||||
|
print(" - " + f)
|
||||||
|
else:
|
||||||
|
print("all checks passed")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
rc = 1
|
||||||
|
try:
|
||||||
|
rc = main()
|
||||||
|
finally:
|
||||||
|
if not os.environ.get("KEEP"):
|
||||||
|
stop()
|
||||||
|
sys.exit(rc)
|
||||||
Reference in New Issue
Block a user