Skip to content

Commit 1a46e54

Browse files
committed
feat: add LXC container support for local chatmail development
Add cmdeploy "lxc-test" command to run cmdeploy against local containers, with supplementary lxc-start, lxc-stop and lxc-status subcommands. See doc/source/lxc.rst for full documentation including prerequisites, DNS setup, TLS handling, DNS-free testing, and known limitations. Apart from adding lxc-specific docs, tests, and implementation files in the cmdeploy/lxc directory, this PR adds the --ssh-config option to cmdeploy run/dns/status/test commands and pyinfra invocations, and also to sshexec (Execnet) handling. This allows for the host to need no DNS entries for a relay, and route all resolution through ssh-config. This is used by the "lxc-test" command, which performs a completely local setup -- again, see docs for more details. While working on DNS/SSH things i also unified all zone-file handling to use actual BIND format as it is easy enough to parse back.
1 parent ed9b409 commit 1a46e54

24 files changed

Lines changed: 2115 additions & 126 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ __pycache__/
55
*.swp
66
*qr-*.png
77
chatmail*.ini
8+
lxconfigs/
89

910

1011
# C extensions

cmdeploy/src/cmdeploy/chatmail.zone.j2

Lines changed: 0 additions & 32 deletions
This file was deleted.

cmdeploy/src/cmdeploy/cmdeploy.py

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,23 @@
1818
from termcolor import colored
1919

2020
from . import dns, remote
21-
from .sshexec import LocalExec, SSHExec
21+
from .lxc.cli import ( # noqa: F401
22+
lxc_start_cmd,
23+
lxc_start_cmd_options,
24+
lxc_status_cmd,
25+
lxc_status_cmd_options,
26+
lxc_stop_cmd,
27+
lxc_stop_cmd_options,
28+
lxc_test_cmd,
29+
lxc_test_cmd_options,
30+
)
31+
from .sshexec import (
32+
LocalExec,
33+
SSHExec,
34+
resolve_host_from_ssh_config,
35+
resolve_key_from_ssh_config,
36+
)
37+
from .www import main as webdev_main
2238

2339
#
2440
# cmdeploy sub commands and options
@@ -82,13 +98,14 @@ def run_cmd_options(parser):
8298
help="disable checks nslookup for dns",
8399
)
84100
add_ssh_host_option(parser)
101+
add_ssh_config_option(parser)
85102

86103

87104
def run_cmd(args, out):
88105
"""Deploy chatmail services on the remote server."""
89106

90107
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
91-
sshexec = get_sshexec(ssh_host)
108+
sshexec = get_sshexec(ssh_host, ssh_config=args.ssh_config)
92109
require_iroh = args.config.enable_iroh_relay
93110
strict_tls = args.config.tls_cert_mode == "acme"
94111
if not args.dns_check_disabled:
@@ -108,6 +125,18 @@ def run_cmd(args, out):
108125
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
109126

110127
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
128+
ssh_config = args.ssh_config
129+
if ssh_config:
130+
ssh_config = str(Path(ssh_config).resolve())
131+
132+
# Use pyinfra's native SSH data keys to configure the connection directly
133+
# rather than relying on paramiko config parsing (see also sshexec.py)
134+
ip = resolve_host_from_ssh_config(ssh_host, ssh_config)
135+
key = resolve_key_from_ssh_config(ssh_host, ssh_config)
136+
data_args = f"--data ssh_hostname={ip} --data ssh_known_hosts_file=/dev/null"
137+
if key:
138+
data_args += f" --data ssh_key={key}"
139+
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y {data_args}"
111140
if ssh_host in ["localhost", "@docker"]:
112141
if ssh_host == "@docker":
113142
env["CHATMAIL_NOPORTCHECK"] = "True"
@@ -139,15 +168,16 @@ def dns_cmd_options(parser):
139168
dest="zonefile",
140169
type=pathlib.Path,
141170
default=None,
142-
help="write out a zonefile",
171+
help="write DNS records in standard BIND format to the given file",
143172
)
144173
add_ssh_host_option(parser)
174+
add_ssh_config_option(parser)
145175

146176

147177
def dns_cmd(args, out):
148178
"""Check DNS entries and optionally generate dns zone file."""
149179
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
150-
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
180+
sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config)
151181
tls_cert_mode = args.config.tls_cert_mode
152182
strict_tls = tls_cert_mode == "acme"
153183
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
@@ -178,13 +208,14 @@ def dns_cmd(args, out):
178208

179209
def status_cmd_options(parser):
180210
add_ssh_host_option(parser)
211+
add_ssh_config_option(parser)
181212

182213

183214
def status_cmd(args, out):
184215
"""Display status for online chatmail instance."""
185216

186217
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
187-
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
218+
sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config)
188219

