From 4c87ca6ebace247e93400653b467b4653b2ef3cd Mon Sep 17 00:00:00 2001 From: ccclxxiii <151577046+ccclxxiii@users.noreply.github.com> Date: Thu, 25 Jun 2026 08:13:27 -0500 Subject: [PATCH] feat(cmdeploy): add remove command --- cmdeploy/src/cmdeploy/cmdeploy.py | 92 +++++- cmdeploy/src/cmdeploy/remove.py | 22 ++ cmdeploy/src/cmdeploy/removers.py | 290 +++++++++++++++++++ cmdeploy/src/cmdeploy/tests/test_cmdeploy.py | 44 +++ cmdeploy/src/cmdeploy/tests/test_removers.py | 99 +++++++ 5 files changed, 545 insertions(+), 2 deletions(-) create mode 100644 cmdeploy/src/cmdeploy/remove.py create mode 100644 cmdeploy/src/cmdeploy/removers.py create mode 100644 cmdeploy/src/cmdeploy/tests/test_removers.py diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index edca0a3a..8cf3c6c8 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -104,7 +104,9 @@ def run_cmd(args, out): args.dns_check_disabled = True if not args.dns_check_disabled: remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) - if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): + if not dns.check_initial_remote_data( + remote_data, strict_tls=strict_tls, print=out.red + ): return 1 env = os.environ.copy() @@ -127,7 +129,11 @@ def run_cmd(args, out): out.check_call(cmd, env=env) if args.website_only: out.green("Website deployment completed.") - elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: + elif ( + not args.dns_check_disabled + and strict_tls + and not remote_data["acme_account_url"] + ): out.red("Deploy completed but letsencrypt not configured") out.red("Run 'cmdeploy run' again") elif args.config.ipv4_relay: @@ -141,6 +147,88 @@ def run_cmd(args, out): return 1 +def remove_cmd_options(parser): + parser.add_argument( + "--dry-run", + dest="dry_run", + action="store_true", + help="show what would be removed without modifying the server", + ) + parser.add_argument( + "--yes", + "-y", + dest="yes", + action="store_true", + help="do not ask for confirmation before removing chatmail data", + ) + parser.add_argument( + "--keep-packages", + action="store_true", + help="remove chatmail files and users but do not purge installed packages", + ) + add_ssh_host_option(parser) + + +def _confirm_remove(args, out): + if args.yes: + return True + + domain = args.config.mail_domain_bare + out.red( + "WARNING: this removes chatmail services, configuration, package state, " + f"and mailbox data for {domain!r}." + ) + if args.config.tls_cert_mode == "acme": + out.red( + "DNS records are not removed. If your DNS has a CAA record with " + "a Let's Encrypt accounturi, remove or relax it before deploying " + "this relay again." + ) + out.red("Type the chatmail domain to continue.") + try: + answer = input(f"remove {domain}: ") + except EOFError: + answer = "" + + if answer != domain: + out.red("Remove cancelled.") + return False + return True + + +def remove_cmd(args, out): + """Remove chatmail services, configuration, state, users and packages.""" + + if not _confirm_remove(args, out): + return 1 + + ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare + env = os.environ.copy() + env["CHATMAIL_INI"] = str(args.inipath) + env["CHATMAIL_KEEP_PACKAGES"] = "True" if args.keep_packages else "" + remove_path = importlib.resources.files(__package__).joinpath("remove.py").resolve() + pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" + + cmd = f"{pyinf} --ssh-user root {ssh_host} {remove_path} -y" + if ssh_host == "localhost": + cmd = f"{pyinf} @local {remove_path} -y" + + if version.parse(pyinfra.__version__) < version.parse("3"): + out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.") + return 1 + + try: + out.check_call(cmd, env=env) + if args.dry_run: + out.green("Remove dry run completed.") + else: + out.green("Remove completed.") + return 0 + except subprocess.CalledProcessError: + out.red("Remove failed") + return 1 + + def dns_cmd_options(parser): parser.add_argument( "--zonefile", diff --git a/cmdeploy/src/cmdeploy/remove.py b/cmdeploy/src/cmdeploy/remove.py new file mode 100644 index 00000000..1a48aaa5 --- /dev/null +++ b/cmdeploy/src/cmdeploy/remove.py @@ -0,0 +1,22 @@ +import importlib.resources +import os + +import pyinfra + +# pyinfra runs this module as a python file and not as a module so +# import paths must be absolute +from cmdeploy.removers import remove_chatmail + + +def main(): + config_path = os.getenv( + "CHATMAIL_INI", + importlib.resources.files("cmdeploy").joinpath("../../../chatmail.ini"), + ) + keep_packages = bool(os.environ.get("CHATMAIL_KEEP_PACKAGES")) + + remove_chatmail(config_path, keep_packages=keep_packages) + + +if pyinfra.is_cli: + main() diff --git a/cmdeploy/src/cmdeploy/removers.py b/cmdeploy/src/cmdeploy/removers.py new file mode 100644 index 00000000..9a1a7f22 --- /dev/null +++ b/cmdeploy/src/cmdeploy/removers.py @@ -0,0 +1,290 @@ +import shlex +from pathlib import Path + +from chatmaild.config import read_config +from pyinfra.operations import apt, files, server + +CHATMAIL_UNITS = [ + "doveauth.service", + "lastlogin.service", + "chatmail-metadata.service", + "chatmail-expire.service", + "chatmail-expire.timer", + "chatmail-fsreport.service", + "chatmail-fsreport.timer", + "filtermail.service", + "filtermail-incoming.service", + "filtermail-transport.service", + "turnserver.service", + "iroh-relay.service", + "mtail.service", + "acmetool-redirector.service", + "acmetool-reconcile.service", + "acmetool-reconcile.timer", + "tls-cert-reload.path", + "tls-cert-reload.service", +] + +LEGACY_UNITS = [ + "doveauth-dictproxy.service", + "echobot.service", + "mta-sts-daemon.service", +] + +PACKAGE_UNITS = [ + "dovecot.service", + "postfix.service", + "nginx.service", + "opendkim.service", + "unbound.service", + "fcgiwrap.service", +] + +PACKAGE_NAMES = [ + "acmetool", + "dovecot-core", + "dovecot-imapd", + "dovecot-lmtpd", + "postfix", + "opendkim", + "opendkim-tools", + "nginx", + "nginx-common", + "libnginx-mod-stream", + "fcgiwrap", + "unbound", + "unbound-anchor", + "dnsutils", + "python3-virtualenv", + "gcc", + "python3-dev", + "curl", + "rsync", +] + +RELAY_FILES = [ + "/etc/apt/apt.conf.d/00InstallRecommends", + "/etc/apt/keyrings/obs-home-deltachat.gpg", + "/etc/apt/preferences.d/pin-dovecot", + "/etc/chatmail-nocreate", + "/etc/chatmail-version", + "/etc/cron.d/acmetool", + "/etc/cron.d/chatmail-metrics", + "/etc/cron.d/expunge", + "/etc/dovecot/auth.conf", + "/etc/dovecot/dovecot.conf", + "/etc/dovecot/push_notification.lua", + "/etc/iroh-relay.toml", + "/etc/mailname", + "/etc/mta-sts-daemon.yml", + "/etc/mtail/delivered_mail.mtail", + "/etc/nginx/nginx.conf", + "/etc/opendkim.conf", + "/etc/postfix/lmtp_header_cleanup", + "/etc/postfix/login_map", + "/etc/postfix/main.cf", + "/etc/postfix/master.cf", + "/etc/postfix/smtp_tls_policy_map", + "/etc/postfix/smtp_tls_policy_map.db", + "/etc/postfix/submission_header_cleanup", + "/etc/systemd/journald.conf", + "/etc/systemd/system/mta-sts-daemon.service", + "/etc/systemd/system/dovecot.service.d/10_restart.conf", + "/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf", + "/etc/systemd/system/postfix@.service.d/10_restart.conf", + "/etc/unbound/unbound.conf.d/chatmail.conf", + "/usr/lib/cgi-bin/newemail.py", + "/usr/local/bin/chatmail-turn", + "/usr/local/bin/filtermail", + "/usr/local/bin/iroh-relay", + "/usr/local/bin/mtail", + "/usr/sbin/policy-rc.d", + "/var/www/html/metrics", +] + +PACKAGE_CONFIG_DIRS = [ + "/etc/dkimkeys", + "/etc/dovecot", + "/etc/nginx", + "/etc/opendkim", + "/etc/postfix", + "/etc/unbound", +] + +RELAY_DIRS = [ + "/etc/mtail", + "/root/from-cmdeploy", + "/usr/local/lib/chatmaild", + "/usr/local/lib/postfix-mta-sts-resolver", + "/var/lib/dovecot", + "/var/lib/nginx", + "/var/lib/postfix", + "/var/lib/unbound", + "/var/log/nginx", + "/var/spool/postfix", + "/var/www/html", +] + +EMPTY_SYSTEMD_DIRS = [ + "/etc/systemd/system/dovecot.service.d", + "/etc/systemd/system/opendkim.service.d", + "/etc/systemd/system/postfix@.service.d", +] + +RELAY_USERS = ["vmail", "iroh", "opendkim"] +RELAY_GROUPS = ["vmail", "iroh", "opendkim"] + + +def _quote(value) -> str: + return shlex.quote(str(value)) + + +def _systemd_command(args: str) -> str: + return ( + "if command -v systemctl >/dev/null && [ -d /run/systemd/system ]; " + f"then systemctl {args} || true; fi" + ) + + +def _remove_file(path: str): + files.file(name=f"Remove {path}", path=path, present=False) + + +def _remove_dir(path: str): + files.directory(name=f"Remove {path}", path=path, present=False) + + +def _remove_units(): + units = CHATMAIL_UNITS + LEGACY_UNITS + PACKAGE_UNITS + quoted_units = " ".join(_quote(unit) for unit in units) + server.shell( + name="Stop and disable chatmail services", + commands=[_systemd_command(f"disable --now {quoted_units}")], + ) + + for unit in CHATMAIL_UNITS + LEGACY_UNITS: + _remove_file(f"/etc/systemd/system/{unit}") + for path in RELAY_FILES: + _remove_file(path) + + dirs = " ".join(_quote(path) for path in EMPTY_SYSTEMD_DIRS) + server.shell( + name="Remove empty chatmail systemd drop-in directories", + commands=[f"rmdir --ignore-fail-on-non-empty {dirs} 2>/dev/null || true"], + ) + server.shell( + name="Reload systemd after removing chatmail units", + commands=[ + _systemd_command("daemon-reload"), + _systemd_command("reset-failed"), + ], + ) + + +def _remove_packages(keep_packages: bool): + if keep_packages: + return + apt.packages( + name="Purge chatmail relay packages", + packages=PACKAGE_NAMES, + present=False, + purge=True, + ) + server.shell( + name="Remove downloaded dovecot packages", + commands=[ + "rm -f -- /root/dovecot-core_*.deb " + "/root/dovecot-imapd_*.deb /root/dovecot-lmtpd_*.deb" + ], + ) + + +def _remove_dynamic_state(config, keep_packages: bool): + if not keep_packages: + for path in PACKAGE_CONFIG_DIRS: + _remove_dir(path) + + for path in RELAY_DIRS: + _remove_dir(path) + + _remove_dir(str(config.mailboxes_dir)) + + passdb = _quote(config.passdb_path) + server.shell( + name="Remove legacy chatmail passdb files", + commands=[f"rm -f -- {passdb} {passdb}.old {passdb}-*"], + ) + + home_vmail = Path("/home/vmail") + if home_vmail == config.mailboxes_dir or home_vmail in config.mailboxes_dir.parents: + _remove_dir(str(home_vmail)) + + if config.tls_cert_mode == "self": + _remove_file("/etc/ssl/certs/mailserver.pem") + _remove_file("/etc/ssl/private/mailserver.key") + elif config.tls_cert_mode == "acme": + _remove_dir("/etc/acme") + _remove_dir("/var/lib/acme") + + +def _restore_basic_resolver(): + server.shell( + name="Restore basic resolver after removing unbound", + commands=[ + _systemd_command("unmask systemd-resolved.service"), + _systemd_command("enable --now systemd-resolved.service"), + "if [ -e /run/systemd/resolve/stub-resolv.conf ]; then " + "ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf; " + "else printf 'nameserver 9.9.9.9\\n' >/etc/resolv.conf; fi", + ], + ) + + +def _remove_environment_changes(): + files.line( + name="Remove chatmail TZ environment override", + path="/etc/environment", + line="TZ=:/etc/localtime", + present=False, + ) + files.line( + name="Remove chatmail inotify instance sysctl override", + path="/etc/sysctl.conf", + line="fs.inotify.max_user_instances = 65535", + present=False, + ) + files.line( + name="Remove chatmail inotify watches sysctl override", + path="/etc/sysctl.conf", + line="fs.inotify.max_user_watches = 65535", + present=False, + ) + files.line( + name="Remove legacy chatmail dovecot package repository", + path="/etc/apt/sources.list", + line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./", + escape_regex_characters=True, + present=False, + ) + + +def _remove_users(): + commands = [] + for user in RELAY_USERS: + quoted = _quote(user) + commands.append(f"userdel -r {quoted} 2>/dev/null || userdel {quoted} || true") + for group in RELAY_GROUPS: + commands.append(f"groupdel {_quote(group)} 2>/dev/null || true") + server.shell(name="Remove chatmail users and groups", commands=commands) + + +def remove_chatmail(config_path: Path, keep_packages: bool = False) -> None: + """Remove the deployed chatmail relay from the target host.""" + config = read_config(config_path) + + _remove_units() + _remove_packages(keep_packages=keep_packages) + _remove_dynamic_state(config, keep_packages=keep_packages) + _remove_environment_changes() + _restore_basic_resolver() + _remove_users() diff --git a/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py b/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py index c4513bc1..c57cbcd3 100644 --- a/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py +++ b/cmdeploy/src/cmdeploy/tests/test_cmdeploy.py @@ -47,6 +47,50 @@ class TestCmdline: out, err = capsys.readouterr() assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n" + def test_remove_cancelled_on_wrong_confirmation(self, tmp_path, monkeypatch): + monkeypatch.delenv("CHATMAIL_INI", raising=False) + inipath = tmp_path / "chatmail.ini" + assert main(["init", "--config", str(inipath), "chat.example.org"]) == 0 + + def check_call(self, arg, env=None, quiet=False): + raise AssertionError("remove command should not run after cancellation") + + monkeypatch.setattr("cmdeploy.cmdeploy.Out.check_call", check_call) + monkeypatch.setattr("builtins.input", lambda prompt: "wrong.example.org") + assert main(["remove", "--config", str(inipath)]) == 1 + + def test_remove_dry_run_local(self, tmp_path, monkeypatch): + monkeypatch.delenv("CHATMAIL_INI", raising=False) + inipath = tmp_path / "chatmail.ini" + assert main(["init", "--config", str(inipath), "chat.example.org"]) == 0 + + calls = [] + + def check_call(self, arg, env=None, quiet=False): + calls.append((arg, env)) + + monkeypatch.setattr("cmdeploy.cmdeploy.Out.check_call", check_call) + assert ( + main( + [ + "remove", + "--config", + str(inipath), + "--ssh-host", + "localhost", + "--dry-run", + "--yes", + "--keep-packages", + ] + ) + == 0 + ) + cmd, env = calls[0] + assert cmd.startswith("pyinfra --dry @local ") + assert "remove.py -y" in cmd + assert env["CHATMAIL_INI"] == str(inipath) + assert env["CHATMAIL_KEEP_PACKAGES"] == "True" + def test_www_folder(example_config, tmp_path): reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() diff --git a/cmdeploy/src/cmdeploy/tests/test_removers.py b/cmdeploy/src/cmdeploy/tests/test_removers.py new file mode 100644 index 00000000..1e41e6c7 --- /dev/null +++ b/cmdeploy/src/cmdeploy/tests/test_removers.py @@ -0,0 +1,99 @@ +from unittest.mock import call + +from cmdeploy import removers + + +def test_remove_chatmail_purges_packages_and_state(make_config, monkeypatch): + config = make_config("chat.example.org") + + apt_calls = [] + file_calls = [] + dir_calls = [] + shell_calls = [] + line_calls = [] + + monkeypatch.setattr(removers.apt, "packages", lambda **kw: apt_calls.append(kw)) + monkeypatch.setattr(removers.files, "file", lambda **kw: file_calls.append(kw)) + monkeypatch.setattr(removers.files, "directory", lambda **kw: dir_calls.append(kw)) + monkeypatch.setattr(removers.server, "shell", lambda **kw: shell_calls.append(kw)) + monkeypatch.setattr(removers.files, "line", lambda **kw: line_calls.append(kw)) + + removers.remove_chatmail(config._inipath) + + assert apt_calls == [ + { + "name": "Purge chatmail relay packages", + "packages": removers.PACKAGE_NAMES, + "present": False, + "purge": True, + } + ] + assert call(path="/usr/local/lib/chatmaild", present=False) in [ + call(path=entry["path"], present=entry["present"]) for entry in dir_calls + ] + assert call(path=str(config.mailboxes_dir), present=False) in [ + call(path=entry["path"], present=entry["present"]) for entry in dir_calls + ] + assert any(entry["path"] == "/var/lib/acme" for entry in dir_calls) + assert any( + entry["path"] == "/etc/systemd/system/doveauth.service" for entry in file_calls + ) + assert any(entry["path"] == "/etc/postfix/main.cf" for entry in file_calls) + assert any(entry["path"] == "/etc/opendkim.conf" for entry in file_calls) + assert any(entry["path"] == "/etc/postfix" for entry in dir_calls) + assert any(entry["path"] == "/var/log/nginx" for entry in dir_calls) + assert any( + "userdel -r vmail" in command + for entry in shell_calls + for command in entry["commands"] + ) + assert any(entry["path"] == "/etc/environment" for entry in line_calls) + + +def test_remove_chatmail_keep_packages_and_external_tls(make_config, monkeypatch): + config = make_config( + "chat.example.org", + {"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem"}, + ) + + apt_calls = [] + file_calls = [] + dir_calls = [] + + monkeypatch.setattr(removers.apt, "packages", lambda **kw: apt_calls.append(kw)) + monkeypatch.setattr(removers.files, "file", lambda **kw: file_calls.append(kw)) + monkeypatch.setattr(removers.files, "directory", lambda **kw: dir_calls.append(kw)) + monkeypatch.setattr(removers.server, "shell", lambda **kw: None) + monkeypatch.setattr(removers.files, "line", lambda **kw: None) + + removers.remove_chatmail(config._inipath, keep_packages=True) + + assert apt_calls == [] + removed_files = {entry["path"] for entry in file_calls} + removed_dirs = {entry["path"] for entry in dir_calls} + assert "/certs/fullchain.pem" not in removed_files + assert "/certs/privkey.pem" not in removed_files + assert "/var/lib/acme" not in removed_dirs + assert "/etc/nginx" not in removed_dirs + assert "/etc/unbound" not in removed_dirs + assert "/etc/postfix" not in removed_dirs + assert "/etc/dovecot" not in removed_dirs + assert "/etc/nginx/nginx.conf" in removed_files + assert "/etc/unbound/unbound.conf.d/chatmail.conf" in removed_files + + +def test_remove_chatmail_removes_self_signed_tls(make_config, monkeypatch): + config = make_config("_test.example.org") + file_calls = [] + + monkeypatch.setattr(removers.apt, "packages", lambda **kw: None) + monkeypatch.setattr(removers.files, "file", lambda **kw: file_calls.append(kw)) + monkeypatch.setattr(removers.files, "directory", lambda **kw: None) + monkeypatch.setattr(removers.server, "shell", lambda **kw: None) + monkeypatch.setattr(removers.files, "line", lambda **kw: None) + + removers.remove_chatmail(config._inipath) + + removed = {entry["path"] for entry in file_calls} + assert "/etc/ssl/certs/mailserver.pem" in removed + assert "/etc/ssl/private/mailserver.key" in removed