diff --git a/.gitignore b/.gitignore index c0f40b9b1..a542cf469 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ *.swp *qr-*.png chatmail*.ini +lxconfigs/ # C extensions diff --git a/chatmaild/src/chatmaild/tests/plugin.py b/chatmaild/src/chatmaild/tests/plugin.py index b57418a3a..165de3e0c 100644 --- a/chatmaild/src/chatmaild/tests/plugin.py +++ b/chatmaild/src/chatmaild/tests/plugin.py @@ -85,13 +85,13 @@ class MockOut: captured_green = [] captured_plain = [] - def red(self, msg): + def red(self, msg, **kw): self.captured_red.append(msg) - def green(self, msg): + def green(self, msg, **kw): self.captured_green.append(msg) - def __call__(self, msg): + def print(self, msg="", **kw): self.captured_plain.append(msg) return MockOut() diff --git a/cmdeploy/src/cmdeploy/basedeploy.py b/cmdeploy/src/cmdeploy/basedeploy.py index 45654c27e..732ed0490 100644 --- a/cmdeploy/src/cmdeploy/basedeploy.py +++ b/cmdeploy/src/cmdeploy/basedeploy.py @@ -1,6 +1,7 @@ import importlib.resources import io import os +from contextlib import contextmanager from pyinfra.operations import files, server, systemd @@ -10,6 +11,28 @@ def has_systemd(): return os.path.isdir("/run/systemd/system") +@contextmanager +def blocked_service_startup(): + """Prevent services from auto-starting during package installation. + + Installs a ``/usr/sbin/policy-rc.d`` that exits 101, blocking any + service from being started by the package manager. This avoids bind + conflicts and CPU/RAM spikes during initial setup. The file is removed + when the context exits. + """ + # For documentation about policy-rc.d, see: + # https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt + files.put( + src=get_resource("policy-rc.d"), + dest="/usr/sbin/policy-rc.d", + user="root", + group="root", + mode="755", + ) + yield + files.file("/usr/sbin/policy-rc.d", present=False) + + def get_resource(arg, pkg=__package__): return importlib.resources.files(pkg).joinpath(arg) diff --git a/cmdeploy/src/cmdeploy/chatmail.zone.j2 b/cmdeploy/src/cmdeploy/chatmail.zone.j2 deleted file mode 100644 index 9915ae68d..000000000 --- a/cmdeploy/src/cmdeploy/chatmail.zone.j2 +++ /dev/null @@ -1,32 +0,0 @@ -; -; Required DNS entries for chatmail servers -; -{% if A %} -{{ mail_domain }}. A {{ A }} -{% endif %} -{% if AAAA %} -{{ mail_domain }}. AAAA {{ AAAA }} -{% endif %} -{{ mail_domain }}. MX 10 {{ mail_domain }}. -{% if strict_tls %} -_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}" -mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}. -{% endif %} -www.{{ mail_domain }}. CNAME {{ mail_domain }}. -{{ dkim_entry }} - -; -; Recommended DNS entries for interoperability and security-hardening -; -{{ mail_domain }}. TXT "v=spf1 a ~all" -_dmarc.{{ mail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s" - -{% if acme_account_url %} -{{ mail_domain }}. CAA 0 issue "letsencrypt.org;accounturi={{ acme_account_url }}" -{% endif %} -_adsp._domainkey.{{ mail_domain }}. TXT "dkim=discardable" - -_submission._tcp.{{ mail_domain }}. SRV 0 1 587 {{ mail_domain }}. -_submissions._tcp.{{ mail_domain }}. SRV 0 1 465 {{ mail_domain }}. -_imap._tcp.{{ mail_domain }}. SRV 0 1 143 {{ mail_domain }}. -_imaps._tcp.{{ mail_domain }}. SRV 0 1 993 {{ mail_domain }}. diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index aace16932..f5050271d 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -15,10 +15,27 @@ import pyinfra from chatmaild.config import read_config, write_initial_config from packaging import version -from termcolor import colored from . import dns, remote -from .sshexec import LocalExec, SSHExec +from .lxc.cli import ( + lxc_start_cmd, + lxc_start_cmd_options, + lxc_status_cmd, + lxc_status_cmd_options, + lxc_stop_cmd, + lxc_stop_cmd_options, + lxc_test_cmd, + lxc_test_cmd_options, +) +from .lxc.incus import DNSConfigurationError +from .sshexec import ( + LocalExec, + SSHExec, + resolve_host_from_ssh_config, + resolve_key_from_ssh_config, +) +from .util import Out +from .www import main as webdev_main # # cmdeploy sub commands and options @@ -82,18 +99,21 @@ def run_cmd_options(parser): help="disable checks nslookup for dns", ) add_ssh_host_option(parser) + add_ssh_config_option(parser) def run_cmd(args, out): """Deploy chatmail services on the remote server.""" ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain - sshexec = get_sshexec(ssh_host) + sshexec = get_sshexec(ssh_host, ssh_config=args.ssh_config) require_iroh = args.config.enable_iroh_relay strict_tls = args.config.tls_cert_mode == "acme" 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() @@ -104,10 +124,24 @@ def run_cmd(args, out): if not args.dns_check_disabled: env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or "" env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or "" + env["DEBIAN_FRONTEND"] = "noninteractive" + env["TERM"] = "linux" deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve() pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" + ssh_config = args.ssh_config + if ssh_config: + ssh_config = str(Path(ssh_config).resolve()) + + # Use pyinfra's native SSH data keys to configure the connection directly + # rather than relying on paramiko config parsing (see also sshexec.py) + ip = resolve_host_from_ssh_config(ssh_host, ssh_config) + key = resolve_key_from_ssh_config(ssh_host, ssh_config) + data_args = f"--data ssh_hostname={ip} --data ssh_known_hosts_file=/dev/null" + if key: + data_args += f" --data ssh_key={key}" + cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y {data_args}" if ssh_host in ["localhost", "@docker"]: if ssh_host == "@docker": env["CHATMAIL_NOPORTCHECK"] = "True" @@ -119,10 +153,17 @@ def run_cmd(args, out): return 1 try: - out.check_call(cmd, env=env) + ret = out.shell(cmd, env=env) + if ret: + out.red("Deploy failed") + return 1 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") else: @@ -139,15 +180,16 @@ def dns_cmd_options(parser): dest="zonefile", type=pathlib.Path, default=None, - help="write out a zonefile", + help="write DNS records in standard BIND format to the given file", ) add_ssh_host_option(parser) + add_ssh_config_option(parser) def dns_cmd(args, out): """Check DNS entries and optionally generate dns zone file.""" ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain - sshexec = get_sshexec(ssh_host, verbose=args.verbose) + sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config) tls_cert_mode = args.config.tls_cert_mode strict_tls = tls_cert_mode == "acme" remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) @@ -178,13 +220,14 @@ def dns_cmd(args, out): def status_cmd_options(parser): add_ssh_host_option(parser) + add_ssh_config_option(parser) def status_cmd(args, out): """Display status for online chatmail instance.""" ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain - sshexec = get_sshexec(ssh_host, verbose=args.verbose) + sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config) out.green(f"chatmail domain: {args.config.mail_domain}") if args.config.privacy_mail: @@ -204,14 +247,14 @@ def test_cmd_options(parser): help="also run slow tests", ) add_ssh_host_option(parser) + add_ssh_config_option(parser) def test_cmd(args, out): """Run local and online tests for chatmail deployment.""" env = os.environ.copy() - if args.ssh_host: - env["CHATMAIL_SSH"] = args.ssh_host + env["CHATMAIL_INI"] = str(args.inipath.resolve()) pytest_path = shutil.which("pytest") pytest_args = [ @@ -225,7 +268,11 @@ def test_cmd(args, out): ] if args.slow: pytest_args.append("--slow") - ret = out.run_ret(pytest_args, env=env) + if args.ssh_host: + pytest_args.extend(["--ssh-host", args.ssh_host]) + if args.ssh_config: + pytest_args.extend(["--ssh-config", str(Path(args.ssh_config).resolve())]) + ret = out.shell(" ".join(pytest_args), env=env) return ret @@ -262,8 +309,8 @@ def fmt_cmd(args, out): format_args.extend(sources) check_args.extend(sources) - out.check_call(" ".join(format_args), quiet=not args.verbose) - out.check_call(" ".join(check_args), quiet=not args.verbose) + out.shell(" ".join(format_args), quiet=not args.verbose) + out.shell(" ".join(check_args), quiet=not args.verbose) def bench_cmd(args, out): @@ -276,9 +323,7 @@ def bench_cmd(args, out): def webdev_cmd(args, out): """Run local web development loop for static web pages.""" - from .www import main - - main() + webdev_main() # @@ -286,32 +331,6 @@ def webdev_cmd(args, out): # -class Out: - """Convenience output printer providing coloring.""" - - def red(self, msg, file=sys.stderr): - print(colored(msg, "red"), file=file) - - def green(self, msg, file=sys.stderr): - print(colored(msg, "green"), file=file) - - def __call__(self, msg, red=False, green=False, file=sys.stdout): - color = "red" if red else ("green" if green else None) - print(colored(msg, color), file=file) - - def check_call(self, arg, env=None, quiet=False): - if not quiet: - self(f"[$ {arg}]", file=sys.stderr) - return subprocess.check_call(arg, shell=True, env=env) - - def run_ret(self, args, env=None, quiet=False): - if not quiet: - cmdstring = " ".join(args) - self(f"[$ {cmdstring}]", file=sys.stderr) - proc = subprocess.run(args, env=env, check=False) - return proc.returncode - - def add_ssh_host_option(parser): parser.add_argument( "--ssh-host", @@ -321,6 +340,16 @@ def add_ssh_host_option(parser): ) +def add_ssh_config_option(parser): + parser.add_argument( + "--ssh-config", + dest="ssh_config", + type=Path, + default=None, + help="Path to an SSH config file (e.g. lxconfigs/ssh-config).", + ) + + def add_config_option(parser): parser.add_argument( "--config", @@ -330,25 +359,26 @@ def add_config_option(parser): type=Path, help="path to the chatmail.ini file", ) - parser.add_argument( - "--verbose", - "-v", - dest="verbose", - action="store_true", - default=False, - help="provide verbose logging", - ) -def add_subcommand(subparsers, func): +def add_subcommand(subparsers, func, add_config=True): name = func.__name__ assert name.endswith("_cmd") - name = name[:-4] + name = name[:-4].replace("_", "-") doc = func.__doc__.strip() help = doc.split("\n")[0].strip(".") p = subparsers.add_parser(name, description=doc, help=help) p.set_defaults(func=func) - add_config_option(p) + if add_config: + add_config_option(p) + p.add_argument( + "-v", + "--verbose", + dest="verbose", + action="count", + default=0, + help="increase verbosity (can be repeated: -v, -vv)", + ) return p @@ -357,45 +387,60 @@ def add_subcommand(subparsers, func): deploy it via SSH to your remote location. """ +# Explicit subcommand registry: (cmd_func, options_func_or_None, needs_config). +# LXC commands don't need a chatmail.ini (no config); all others do. +SUBCOMMANDS = [ + (init_cmd, init_cmd_options, True), + (run_cmd, run_cmd_options, True), + (dns_cmd, dns_cmd_options, True), + (status_cmd, status_cmd_options, True), + (test_cmd, test_cmd_options, True), + (fmt_cmd, fmt_cmd_options, True), + (bench_cmd, None, True), + (webdev_cmd, None, True), + (lxc_start_cmd, lxc_start_cmd_options, False), + (lxc_stop_cmd, lxc_stop_cmd_options, False), + (lxc_status_cmd, lxc_status_cmd_options, False), + (lxc_test_cmd, lxc_test_cmd_options, False), +] + def get_parser(): """Return an ArgumentParser for the 'cmdeploy' CLI""" parser = argparse.ArgumentParser(description=description.strip()) + parser.set_defaults(func=None, inipath=None) subparsers = parser.add_subparsers(title="subcommands") - # find all subcommands in the module namespace - glob = globals() - for name, func in glob.items(): - if name.endswith("_cmd"): - subparser = add_subcommand(subparsers, func) - addopts = glob.get(name + "_options") - if addopts is not None: - addopts(subparser) + for func, addopts, needs_config in SUBCOMMANDS: + subparser = add_subcommand(subparsers, func, add_config=needs_config) + if addopts is not None: + addopts(subparser) return parser -def get_sshexec(ssh_host: str, verbose=True): +def get_sshexec(ssh_host: str, verbose=True, ssh_config=None): if ssh_host in ["localhost", "@local"]: return LocalExec(verbose, docker=False) elif ssh_host == "@docker": return LocalExec(verbose, docker=True) if verbose: print(f"[ssh] login to {ssh_host}") - return SSHExec(ssh_host, verbose=verbose) + return SSHExec(ssh_host, verbose=verbose, ssh_config=ssh_config) def main(args=None): """Provide main entry point for 'cmdeploy' CLI invocation.""" parser = get_parser() args = parser.parse_args(args=args) - if not hasattr(args, "func"): + if args.func is None: return parser.parse_args(["-h"]) - out = Out() + out = Out(verbosity=args.verbose) kwargs = {} - if args.func.__name__ not in ("init_cmd", "fmt_cmd"): + + if args.inipath is not None and args.func.__name__ not in ("init_cmd", "fmt_cmd"): if not args.inipath.exists(): out.red(f"expecting {args.inipath} to exist, run init first?") raise SystemExit(1) @@ -410,6 +455,9 @@ def main(args=None): if res is None: res = 0 return res + except DNSConfigurationError as exc: + out.red(str(exc)) + return 1 except KeyboardInterrupt: out.red("KeyboardInterrupt") sys.exit(130) diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 11536062d..a11e45610 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -17,13 +17,12 @@ from pyinfra.facts.systemd import SystemdEnabled from pyinfra.operations import apt, files, pip, server, systemd -from cmdeploy.cmdeploy import Out - from .acmetool import AcmetoolDeployer from .basedeploy import ( Deployer, Deployment, activate_remote_units, + blocked_service_startup, configure_remote_units, get_resource, has_systemd, @@ -36,6 +35,7 @@ from .opendkim.deployer import OpendkimDeployer from .postfix.deployer import PostfixDeployer from .selfsigned.deployer import SelfSignedTlsDeployer +from .util import Out, get_version_string from .www import build_webpages, find_merge_conflict, get_paths @@ -149,33 +149,16 @@ def __init__(self, config): self.need_restart = False def install(self): - # Run local DNS resolver `unbound`. - # `resolvconf` takes care of setting up /etc/resolv.conf - # to use 127.0.0.1 as the resolver. - - # - # On an IPv4-only system, if unbound is started but not - # configured, it causes subsequent steps to fail to resolve hosts. - # Here, we use policy-rc.d to prevent unbound from starting up - # on initial install. Later, we will configure it and start it. - # - # For documentation about policy-rc.d, see: - # https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt - # - files.put( - src=get_resource("policy-rc.d"), - dest="/usr/sbin/policy-rc.d", - user="root", - group="root", - mode="755", - ) - - apt.packages( - name="Install unbound", - packages=["unbound", "unbound-anchor", "dnsutils"], - ) - - files.file("/usr/sbin/policy-rc.d", present=False) + # Run local DNS resolver `unbound`. `resolvconf` takes care of + # setting up /etc/resolv.conf to use 127.0.0.1 as the resolver. + + # On an IPv4-only system, if unbound is started but not configured, + # it causes subsequent steps to fail to resolve hosts. + with blocked_service_startup(): + apt.packages( + name="Install unbound", + packages=["unbound", "unbound-anchor", "dnsutils"], + ) def configure(self): server.shell( @@ -271,8 +254,14 @@ def configure(self): logger.warning("Web page build failed, skipping website deployment") return # if it is not a hugo page, upload it as is - files.rsync( - f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"] + # pyinfra files.rsync (experimental) causes problems with ssh-config configuration + # the stable files.sync should do + files.sync( + src=str(www_path), + dest="/var/www/html", + user="www-data", + group="www-data", + delete=True, ) @@ -336,12 +325,12 @@ def __init__(self, mail_domain): def install(self): (url, sha256sum) = { "x86_64": ( - "https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux", - "841e527c15fdc2940b0469e206188ea8f0af48533be12ecb8098520f813d41e4", + "https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-x86_64-linux", + "1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a", ), "aarch64": ( - "https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-aarch64-linux", - "a5fc2d06d937b56a34e098d2cd72a82d3e89967518d159bf246dc69b65e81b42", + "https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-aarch64-linux", + "0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756", ), }[host.get_fact(facts.server.Arch)] @@ -474,8 +463,9 @@ class ChatmailDeployer(Deployer): ("iroh", None, None), ] - def __init__(self, mail_domain): - self.mail_domain = mail_domain + def __init__(self, config): + self.config = config + self.mail_domain = config.mail_domain def install(self): files.put( @@ -504,6 +494,17 @@ def install(self): ) def configure(self): + # Ensure the per-domain mailbox directory exists before + # chatmail-metadata starts (it crashes without it). + files.directory( + name="Ensure vmail mailbox directory exists", + path=f"/home/vmail/mail/{self.mail_domain}", + user="vmail", + group="vmail", + mode="700", + present=True, + ) + # This file is used by auth proxy. # https://wiki.debian.org/EtcMailName server.shell( @@ -513,6 +514,15 @@ def configure(self): ], ) + files.directory( + name=f"Ensure mailboxes directory {self.config.mailboxes_dir} exists", + path=str(self.config.mailboxes_dir), + user="vmail", + group="vmail", + mode="700", + present=True, + ) + class FcgiwrapDeployer(Deployer): def install(self): @@ -532,17 +542,9 @@ def activate(self): class GithashDeployer(Deployer): def activate(self): - try: - git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode() - except Exception: - git_hash = "unknown\n" - try: - git_diff = subprocess.check_output(["git", "diff"]).decode() - except Exception: - git_diff = "" files.put( name="Upload chatmail relay git commit hash", - src=StringIO(git_hash + git_diff), + src=StringIO(get_version_string()), dest="/etc/chatmail-version", mode="700", ) @@ -586,11 +588,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - ) # Check if mtail_address interface is available (if configured) - if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'): + if config.mtail_address and config.mtail_address not in ( + "127.0.0.1", + "::1", + "localhost", + ): ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] if config.mtail_address not in all_addresses: - Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") + Out().red( + f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n" + ) exit(1) if not os.environ.get("CHATMAIL_NOPORTCHECK"): @@ -633,7 +641,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - tls_deployer = get_tls_deployer(config, mail_domain) all_deployers = [ - ChatmailDeployer(mail_domain), + ChatmailDeployer(config), LegacyRemoveDeployer(), FiltermailDeployer(), JournaldDeployer(), diff --git a/cmdeploy/src/cmdeploy/dns.py b/cmdeploy/src/cmdeploy/dns.py index 05421b9ed..adfdb3f4a 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -1,11 +1,26 @@ import datetime -import importlib - -from jinja2 import Template from . import remote +def parse_zone_records(text): + """Yield ``(name, ttl, rtype, rdata)`` from standard BIND-format text. + + Skips comment lines (starting with ``;``) and blank lines. + Each record line must have the format ``name TTL IN type rdata``. + """ + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith(";"): + continue + try: + name, ttl, _in, rtype, rdata = line.split(None, 4) + except ValueError: + raise ValueError(f"Bad zone record line: {line!r}") from None + name = name.rstrip(".") + yield name, ttl, rtype.upper(), rdata + + def get_initial_remote_data(sshexec, mail_domain): return sshexec.logged( call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain) @@ -31,13 +46,36 @@ def get_filled_zone_file(remote_data): if not sts_id: remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M") - template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2") - content = template.read_text() - zonefile = Template(content).render(**remote_data) - lines = [x.strip() for x in zonefile.split("\n") if x.strip()] + d = remote_data["mail_domain"] + lines = ["; Required DNS entries"] + if remote_data.get("A"): + lines.append(f"{d}. 3600 IN A {remote_data['A']}") + if remote_data.get("AAAA"): + lines.append(f"{d}. 3600 IN AAAA {remote_data['AAAA']}") + lines.append(f"{d}. 3600 IN MX 10 {d}.") + if remote_data.get("strict_tls"): + lines.append( + f'_mta-sts.{d}. 3600 IN TXT "v=STSv1; id={remote_data["sts_id"]}"' + ) + lines.append(f"mta-sts.{d}. 3600 IN CNAME {d}.") + lines.append(f"www.{d}. 3600 IN CNAME {d}.") + lines.append(remote_data["dkim_entry"]) + lines.append("") + lines.append("; Recommended DNS entries") + lines.append(f'{d}. 3600 IN TXT "v=spf1 a ~all"') + lines.append(f'_dmarc.{d}. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s"') + if remote_data.get("acme_account_url"): + lines.append( + f"{d}. 3600 IN CAA 0 issue" + f' "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"' + ) + lines.append(f'_adsp._domainkey.{d}. 3600 IN TXT "dkim=discardable"') + lines.append(f"_submission._tcp.{d}. 3600 IN SRV 0 1 587 {d}.") + lines.append(f"_submissions._tcp.{d}. 3600 IN SRV 0 1 465 {d}.") + lines.append(f"_imap._tcp.{d}. 3600 IN SRV 0 1 143 {d}.") + lines.append(f"_imaps._tcp.{d}. 3600 IN SRV 0 1 993 {d}.") lines.append("") - zonefile = "\n".join(lines) - return zonefile + return "\n".join(lines) def check_full_zone(sshexec, remote_data, out, zonefile) -> int: @@ -53,18 +91,19 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int: if required_diff: out.red("Please set required DNS entries at your DNS provider:\n") for line in required_diff: - out(line) - out("") + out.print(line) + out.print() returncode = 1 if remote_data.get("dkim_entry") in required_diff: - out( - "If the DKIM entry above does not work with your DNS provider, you can try this one:\n" + out.print( + "If the DKIM entry above does not work with your DNS provider," + " you can try this one:\n" ) - out(remote_data.get("web_dkim_entry") + "\n") + out.print(remote_data.get("web_dkim_entry") + "\n") if recommended_diff: - out("WARNING: these recommended DNS entries are not set:\n") + out.print("WARNING: these recommended DNS entries are not set:\n") for line in recommended_diff: - out(line) + out.print(line) if not (recommended_diff or required_diff): out.green("Great! All your DNS entries are verified and correct.") diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index 2e9bf5b66..6d44e9cd3 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -1,15 +1,15 @@ -import os import urllib.request from chatmaild.config import Config from pyinfra import host -from pyinfra.facts.server import Arch, Sysctl +from pyinfra.facts.server import Arch, Command, Sysctl from pyinfra.facts.systemd import SystemdEnabled from pyinfra.operations import apt, files, server, systemd from cmdeploy.basedeploy import ( Deployer, activate_remote_units, + blocked_service_startup, configure_remote_units, get_resource, has_systemd, @@ -28,9 +28,11 @@ def install(self): arch = host.get_fact(Arch) if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled): return # already installed and running - _install_dovecot_package("core", arch) - _install_dovecot_package("imapd", arch) - _install_dovecot_package("lmtpd", arch) + + with blocked_service_startup(): + _install_dovecot_package("core", arch) + _install_dovecot_package("imapd", arch) + _install_dovecot_package("lmtpd", arch) def configure(self): configure_remote_units(self.config.mail_domain, self.units) @@ -134,19 +136,25 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool): # as per https://doc.dovecot.org/2.3/configuration_manual/os/ # it is recommended to set the following inotify limits - if not os.environ.get("CHATMAIL_NOSYSCTL"): - for name in ("max_user_instances", "max_user_watches"): - key = f"fs.inotify.{name}" - if host.get_fact(Sysctl)[key] > 65535: - # Skip updating limits if already sufficient - # (enables running in incus containers where sysctl readonly) - continue - server.sysctl( - name=f"Change {key}", - key=key, - value=65535, - persist=True, + can_modify = host.get_fact(Command, "systemd-detect-virt -c || true") == "none" + for name in ("max_user_instances", "max_user_watches"): + key = f"fs.inotify.{name}" + value = host.get_fact(Sysctl)[key] + if value > 65534: + continue + if not can_modify: + print( + "\n!!!! refusing to attempt sysctl setting in shared-kernel containers\n" + f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n" + "!!!!" ) + continue + server.sysctl( + name=f"Change {key}", + key=key, + value=65535, + persist=True, + ) timezone_env = files.line( name="Set TZ environment variable", diff --git a/cmdeploy/src/cmdeploy/lxc/cli.py b/cmdeploy/src/cmdeploy/lxc/cli.py new file mode 100644 index 000000000..eddd3b5e7 --- /dev/null +++ b/cmdeploy/src/cmdeploy/lxc/cli.py @@ -0,0 +1,475 @@ +"""lxc-start/stop/status/test subcommands for testing with local containers.""" + +import os +import time + +from ..util import get_git_hash, get_version_string, shell +from .incus import RELAY_IMAGE_ALIAS, Incus, RelayContainer + +RELAY_NAMES = ("test0", "test1") + + +# ------------------------------------------------------------------- +# lxc-start +# ------------------------------------------------------------------- + + +def lxc_start_cmd_options(parser): + _add_name_args( + parser, + help_text="User relay name(s) to create (default: test0).", + ) + parser.add_argument( + "--ipv4-only", + dest="ipv4_only", + action="store_true", + help="Create an IPv4-only container.", + ) + parser.add_argument( + "--run", + action="store_true", + help="Run 'cmdeploy run' on each container after starting it.", + ) + + +def lxc_start_cmd(args, out): + """Create/Ensure and start LXC relay and DNS containers.""" + + with out.section("Preparing container setup"): + _lxc_start_cmd(args, out) + + +def _lxc_start_cmd(args, out): + ix = Incus(out) + sub = out.new_prefixed_out() + out.green("Ensuring base image ...") + ix.ensure_base_image() + out.green("Ensuring DNS container (ns-localchat) ...") + dns_ct = ix.get_dns_container() + dns_ct.ensure() + sub.print(f"DNS container IP: {dns_ct.ipv4}") + + names = args.names if args.names else RELAY_NAMES + relays = list(ix.get_container(n) for n in names) + for ct in relays: + out.green(f"Ensuring container {ct.name!r} ({ct.domain}) ...") + ct.ensure() + ip = ct.ipv4 + + sub.print("Configuring container hostname ...") + ct.configure_hosts(ip) + + sub.print(f"Writing {ct.ini.name} ...") + ct.write_ini(disable_ipv6=args.ipv4_only) + sub.print(f"Config: {ct.ini}") + if args.ipv4_only: + ct.disable_ipv6() + ipv6 = None + else: + output = ct.bash( + "ip -6 addr show scope global -deprecated" + " | grep -oP '(?<=inet6 )[^/]+'", + check=False, + ) + ipv6 = output.strip() if output else None + sub.print(f"{_format_addrs(ip, ipv6)}") + + sub.green(f"Container {ct.name!r} ready: {ct.domain} -> {ip}") + out.print() + + # Reset DNS zones only for the containers we just started + started_cnames = {ct.name for ct in relays} + managed = ix.list_managed() + started = [c for c in managed if c["name"] in started_cnames] + + if started: + out.print( + f"Resetting DNS zones for {len(started)} domain(s) (A + AAAA records) ..." + ) + dns_ct.reset_dns_records(dns_ct.ipv4, started) + + for ct in relays: + if ct.name in started_cnames: + sub.print(f"Configuring DNS in {ct.name} ...") + ct.configure_dns(dns_ct.ipv4) + + # Generate the unified SSH config + out.green("Writing ssh-config ...") + ssh_cfg = ix.write_ssh_config() + sub.print(f"{ssh_cfg}") + + # Verify SSH via the generated config + for ct in relays: + sub.print(f"Verifying SSH to {ct.name} via ssh-config ...") + if ct.verify_ssh(ssh_cfg): + sub.print(f"SSH OK: ssh -F lxconfigs/ssh-config {ct.domain}") + else: + sub.red(f"WARNING: SSH verification failed for {ct.name}") + + # Print integration suggestions + ssh_cfg = ix.ssh_config_path + if not ix.check_ssh_include(): + sub.green( + "\n(Optional) To use containers from any SSH client, add to ~/.ssh/config:" + ) + sub.green(f" Include {ssh_cfg}") + + # Optionally run cmdeploy run + dns on each relay + if args.run: + for ct in relays: + with out.section(f"cmdeploy run: {ct.sname} ({ct.domain})"): + ret = _run_cmdeploy("run", ct, ix, out, extra=["--skip-dns-check"]) + if ret: + out.red(f"Deploy to {ct.sname} failed (exit {ret})") + return ret + + with out.section("loading DNS zones"): + for ct in relays: + ret = _run_cmdeploy( + "dns", ct, ix, out, + extra=["--zonefile", str(ct.zone)], + ) + if ret: + out.red(f"DNS for {ct.sname} failed (exit {ret})") + return ret + if ct.zone.exists(): + dns_ct.set_dns_records(ct.zone.read_text()) + out.print(f"Restarting filtermail-incoming on {ct.name}") + ct.bash("systemctl restart filtermail-incoming") + + +# ------------------------------------------------------------------- +# lxc-stop +# ------------------------------------------------------------------- + + +def lxc_stop_cmd_options(parser): + parser.add_argument( + "--destroy", + action="store_true", + help="Delete containers and their config files after stopping.", + ) + parser.add_argument( + "--destroy-all", + dest="destroy_all", + action="store_true", + help="Like --destroy, but also remove the ns-localchat DNS container.", + ) + _add_name_args( + parser, + help_text="Container name(s) to stop (default: test0 + test1).", + ) + + +def lxc_stop_cmd(args, out): + """Stop (and optionally destroy) local LXC relay containers.""" + ix = Incus(out) + names = args.names or RELAY_NAMES + destroy = args.destroy or args.destroy_all + + for ct in map(ix.get_container, names): + if destroy: + out.green(f"Destroying container {ct.name!r} ...") + ct.destroy() + else: + out.green(f"Stopping container {ct.name!r} ...") + ct.stop(force=True) + + if args.destroy_all: + dns_ct = ix.get_dns_container() + out.green(f"Destroying DNS container {dns_ct.name!r} ...") + dns_ct.destroy() + ix.delete_images() + + if destroy: + ix.write_ssh_config() + out.green("LXC containers destroyed.") + else: + out.green("LXC containers stopped.") + + +# ------------------------------------------------------------------- +# lxc-test +# ------------------------------------------------------------------- + + +def lxc_test_cmd_options(parser): + parser.add_argument( + "--one", + action="store_true", + help="Only deploy and test against test0 (skip test1).", + ) + + +def lxc_test_cmd(args, out): + """Run full LXC pipeline: start, deploy, DNS, zone files, and tests. + + All commands run directly on the host using + ``--ssh-config lxconfigs/ssh-config`` for SSH access. + """ + ix = Incus(out) + t_total = time.time() + relay_names = list(RELAY_NAMES) + if args.one: + relay_names = relay_names[:1] + + local_hash = get_git_hash() + + # Per-relay: start, deploy, then snapshot the first relay as a + # reusable image so the second relay launches pre-deployed. + ipv4_only_flags = {RELAY_NAMES[0]: False, RELAY_NAMES[1]: True} + + for ct in map(ix.get_container, relay_names): + name = ct.sname + ipv4_only = ipv4_only_flags.get(name, False) + v_flag = " -" + "v" * out.verbosity if out.verbosity > 0 else "" + start_cmd = f"cmdeploy lxc-start{v_flag} {name}" + if ipv4_only: + start_cmd += " --ipv4-only" + with out.section(f"cmdeploy lxc-start: {name}"): + ret = out.shell(start_cmd, cwd=str(ix.project_root)) + if ret: + return ret + + status = _deploy_status(ct, local_hash, ix) + with out.section(f"cmdeploy run: {name}"): + if "IN-SYNC" in status: + out.print(f"{name} is {status}, skipping") + else: + ret = _run_cmdeploy("run", ct, ix, out, extra=["--skip-dns-check"]) + if ret: + out.red(f"Deploy to {name} failed (exit {ret})") + return ret + + # Snapshot the first relay so subsequent ones launch pre-deployed + if not ix.find_image([RELAY_IMAGE_ALIAS]): + with out.section("lxc-test: caching relay image"): + ct.publish_as_relay_image() + + for ct in map(ix.get_container, relay_names): + with out.section(f"cmdeploy dns: {ct.sname} ({ct.domain})"): + ret = _run_cmdeploy("dns", ct, ix, out, extra=["--zonefile", str(ct.zone)]) + if ret: + out.red(f"DNS for {ct.sname} failed (exit {ret})") + return ret + + with out.section(f"lxc-test: loading DNS zones {' & '.join(relay_names)}"): + dns_ct = ix.get_dns_container() + for ct in map(ix.get_container, relay_names): + if ct.zone.exists(): + zone_data = ct.zone.read_text() + out.print(f"Loading {ct.zone} into PowerDNS ...") + dns_ct.set_dns_records(zone_data) + + # Restart filtermail so its in-process DNS cache + # does not hold stale negative DKIM responses + # from before the zones were loaded. + for ct in map(ix.get_container, relay_names): + out.print(f"Restarting filtermail-incoming on {ct.name} ...") + ct.bash("systemctl restart filtermail-incoming") + + with out.section("cmdeploy test"): + first = ix.get_container(relay_names[0]) + env = None + if len(relay_names) > 1: + env = os.environ.copy() + env["CHATMAIL_DOMAIN2"] = ix.get_container(relay_names[1]).domain + ret = _run_cmdeploy("test", first, ix, out, **({"env": env} if env else {})) + if ret: + out.red(f"Tests failed (exit {ret})") + return ret + + elapsed = time.time() - t_total + out.section_line(f"lxc-test complete ({elapsed:.1f}s)") + if out.section_timings: + out.print("Section timings:") + for name, secs in out.section_timings: + out.print(f" {name:.<50s} {secs:5.1f}s") + out.print(f" {'total':.<50s} {elapsed:5.1f}s") + out.section_timings.clear() + return 0 + + +# ------------------------------------------------------------------- +# lxc-status +# ------------------------------------------------------------------- + + +def lxc_status_cmd_options(parser): + pass + + +def lxc_status_cmd(args, out): + """Show status of local LXC chatmail containers.""" + ix = Incus(out) + containers = ix.list_managed() + if not containers: + out.red("No LXC containers found. Run 'cmdeploy lxc-start' first.") + return 1 + + local_hash = get_git_hash() + + # Get storage pool path for display + storage_path = None + data = ix.run_json(["storage", "show", "default"], check=False) + if data: + storage_path = data.get("config", {}).get("source") + msg = "Container status" + if storage_path: + msg += f": {storage_path}" + out.section_line(msg) + + dns_ip = None + for c in containers: + _print_container_status(out, c, ix, local_hash) + if c["name"] == ix.get_dns_container().name: + dns_ip = c["ip"] + + out.section_line("Host ssh and DNS configuration") + _print_ssh_status(out, ix) + _print_dns_forwarding_status(out, dns_ip) + return 0 + + +def _print_container_status(out, c, ix, local_hash): + """Print name/status, domain/IPs, and RAM for one container.""" + cname = c["name"] + is_running = c.get("status") == "Running" + ct = ix.get_container(cname) + + # First line: name + running/STOPPED + deploy status + if not is_running: + tag = "STOPPED" + elif not isinstance(ct, RelayContainer): + tag = "running" + else: + tag = f"running {_deploy_status(ct, local_hash, ix)}" + out.print(f"{cname:20s} {tag}") + + # Second line: domain, IPv4, IPv6 + domain = c.get("domain", "") + ip = c.get("ip") or "?" + ipv6 = c.get("ipv6") + out.print(f"{domain:20s} {_format_addrs(ip, ipv6)}") + + # Third line: RAM (RSS), config + detail_out = out.new_prefixed_out(" " * 21) + try: + used, total = ct.rss_mib() + except Exception: + ram_str = "RSS ?" + else: + ram_str = f"RSS {used}/{total} MiB ({used * 100 // total}%)" + + if isinstance(ct, RelayContainer): + detail = f"{ram_str}, config: {os.path.relpath(ct.ini)}" + else: + detail = ram_str + + detail_out.print(detail) + out.print() + + +def _print_ssh_status(out, ix): + """Print SSH integration status.""" + ssh_cfg = ix.ssh_config_path + if ix.check_ssh_include(): + out.green("SSH: ~/.ssh/config includes lxconfigs/ssh-config ✓") + else: + out.red("SSH: ~/.ssh/config does NOT include lxconfigs/ssh-config") + sub = out.new_prefixed_out() + sub.print("Add to ~/.ssh/config:") + sub.print(f" Include {ssh_cfg}") + + +def _print_dns_forwarding_status(out, dns_ip): + """Print host DNS forwarding status for .localchat.""" + sub = out.new_prefixed_out() + if not dns_ip: + out.red("DNS: ns-localchat container not found") + return + try: + rv = shell("resolvectl status incusbr0") + dns_ok = dns_ip in rv.stdout and "localchat" in rv.stdout + except Exception: + dns_ok = None + if dns_ok is True: + out.green(f"DNS: .localchat forwarding to {dns_ip} ✓") + elif dns_ok is False: + out.red("DNS: .localchat forwarding NOT configured") + sub.print("Run:") + sub.print(f" sudo resolvectl dns incusbr0 {dns_ip}") + sub.print(" sudo resolvectl domain incusbr0 ~localchat") + else: + sub.print("DNS: .localchat forwarding status UNKNOWN") + + +# ------------------------------------------------------------------- +# Internal helpers +# ------------------------------------------------------------------- + + +def _format_addrs(ip, ipv6=None): + parts = [f"IPv4 {ip}"] + if ipv6: + parts.append(f"IPv6 {ipv6}") + return ", ".join(parts) + + +def _deploy_status(ct, local_hash, ix): + """Return a human-readable deploy status string. + + Compares the full deployed version (hash + diff) against + the local state built by :func:`~cmdeploy.util.get_version_string`. + """ + deployed = ct.deployed_version() + if deployed is None: + return "NOT DEPLOYED" + + # A container launched from the relay image has the same + # git hash but a different domain — always redeploy. + deployed_domain = ct.deployed_domain() + if deployed_domain and deployed_domain != ct.domain: + return f"DOMAIN-MISMATCH (deployed: {deployed_domain})" + + deployed_lines = deployed.splitlines() + deployed_hash = deployed_lines[0] if deployed_lines else "" + short = deployed_hash[:12] + + if not local_hash: + return f"UNKNOWN (deployed: {short})" + + local_short = local_hash[:12] + if deployed_hash != local_hash: + return f"STALE (deployed: {short}, local: {local_short})" + + # Hash matches — check for uncommitted diffs + local_version = get_version_string() + if deployed != local_version: + return f"DIRTY ({local_short}, undeployed changes)" + + return f"IN-SYNC ({short})" + + +def _add_name_args(parser, help_text): + parser.add_argument("names", nargs="*", metavar="NAME", help=help_text) + + +def _run_cmdeploy(subcmd, ct, ix, out, extra=None, **kwargs): + """Run ``cmdeploy `` with standard --config/--ssh flags. + + *ct* is a Container (uses ``ct.ini`` and ``ct.domain``). + Returns the subprocess exit code. + """ + extra_str = " ".join(extra) if extra else "" + v_flag = " -" + "v" * out.verbosity if out.verbosity > 0 else "" + cmd = f""" + cmdeploy {subcmd}{v_flag} + --config {ct.ini} + --ssh-config {ix.ssh_config_path} + --ssh-host {ct.domain} + {extra_str} + """ + if "cwd" not in kwargs: + kwargs["cwd"] = str(ix.project_root) + return out.shell(cmd, **kwargs) diff --git a/cmdeploy/src/cmdeploy/lxc/incus.py b/cmdeploy/src/cmdeploy/lxc/incus.py new file mode 100644 index 000000000..36700c1d4 --- /dev/null +++ b/cmdeploy/src/cmdeploy/lxc/incus.py @@ -0,0 +1,768 @@ +"""Core Incus operations for local chatmail LXC containers.""" + +import json +import subprocess +import textwrap +import time +from pathlib import Path + +from ..util import shell + +LABEL_KEY = "user.localchat-managed" +SSH_KEY_NAME = "id_localchat" +DOMAIN_SUFFIX = ".localchat" +UPSTREAM_IMAGE = "images:debian/12" +BASE_IMAGE_ALIAS = "localchat-base" +BASE_SETUP_NAME = "localchat-base-setup" +RELAY_IMAGE_ALIAS = "localchat-relay" + +DNS_CONTAINER_NAME = "ns-localchat" +DNS_DOMAIN = "ns.localchat" + + +class DNSConfigurationError(Exception): + """Raised when the DNS container is not reachable or not answering.""" + + +def _extract_ip(net_data, family="inet"): + """Extract the first global-scope IP of *family* from network state data. + + *net_data* is the ``state.network`` dict from ``incus list --format=json``. + *family* is ``"inet"`` for IPv4 or ``"inet6"`` for IPv6. + Returns the address string, or None. + """ + for iface_name, iface in net_data.items(): + if iface_name == "lo": + continue + for addr in iface.get("addresses", []): + if addr["family"] == family and addr["scope"] == "global": + return addr["address"] + return None + + +class Incus: + """Gateway for all Incus container operations. + + Instantiated once per CLI command and passed around so that + all modules share a single entry point for Incus interactions. + """ + + def __init__(self, out): + self.out = out + self.project_root = Path(__file__).resolve().parent.parent.parent.parent.parent + self.lxconfigs_dir = self.project_root / "lxconfigs" + self.lxconfigs_dir.mkdir(exist_ok=True) + self.ssh_key_path = self.lxconfigs_dir / SSH_KEY_NAME + if not self.ssh_key_path.exists(): + shell( + f"ssh-keygen -t ed25519 -f {self.ssh_key_path} -N '' -C localchat", + check=True, + ) + self.ssh_config_path = self.lxconfigs_dir / "ssh-config" + + def write_ssh_config(self): + """Write ``lxconfigs/ssh-config`` mapping all containers to their IPs. + + Each Host block maps the container name, the domain name, and the + short relay name (e.g. ``_test0``) to the container's IP, using the + shared localchat SSH key. Returns the path to the file. + """ + containers = self.list_managed() + key_path = self.ssh_key_path + lines = ["# Auto-generated by cmdeploy lxc-start — do not edit\n"] + for c in containers: + hosts = [c["name"]] + domain = c.get("domain", "") + if domain and domain != c["name"]: + hosts.append(domain) + short = domain.split(".")[0] + if short and short not in hosts: + hosts.append(short) + lines.append(f"\nHost {' '.join(hosts)}\n") + lines.append(f" Hostname {c['ip']}\n") + lines.append(" User root\n") + lines.append(f" IdentityFile {key_path}\n") + lines.append(" IdentitiesOnly yes\n") + lines.append(" StrictHostKeyChecking accept-new\n") + lines.append(" UserKnownHostsFile /dev/null\n") + lines.append(" LogLevel ERROR\n") + path = self.ssh_config_path + path.write_text("".join(lines)) + return path + + def check_ssh_include(self): + """Check if the user's ~/.ssh/config already includes our ssh-config.""" + user_ssh_config = Path.home() / ".ssh" / "config" + if not user_ssh_config.exists(): + return False + lines = user_ssh_config.read_text().splitlines() + target = f"include {self.ssh_config_path}".lower() + return any(line.strip().lower() == target for line in lines) + + def get_host_nameservers(self): + """Return upstream nameservers found on the host.""" + ns = [] + for path in ["/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"]: + p = Path(path) + if p.exists(): + for line in p.read_text().splitlines(): + if line.strip().startswith("nameserver "): + addr = line.split()[1] + if addr not in ("127.0.0.1", "127.0.0.53", "::1"): + if addr not in ns: + ns.append(addr) + if ns: + break + return ns + + def run(self, args, check=True, capture=True, input=None): + """Run an incus command. + + When *capture* is True and *verbosity* >= 1, output is streamed + to the terminal line-by-line while also being captured for + later return via result.stdout. + """ + cmd = ["incus", "--quiet"] + list(args) + sub = self.out.new_prefixed_out(" ") + + if not capture: + # Simple case: let subprocess handle streams (no capture) + if self.out.verbosity >= 1: + sub.print(f"$ {' '.join(cmd)}") + return subprocess.run( + cmd, text=True, input=input, check=check, stdout=None, stderr=None + ) + + # Capture case: we may need to stream while capturing + if sub.verbosity >= 1: + cmd_lines = " ".join(cmd).splitlines() + sub.print(f"$ {cmd_lines.pop(0)}") + if sub.verbosity >= 2: + for line in cmd_lines: + sub.print(f" {line}") + + proc = subprocess.Popen( + cmd, + text=True, + stdin=subprocess.PIPE if input else subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + stdout_lines = [] + if input: + proc.stdin.write(input) + proc.stdin.close() + + for line in proc.stdout: + stdout_lines.append(line) + if sub.verbosity >= 2: + sub.print(f" > {line.rstrip()}") + + stderr = proc.stderr.read() + ret = proc.wait() + stdout = "".join(stdout_lines) + if check and ret != 0: + full_output = stdout + stderr + for line in full_output.splitlines(): + if sub.verbosity < 1: # and we haven't printed it yet + sub.red(line) + raise subprocess.CalledProcessError(ret, cmd, output=stdout, stderr=stderr) + + return subprocess.CompletedProcess(cmd, ret, stdout=stdout, stderr=stderr) + + def run_json(self, args, check=True): + """Run an incus command with ``--format=json``. + + Returns the parsed JSON on success. + When *check* is True raises ``subprocess.CalledProcessError`` + on non-zero exit; when False returns *None* instead. + """ + result = self.run( + list(args) + ["--format=json"], + check=check, + ) + if result.returncode != 0: + return None + return json.loads(result.stdout) + + def run_output(self, args, check=True): + """Run an incus command and return its stripped stdout. + + When *check* is False, returns *None* on non-zero exit + instead of raising. + """ + result = self.run(args, check=check) + if result.returncode != 0: + return None + return result.stdout.strip() + + def find_image(self, aliases): + """Return the first alias from *aliases* that exists, else None.""" + images = self.run_json(["image", "list"], check=False) or [] + existing = {a.get("name") for img in images for a in img.get("aliases", [])} + for alias in aliases: + if alias in existing: + return alias + return None + + def delete_images(self): + """Delete the cached base and relay images.""" + for alias in (RELAY_IMAGE_ALIAS, BASE_IMAGE_ALIAS): + self.run(["image", "delete", alias], check=False) # ok if absent + + def list_managed(self): + """Return list of dicts with name, ip, ipv6, domain, status, memory_usage.""" + containers = [] + for ct in self.run_json(["list"]): + config = ct.get("config", {}) + if config.get(LABEL_KEY) != "true": + continue + name = ct["name"] + state = ct.get("state", {}) + net = state.get("network") or {} + containers.append( + { + "name": name, + "ip": _extract_ip(net, "inet"), + "ipv6": _extract_ip(net, "inet6"), + "domain": config.get( + "user.localchat-domain", f"{name}{DOMAIN_SUFFIX}" + ), + "status": ct.get("status", "Unknown"), + "memory_usage": state.get("memory", {}).get("usage", 0), + } + ) + return containers + + def ensure_base_image(self): + """Build and cache a base image with openssh and the SSH key. + + The image is published as a local incus image with alias + 'localchat-base'. Subsequent container launches use this + image instead of the upstream Debian 12, skipping the + slow apt-get install step. + Returns the image alias. + """ + if self.find_image([BASE_IMAGE_ALIAS]): + self.out.print(f" Base image '{BASE_IMAGE_ALIAS}' already cached.") + return BASE_IMAGE_ALIAS + + self.out.print(" Building base image (one-time setup) ...") + + self.run(["delete", BASE_SETUP_NAME, "--force"], check=False) + self.run(["image", "delete", BASE_IMAGE_ALIAS], check=False) + self.run(["launch", UPSTREAM_IMAGE, BASE_SETUP_NAME]) + + ct = Container(self, BASE_SETUP_NAME) + ct.wait_ready() + + key_path = self.ssh_key_path + pub_key = key_path.with_suffix(".pub").read_text().strip() + host_ns = self.get_host_nameservers() + ns_lines = "\n".join(f"nameserver {n}" for n in host_ns) + ct.bash(f""" + printf '{ns_lines}\n' > /etc/resolv.conf + apt-get -o DPkg::Lock::Timeout=60 update + DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server python3 + systemctl enable ssh + apt-get clean + mkdir -p /root/.ssh + chmod 700 /root/.ssh + echo '{pub_key}' > /root/.ssh/authorized_keys + chmod 600 /root/.ssh/authorized_keys + """) + + self.run(["stop", BASE_SETUP_NAME]) + self.run(["publish", BASE_SETUP_NAME, f"--alias={BASE_IMAGE_ALIAS}"]) + self.run(["delete", BASE_SETUP_NAME, "--force"]) + self.out.print(f" Base image '{BASE_IMAGE_ALIAS}' ready.") + return BASE_IMAGE_ALIAS + + def get_container(self, name): + """Return a container handle for the given name. + + Accepts both short relay names (``test0``) and full Incus + container names (``test0-localchat``). Returns + ``DNSContainer`` for the DNS container and + ``RelayContainer`` for everything else. + """ + if name == DNS_CONTAINER_NAME: + return DNSContainer(self) + return RelayContainer(self, name.removesuffix("-localchat")) + + def get_dns_container(self): + """Return a DNSContainer handle.""" + return DNSContainer(self) + + +class Container: + """The base container handle wraps all interactions with incus.""" + + def __init__(self, incus, name, domain=None): + self.incus = incus + self.out = incus.out + self.name = name + self.domain = domain or f"{name}{DOMAIN_SUFFIX}" + self.ipv4 = None + self.ipv6 = None + + def bash(self, script, check=True): + """Returns stdout from executing ``bash -ec