189220
out.green(f"chatmail domain: {args.config.mail_domain}")
190221
if args.config.privacy_mail:
@@ -204,14 +235,18 @@ def test_cmd_options(parser):
204235
help="also run slow tests",
205236
)
206237
add_ssh_host_option(parser)
238+
add_ssh_config_option(parser)
207239

208240

209241
def test_cmd(args, out):
210242
"""Run local and online tests for chatmail deployment."""
211243

212244
env = os.environ.copy()
245+
env["CHATMAIL_INI"] = str(args.inipath.resolve())
213246
if args.ssh_host:
214247
env["CHATMAIL_SSH"] = args.ssh_host
248+
if args.ssh_config:
249+
env["CHATMAIL_SSH_CONFIG"] = str(Path(args.ssh_config).resolve())
215250

216251
pytest_path = shutil.which("pytest")
217252
pytest_args = [
@@ -276,9 +311,7 @@ def bench_cmd(args, out):
276311

277312
def webdev_cmd(args, out):
278313
"""Run local web development loop for static web pages."""
279-
from .www import main
280-
281-
main()
314+
webdev_main()
282315

283316

284317
#
@@ -321,6 +354,16 @@ def add_ssh_host_option(parser):
321354
)
322355

323356

357+
def add_ssh_config_option(parser):
358+
parser.add_argument(
359+
"--ssh-config",
360+
dest="ssh_config",
361+
type=Path,
362+
default=None,
363+
help="Path to an SSH config file (e.g. lxconfigs/ssh-config).",
364+
)
365+
366+
324367
def add_config_option(parser):
325368
parser.add_argument(
326369
"--config",
@@ -330,6 +373,7 @@ def add_config_option(parser):
330373
type=Path,
331374
help="path to the chatmail.ini file",
332375
)
376+
333377
parser.add_argument(
334378
"--verbose",
335379
"-v",
@@ -340,15 +384,16 @@ def add_config_option(parser):
340384
)
341385

342386

343-
def add_subcommand(subparsers, func):
387+
def add_subcommand(subparsers, func, add_config=True):
344388
name = func.__name__
345389
assert name.endswith("_cmd")
346-
name = name[:-4]
390+
name = name[:-4].replace("_", "-")
347391
doc = func.__doc__.strip()
348392
help = doc.split("\n")[0].strip(".")
349393
p = subparsers.add_parser(name, description=doc, help=help)
350394
p.set_defaults(func=func)
351-
add_config_option(p)
395+
if add_config:
396+
add_config_option(p)
352397
return p
353398

354399

@@ -362,40 +407,43 @@ def get_parser():
362407
"""Return an ArgumentParser for the 'cmdeploy' CLI"""
363408

364409
parser = argparse.ArgumentParser(description=description.strip())
410+
parser.set_defaults(func=None, inipath=None)
365411
subparsers = parser.add_subparsers(title="subcommands")
366412

367413
# find all subcommands in the module namespace
368414
glob = globals()
369415
for name, func in glob.items():
370416
if name.endswith("_cmd"):
371-
subparser = add_subcommand(subparsers, func)
417+
needs_config = not (name.startswith("lxc_") or name.startswith("fmt_"))
418+
subparser = add_subcommand(subparsers, func, add_config=needs_config)
372419
addopts = glob.get(name + "_options")
373420
if addopts is not None:
374421
addopts(subparser)
375422

376423
return parser
377424

378425

379-
def get_sshexec(ssh_host: str, verbose=True):
426+
def get_sshexec(ssh_host: str, verbose=True, ssh_config=None):
380427
if ssh_host in ["localhost", "@local"]:
381428
return LocalExec(verbose, docker=False)
382429
elif ssh_host == "@docker":
383430
return LocalExec(verbose, docker=True)
384431
if verbose:
385432
print(f"[ssh] login to {ssh_host}")
386-
return SSHExec(ssh_host, verbose=verbose)
433+
return SSHExec(ssh_host, verbose=verbose, ssh_config=ssh_config)
387434

388435

389436
def main(args=None):
390437
"""Provide main entry point for 'cmdeploy' CLI invocation."""
391438
parser = get_parser()
392439
args = parser.parse_args(args=args)
393-
if not hasattr(args, "func"):
440+
if args.func is None:
394441
return parser.parse_args(["-h"])
395442

396443
out = Out()
397444
kwargs = {}
398-
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
445+
446+
if args.inipath is not None and args.func.__name__ != "init_cmd":
399447
if not args.inipath.exists():
400448
out.red(f"expecting {args.inipath} to exist, run init first?")
401449
raise SystemExit(1)

cmdeploy/src/cmdeploy/deployers.py

Lines changed: 18 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from pyinfra.operations import apt, files, pip, server, systemd
1919

2020
from cmdeploy.cmdeploy import Out
21+
from cmdeploy.util import get_version_string
2122

2223
from .acmetool import AcmetoolDeployer
2324
from .basedeploy import (
@@ -271,8 +272,14 @@ def configure(self):
271272
logger.warning("Web page build failed, skipping website deployment")
272273
return
273274
# if it is not a hugo page, upload it as is
274-
files.rsync(
275-
f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"]
275+
# pyinfra files.rsync (experimental) causes problems with ssh-config configuration
276+
# the stable files.sync should do
277+
files.sync(
278+
src=str(www_path),
279+
dest="/var/www/html",
280+
user="www-data",
281+
group="www-data",
282+
delete=True,
276283
)
277284

278285

@@ -524,17 +531,9 @@ def activate(self):
524531

525532
class GithashDeployer(Deployer):
526533
def activate(self):
527-
try:
528-
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
529-
except Exception:
530-
git_hash = "unknown\n"
531-
try:
532-
git_diff = subprocess.check_output(["git", "diff"]).decode()
533-
except Exception:
534-
git_diff = ""
535534
files.put(
536535
name="Upload chatmail relay git commit hash",
537-
src=StringIO(git_hash + git_diff),
536+
src=StringIO(get_version_string()),
538537
dest="/etc/chatmail-version",
539538
mode="700",
540539
)
@@ -578,11 +577,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
578577
)
579578

580579
# Check if mtail_address interface is available (if configured)
581-
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
580+
if config.mtail_address and config.mtail_address not in (
581+
"127.0.0.1",
582+
"::1",
583+
"localhost",
584+
):
582585
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
583586
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
584587
if config.mtail_address not in all_addresses:
585-
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
588+
Out().red(
589+
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
590+
)
586591
exit(1)
587592

588593
if not os.environ.get("CHATMAIL_NOPORTCHECK"):

cmdeploy/src/cmdeploy/dns.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,26 @@
11
import datetime
2-
import importlib
3-
4-
from jinja2 import Template
52

63
from . import remote
74

85

6+
def parse_zone_records(text):
7+
"""Yield ``(name, ttl, rtype, rdata)`` from standard BIND-format text.
8+
9+
Skips comment lines (starting with ``;``) and blank lines.
10+
Each record line must have the format ``name TTL IN type rdata``.
11+
"""
12+
for raw_line in text.strip().splitlines():
13+
line = raw_line.strip()
14+
if not line or line.startswith(";"):
15+
continue
16+
parts = line.split(None, 4)
17+
if len(parts) < 5:
18+
raise ValueError(f"Bad zone record line: {line}")
19+
name = parts[0].rstrip(".")
20+
# parts[2] is the IN class — ignored
21+
yield name, parts[1], parts[3].upper(), parts[4]
22+
23+
924
def get_initial_remote_data(sshexec, mail_domain):
1025
return sshexec.logged(
1126
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
@@ -31,13 +46,36 @@ def get_filled_zone_file(remote_data):
3146
if not sts_id:
3247
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
3348

34-
template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
35-
content = template.read_text()
36-
zonefile = Template(content).render(**remote_data)
37-
lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
49+
d = remote_data["mail_domain"]
50+
lines = ["; Required DNS entries"]
51+
if remote_data.get("A"):
52+
lines.append(f"{d}. 3600 IN A {remote_data['A']}")
53+
if remote_data.get("AAAA"):
54+
lines.append(f"{d}. 3600 IN AAAA {remote_data['AAAA']}")
55+
lines.append(f"{d}. 3600 IN MX 10 {d}.")
56+
if remote_data.get("strict_tls"):
57+
lines.append(
58+
f'_mta-sts.{d}. 3600 IN TXT "v=STSv1; id={remote_data["sts_id"]}"'
59+
)
60+
lines.append(f"mta-sts.{d}. 3600 IN CNAME {d}.")
61+
lines.append(f"www.{d}. 3600 IN CNAME {d}.")
62+
lines.append(remote_data["dkim_entry"])
63+
lines.append("")
64+
lines.append("; Recommended DNS entries")
65+
lines.append(f'{d}. 3600 IN TXT "v=spf1 a ~all"')
66+
lines.append(f'_dmarc.{d}. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s"')
67+
if remote_data.get("acme_account_url"):
68+
lines.append(
69+
f'{d}. 3600 IN CAA 0 issue'
70+
f' "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"'
71+
)
72+
lines.append(f'_adsp._domainkey.{d}. 3600 IN TXT "dkim=discardable"')
73+
lines.append(f"_submission._tcp.{d}. 3600 IN SRV 0 1 587 {d}.")
74+
lines.append(f"_submissions._tcp.{d}. 3600 IN SRV 0 1 465 {d}.")
75+
lines.append(f"_imap._tcp.{d}. 3600 IN SRV 0 1 143 {d}.")
76+
lines.append(f"_imaps._tcp.{d}. 3600 IN SRV 0 1 993 {d}.")
3877
lines.append("")
39-
zonefile = "\n".join(lines)
40-
return zonefile
78+
return "\n".join(lines)
4179

4280

4381
def check_full_zone(sshexec, remote_data, out, zonefile) -> int:

0 commit comments

Comments
 (0)