mirror of
https://github.com/chatmail/relay.git
synced 2026-05-15 10:24:40 +00:00
Compare commits
21 Commits
hpk/lxcdep
...
expire-ind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c53b667f | ||
|
|
eb78fcf2e4 | ||
|
|
74326a8c54 | ||
|
|
59e5dea597 | ||
|
|
d7d89d66c1 | ||
|
|
00d723bd6e | ||
|
|
c257bfca4b | ||
|
|
82c9831369 | ||
|
|
b835318ce9 | ||
|
|
b4a46d23e6 | ||
|
|
c6d9d27a84 | ||
|
|
4521f03c99 | ||
|
|
c78859aec6 | ||
|
|
98bd5944cc | ||
|
|
e8933c455f | ||
|
|
d3a483c403 | ||
|
|
e687120d96 | ||
|
|
7409bd3452 | ||
|
|
1a34172487 | ||
|
|
38246ca8ea | ||
|
|
2635ac7e6d |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- name: download filtermail
|
- name: download filtermail
|
||||||
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
||||||
- name: run chatmaild tests
|
- name: run chatmaild tests
|
||||||
working-directory: chatmaild
|
working-directory: chatmaild
|
||||||
run: pipx run tox
|
run: pipx run tox
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
name = "chatmaild"
|
name = "chatmaild"
|
||||||
version = "0.3"
|
version = "0.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiosmtpd",
|
|
||||||
"iniconfig",
|
"iniconfig",
|
||||||
"deltachat-rpc-server",
|
|
||||||
"deltachat-rpc-client",
|
|
||||||
"filelock",
|
"filelock",
|
||||||
"requests",
|
"requests",
|
||||||
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
|
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
|
||||||
@@ -70,6 +67,7 @@ commands =
|
|||||||
deps = pytest
|
deps = pytest
|
||||||
pdbpp
|
pdbpp
|
||||||
pytest-localserver
|
pytest-localserver
|
||||||
|
aiosmtpd
|
||||||
execnet
|
execnet
|
||||||
commands = pytest -v -rsXx {posargs}
|
commands = pytest -v -rsXx {posargs}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class Config:
|
|||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
params.get("filtermail_smtp_port_incoming", "10081")
|
params.get("filtermail_smtp_port_incoming", "10081")
|
||||||
)
|
)
|
||||||
|
self.filtermail_http_port = int(params.get("filtermail_http_port", "10082"))
|
||||||
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
||||||
self.postfix_reinject_port_incoming = int(
|
self.postfix_reinject_port_incoming = int(
|
||||||
params.get("postfix_reinject_port_incoming", "10026")
|
params.get("postfix_reinject_port_incoming", "10026")
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ class Expiry:
|
|||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
self.remove_file(f"{mbox.basedir}/maildirsize")
|
self.remove_file(f"{mbox.basedir}/maildirsize")
|
||||||
|
for file in mbox.extrafiles:
|
||||||
|
if "dovecot.index.cache" in file.path.split("/")[-1]:
|
||||||
|
if file.size > 500 * 1024:
|
||||||
|
self.remove_file(file.path)
|
||||||
|
|
||||||
def get_summary(self):
|
def get_summary(self):
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ dependencies = [
|
|||||||
"pillow",
|
"pillow",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"markdown",
|
"markdown",
|
||||||
"pytest",
|
|
||||||
"setuptools>=68",
|
"setuptools>=68",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"build",
|
"build",
|
||||||
@@ -21,6 +20,7 @@ dependencies = [
|
|||||||
"execnet",
|
"execnet",
|
||||||
"imap_tools",
|
"imap_tools",
|
||||||
"deltachat-rpc-client",
|
"deltachat-rpc-client",
|
||||||
|
"deltachat-rpc-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import importlib.resources
|
import importlib.resources
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from pyinfra.operations import files, server, systemd
|
from pyinfra.operations import files, server, systemd
|
||||||
|
|
||||||
@@ -10,6 +11,28 @@ def has_systemd():
|
|||||||
return os.path.isdir("/run/systemd/system")
|
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__):
|
def get_resource(arg, pkg=__package__):
|
||||||
return importlib.resources.files(pkg).joinpath(arg)
|
return importlib.resources.files(pkg).joinpath(arg)
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}.
|
|
||||||
@@ -111,7 +111,6 @@ def run_cmd(args, out):
|
|||||||
if ssh_host in ["localhost", "@docker"]:
|
if ssh_host in ["localhost", "@docker"]:
|
||||||
if ssh_host == "@docker":
|
if ssh_host == "@docker":
|
||||||
env["CHATMAIL_NOPORTCHECK"] = "True"
|
env["CHATMAIL_NOPORTCHECK"] = "True"
|
||||||
env["CHATMAIL_NOSYSCTL"] = "True"
|
|
||||||
cmd = f"{pyinf} @local {deploy_path} -y"
|
cmd = f"{pyinf} @local {deploy_path} -y"
|
||||||
|
|
||||||
if version.parse(pyinfra.__version__) < version.parse("3"):
|
if version.parse(pyinfra.__version__) < version.parse("3"):
|
||||||
@@ -210,6 +209,7 @@ def test_cmd(args, out):
|
|||||||
"""Run local and online tests for chatmail deployment."""
|
"""Run local and online tests for chatmail deployment."""
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
|
env["CHATMAIL_INI"] = str(args.inipath.absolute())
|
||||||
if args.ssh_host:
|
if args.ssh_host:
|
||||||
env["CHATMAIL_SSH"] = args.ssh_host
|
env["CHATMAIL_SSH"] = args.ssh_host
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .basedeploy import (
|
|||||||
Deployer,
|
Deployer,
|
||||||
Deployment,
|
Deployment,
|
||||||
activate_remote_units,
|
activate_remote_units,
|
||||||
|
blocked_service_startup,
|
||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
get_resource,
|
||||||
has_systemd,
|
has_systemd,
|
||||||
@@ -149,33 +150,16 @@ class UnboundDeployer(Deployer):
|
|||||||
self.need_restart = False
|
self.need_restart = False
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
# Run local DNS resolver `unbound`.
|
# Run local DNS resolver `unbound`. `resolvconf` takes care of
|
||||||
# `resolvconf` takes care of setting up /etc/resolv.conf
|
# setting up /etc/resolv.conf to use 127.0.0.1 as the resolver.
|
||||||
# to use 127.0.0.1 as the resolver.
|
|
||||||
|
|
||||||
#
|
# On an IPv4-only system, if unbound is started but not configured,
|
||||||
# On an IPv4-only system, if unbound is started but not
|
# it causes subsequent steps to fail to resolve hosts.
|
||||||
# configured, it causes subsequent steps to fail to resolve hosts.
|
with blocked_service_startup():
|
||||||
# Here, we use policy-rc.d to prevent unbound from starting up
|
apt.packages(
|
||||||
# on initial install. Later, we will configure it and start it.
|
name="Install unbound",
|
||||||
#
|
packages=["unbound", "unbound-anchor", "dnsutils"],
|
||||||
# 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)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
server.shell(
|
server.shell(
|
||||||
@@ -336,12 +320,12 @@ class TurnDeployer(Deployer):
|
|||||||
def install(self):
|
def install(self):
|
||||||
(url, sha256sum) = {
|
(url, sha256sum) = {
|
||||||
"x86_64": (
|
"x86_64": (
|
||||||
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux",
|
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-x86_64-linux",
|
||||||
"841e527c15fdc2940b0469e206188ea8f0af48533be12ecb8098520f813d41e4",
|
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
|
||||||
),
|
),
|
||||||
"aarch64": (
|
"aarch64": (
|
||||||
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-aarch64-linux",
|
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-aarch64-linux",
|
||||||
"a5fc2d06d937b56a34e098d2cd72a82d3e89967518d159bf246dc69b65e81b42",
|
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
|
||||||
),
|
),
|
||||||
}[host.get_fact(facts.server.Arch)]
|
}[host.get_fact(facts.server.Arch)]
|
||||||
|
|
||||||
@@ -474,8 +458,9 @@ class ChatmailDeployer(Deployer):
|
|||||||
("iroh", None, None),
|
("iroh", None, None),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, mail_domain):
|
def __init__(self, config):
|
||||||
self.mail_domain = mail_domain
|
self.config = config
|
||||||
|
self.mail_domain = config.mail_domain
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
files.put(
|
files.put(
|
||||||
@@ -498,12 +483,18 @@ class ChatmailDeployer(Deployer):
|
|||||||
name="Install rsync",
|
name="Install rsync",
|
||||||
packages=["rsync"],
|
packages=["rsync"],
|
||||||
)
|
)
|
||||||
apt.packages(
|
|
||||||
name="Ensure cron is installed",
|
|
||||||
packages=["cron"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
|
# metadata crashes if the mailboxes dir does not exist
|
||||||
|
files.directory(
|
||||||
|
name="Ensure vmail mailbox directory exists",
|
||||||
|
path=str(self.config.mailboxes_dir),
|
||||||
|
user="vmail",
|
||||||
|
group="vmail",
|
||||||
|
mode="700",
|
||||||
|
present=True,
|
||||||
|
)
|
||||||
|
|
||||||
# This file is used by auth proxy.
|
# This file is used by auth proxy.
|
||||||
# https://wiki.debian.org/EtcMailName
|
# https://wiki.debian.org/EtcMailName
|
||||||
server.shell(
|
server.shell(
|
||||||
@@ -633,7 +624,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
tls_deployer = get_tls_deployer(config, mail_domain)
|
tls_deployer = get_tls_deployer(config, mail_domain)
|
||||||
|
|
||||||
all_deployers = [
|
all_deployers = [
|
||||||
ChatmailDeployer(mail_domain),
|
ChatmailDeployer(config),
|
||||||
LegacyRemoveDeployer(),
|
LegacyRemoveDeployer(),
|
||||||
FiltermailDeployer(),
|
FiltermailDeployer(),
|
||||||
JournaldDeployer(),
|
JournaldDeployer(),
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import importlib
|
|
||||||
|
|
||||||
from jinja2 import Template
|
|
||||||
|
|
||||||
from . import remote
|
from . import remote
|
||||||
|
|
||||||
|
|
||||||
|
def parse_zone_records(text):
|
||||||
|
"""Yield ``(name, ttl, rtype, rdata)`` from standard BIND-format text."""
|
||||||
|
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):
|
def get_initial_remote_data(sshexec, mail_domain):
|
||||||
return sshexec.logged(
|
return sshexec.logged(
|
||||||
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
||||||
@@ -31,13 +42,39 @@ def get_filled_zone_file(remote_data):
|
|||||||
if not sts_id:
|
if not sts_id:
|
||||||
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
|
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
|
||||||
|
|
||||||
template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
|
d = remote_data["mail_domain"]
|
||||||
content = template.read_text()
|
|
||||||
zonefile = Template(content).render(**remote_data)
|
def append_record(name, rtype, rdata, ttl=3600):
|
||||||
lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
|
lines.append(f"{name:<40} {ttl:<6} IN {rtype:<5} {rdata}")
|
||||||
|
|
||||||
|
lines = ["; Required DNS entries"]
|
||||||
|
if remote_data.get("A"):
|
||||||
|
append_record(f"{d}.", "A", remote_data["A"])
|
||||||
|
if remote_data.get("AAAA"):
|
||||||
|
append_record(f"{d}.", "AAAA", remote_data["AAAA"])
|
||||||
|
append_record(f"{d}.", "MX", f"10 {d}.")
|
||||||
|
if remote_data.get("strict_tls"):
|
||||||
|
append_record(f"_mta-sts.{d}.", "TXT", f'"v=STSv1; id={remote_data["sts_id"]}"')
|
||||||
|
append_record(f"mta-sts.{d}.", "CNAME", f"{d}.")
|
||||||
|
append_record(f"www.{d}.", "CNAME", f"{d}.")
|
||||||
|
lines.append(remote_data["dkim_entry"])
|
||||||
lines.append("")
|
lines.append("")
|
||||||
zonefile = "\n".join(lines)
|
lines.append("; Recommended DNS entries")
|
||||||
return zonefile
|
append_record(f"{d}.", "TXT", '"v=spf1 a ~all"')
|
||||||
|
append_record(f"_dmarc.{d}.", "TXT", '"v=DMARC1;p=reject;adkim=s;aspf=s"')
|
||||||
|
if remote_data.get("acme_account_url"):
|
||||||
|
append_record(
|
||||||
|
f"{d}.",
|
||||||
|
"CAA",
|
||||||
|
f'0 issue "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"',
|
||||||
|
)
|
||||||
|
append_record(f"_adsp._domainkey.{d}.", "TXT", '"dkim=discardable"')
|
||||||
|
append_record(f"_submission._tcp.{d}.", "SRV", f"0 1 587 {d}.")
|
||||||
|
append_record(f"_submissions._tcp.{d}.", "SRV", f"0 1 465 {d}.")
|
||||||
|
append_record(f"_imap._tcp.{d}.", "SRV", f"0 1 143 {d}.")
|
||||||
|
append_record(f"_imaps._tcp.{d}.", "SRV", f"0 1 993 {d}.")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
|
def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
|
||||||
@@ -58,7 +95,8 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
|
|||||||
returncode = 1
|
returncode = 1
|
||||||
if remote_data.get("dkim_entry") in required_diff:
|
if remote_data.get("dkim_entry") in required_diff:
|
||||||
out(
|
out(
|
||||||
"If the DKIM entry above does not work with your DNS provider, you can try this one:\n"
|
"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(remote_data.get("web_dkim_entry") + "\n")
|
||||||
if recommended_diff:
|
if recommended_diff:
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
import os
|
import io
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from chatmaild.config import Config
|
from chatmaild.config import Config
|
||||||
from pyinfra import host
|
from pyinfra import host
|
||||||
from pyinfra.facts.server import Arch, Sysctl
|
from pyinfra.facts.deb import DebPackages
|
||||||
from pyinfra.facts.systemd import SystemdEnabled
|
from pyinfra.facts.server import Arch, Command, Sysctl
|
||||||
from pyinfra.operations import apt, files, server, systemd
|
from pyinfra.operations import apt, files, server, systemd
|
||||||
|
|
||||||
from cmdeploy.basedeploy import (
|
from cmdeploy.basedeploy import (
|
||||||
Deployer,
|
Deployer,
|
||||||
activate_remote_units,
|
activate_remote_units,
|
||||||
|
blocked_service_startup,
|
||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
get_resource,
|
||||||
has_systemd,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DOVECOT_VERSION = "2.3.21+dfsg1-3"
|
||||||
|
|
||||||
|
DOVECOT_SHA256 = {
|
||||||
|
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
|
||||||
|
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
|
||||||
|
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
|
||||||
|
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
|
||||||
|
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
|
||||||
|
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DovecotDeployer(Deployer):
|
class DovecotDeployer(Deployer):
|
||||||
daemon_reload = False
|
daemon_reload = False
|
||||||
@@ -26,11 +37,31 @@ class DovecotDeployer(Deployer):
|
|||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
arch = host.get_fact(Arch)
|
arch = host.get_fact(Arch)
|
||||||
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
|
with blocked_service_startup():
|
||||||
return # already installed and running
|
debs = []
|
||||||
_install_dovecot_package("core", arch)
|
for pkg in ("core", "imapd", "lmtpd"):
|
||||||
_install_dovecot_package("imapd", arch)
|
deb = _download_dovecot_package(pkg, arch)
|
||||||
_install_dovecot_package("lmtpd", arch)
|
if deb:
|
||||||
|
debs.append(deb)
|
||||||
|
if debs:
|
||||||
|
deb_list = " ".join(debs)
|
||||||
|
server.shell(
|
||||||
|
name="Install dovecot packages",
|
||||||
|
commands=[
|
||||||
|
f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true",
|
||||||
|
"DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install",
|
||||||
|
f"dpkg --force-confdef --force-confold -i {deb_list}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
files.put(
|
||||||
|
name="Pin dovecot packages to block Debian dist-upgrades",
|
||||||
|
src=io.StringIO(
|
||||||
|
"Package: dovecot-*\n"
|
||||||
|
"Pin: version *\n"
|
||||||
|
"Pin-Priority: -1\n"
|
||||||
|
),
|
||||||
|
dest="/etc/apt/preferences.d/pin-dovecot",
|
||||||
|
)
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
configure_remote_units(self.config.mail_domain, self.units)
|
configure_remote_units(self.config.mail_domain, self.units)
|
||||||
@@ -63,43 +94,51 @@ def _pick_url(primary, fallback):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def _install_dovecot_package(package: str, arch: str):
|
def _download_dovecot_package(package: str, arch: str):
|
||||||
|
"""Download a dovecot .deb if needed, return its path (or None)."""
|
||||||
arch = "amd64" if arch == "x86_64" else arch
|
arch = "amd64" if arch == "x86_64" else arch
|
||||||
arch = "arm64" if arch == "aarch64" else arch
|
arch = "arm64" if arch == "aarch64" else arch
|
||||||
primary_url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
|
|
||||||
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F2.3.21%2Bdfsg1/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
|
|
||||||
url = _pick_url(primary_url, fallback_url)
|
|
||||||
deb_filename = "/root/" + url.split("/")[-1]
|
|
||||||
|
|
||||||
match (package, arch):
|
pkg_name = f"dovecot-{package}"
|
||||||
case ("core", "amd64"):
|
sha256 = DOVECOT_SHA256.get((package, arch))
|
||||||
sha256 = "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d"
|
if sha256 is None:
|
||||||
case ("core", "arm64"):
|
apt.packages(packages=[pkg_name])
|
||||||
sha256 = "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9"
|
return None
|
||||||
case ("imapd", "amd64"):
|
|
||||||
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
|
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
|
||||||
case ("imapd", "arm64"):
|
if DOVECOT_VERSION in installed_versions:
|
||||||
sha256 = "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f"
|
return None
|
||||||
case ("lmtpd", "amd64"):
|
|
||||||
sha256 = "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab"
|
url_version = DOVECOT_VERSION.replace("+", "%2B")
|
||||||
case ("lmtpd", "arm64"):
|
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
|
||||||
sha256 = "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f"
|
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
|
||||||
case _:
|
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
|
||||||
apt.packages(packages=[f"dovecot-{package}"])
|
url = _pick_url(primary_url, fallback_url)
|
||||||
return
|
deb_filename = f"/root/{deb_base}"
|
||||||
|
|
||||||
files.download(
|
files.download(
|
||||||
name=f"Download dovecot-{package}",
|
name=f"Download {pkg_name}",
|
||||||
src=url,
|
src=url,
|
||||||
dest=deb_filename,
|
dest=deb_filename,
|
||||||
sha256sum=sha256,
|
sha256sum=sha256,
|
||||||
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
|
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
|
||||||
)
|
)
|
||||||
|
|
||||||
apt.deb(name=f"Install dovecot-{package}", src=deb_filename)
|
return deb_filename
|
||||||
|
|
||||||
|
|
||||||
def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
|
def _can_set_inotify_limits() -> bool:
|
||||||
|
is_container = (
|
||||||
|
host.get_fact(
|
||||||
|
Command,
|
||||||
|
"systemd-detect-virt --container --quiet 2>/dev/null && echo yes || true",
|
||||||
|
)
|
||||||
|
== "yes"
|
||||||
|
)
|
||||||
|
return not is_container
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]:
|
||||||
"""Configures Dovecot IMAP server."""
|
"""Configures Dovecot IMAP server."""
|
||||||
need_restart = False
|
need_restart = False
|
||||||
daemon_reload = False
|
daemon_reload = False
|
||||||
@@ -134,19 +173,25 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
|
|||||||
|
|
||||||
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
||||||
# it is recommended to set the following inotify limits
|
# it is recommended to set the following inotify limits
|
||||||
if not os.environ.get("CHATMAIL_NOSYSCTL"):
|
can_modify = _can_set_inotify_limits()
|
||||||
for name in ("max_user_instances", "max_user_watches"):
|
for name in ("max_user_instances", "max_user_watches"):
|
||||||
key = f"fs.inotify.{name}"
|
key = f"fs.inotify.{name}"
|
||||||
if host.get_fact(Sysctl)[key] > 65535:
|
value = host.get_fact(Sysctl)[key]
|
||||||
# Skip updating limits if already sufficient
|
if value > 65534:
|
||||||
# (enables running in incus containers where sysctl readonly)
|
continue
|
||||||
continue
|
if not can_modify:
|
||||||
server.sysctl(
|
print(
|
||||||
name=f"Change {key}",
|
"\n!!!! refusing to attempt sysctl setting in containers\n"
|
||||||
key=key,
|
f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n"
|
||||||
value=65535,
|
"!!!!"
|
||||||
persist=True,
|
|
||||||
)
|
)
|
||||||
|
continue
|
||||||
|
server.sysctl(
|
||||||
|
name=f"Change {key}",
|
||||||
|
key=key,
|
||||||
|
value=65535,
|
||||||
|
persist=True,
|
||||||
|
)
|
||||||
|
|
||||||
timezone_env = files.line(
|
timezone_env = files.line(
|
||||||
name="Set TZ environment variable",
|
name="Set TZ environment variable",
|
||||||
|
|||||||
@@ -70,12 +70,6 @@ userdb {
|
|||||||
# Mailboxes are stored in the "mail" directory of the vmail user home.
|
# Mailboxes are stored in the "mail" directory of the vmail user home.
|
||||||
mail_location = maildir:{{ config.mailboxes_dir }}/%u
|
mail_location = maildir:{{ config.mailboxes_dir }}/%u
|
||||||
|
|
||||||
# index/cache files are not very useful for chatmail relay operations
|
|
||||||
# but it's not clear how to disable them completely.
|
|
||||||
# According to https://doc.dovecot.org/2.3/settings/advanced/#core_setting-mail_cache_max_size
|
|
||||||
# if the cache file becomes larger than the specified size, it is truncated by dovecot
|
|
||||||
mail_cache_max_size = 500K
|
|
||||||
|
|
||||||
namespace inbox {
|
namespace inbox {
|
||||||
inbox = yes
|
inbox = yes
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
|
|||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
arch = host.get_fact(facts.server.Arch)
|
arch = host.get_fact(facts.server.Arch)
|
||||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.0/filtermail-{arch}"
|
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-{arch}"
|
||||||
sha256sum = {
|
sha256sum = {
|
||||||
"x86_64": "3fd8b18282252c75a5bbfa603d8c1b65f6563e5e920bddf3e64e451b7cdb43ce",
|
"x86_64": "48b3fb80c092d00b9b0a0ef77a8673496da3b9aed5ec1851e1df936d5589d62f",
|
||||||
"aarch64": "2bd191de205f7fd60158dd8e3516ab7e3efb14627696f3d7dc186bdcd9e10a43",
|
"aarch64": "c65bd5f45df187d3d65d6965a285583a3be0f44a6916ff12909ff9a8d702c22e",
|
||||||
}[arch]
|
}[arch]
|
||||||
self.need_restart |= files.download(
|
self.need_restart |= files.download(
|
||||||
name="Download filtermail",
|
name="Download filtermail",
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ http {
|
|||||||
|
|
||||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||||
|
|
||||||
|
location /mxdeliv/ {
|
||||||
|
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port }};
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
# First attempt to serve request as file, then
|
# First attempt to serve request as file, then
|
||||||
# as directory, then fall back to displaying a 404.
|
# as directory, then fall back to displaying a 404.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ smtpd_tls_key_file={{ config.tls_key_path }}
|
|||||||
smtpd_tls_security_level=may
|
smtpd_tls_security_level=may
|
||||||
|
|
||||||
smtp_tls_CApath=/etc/ssl/certs
|
smtp_tls_CApath=/etc/ssl/certs
|
||||||
smtp_tls_security_level={{ "verify" if config.tls_cert_mode == "acme" else "encrypt" }}
|
smtp_tls_security_level=verify
|
||||||
# Send SNI extension when connecting to other servers.
|
# Send SNI extension when connecting to other servers.
|
||||||
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
|
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
|
||||||
smtp_tls_servername = hostname
|
smtp_tls_servername = hostname
|
||||||
@@ -88,6 +88,22 @@ inet_protocols = ipv4
|
|||||||
inet_protocols = all
|
inet_protocols = all
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
# Postfix does not try IPv4 and IPv6 connections
|
||||||
|
# concurrently as of version 3.7.11.
|
||||||
|
#
|
||||||
|
# When relay has both A (IPv4) and AAAA (IPv6) records,
|
||||||
|
# but broken IPv6 connectivity,
|
||||||
|
# every second message is delayed by the connection timeout
|
||||||
|
# <https://www.postfix.org/postconf.5.html#smtp_connect_timeout>
|
||||||
|
# which defaults to 30 seconds. Reducing timeouts is not a solution
|
||||||
|
# as this will result in a failure to connect to slow servers.
|
||||||
|
#
|
||||||
|
# As a workaround we always prefer IPv4 when it is available.
|
||||||
|
#
|
||||||
|
# The setting is documented at
|
||||||
|
# <https://www.postfix.org/postconf.5.html#smtp_address_preference>
|
||||||
|
smtp_address_preference=ipv4
|
||||||
|
|
||||||
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
||||||
virtual_mailbox_domains = {{ config.mail_domain }}
|
virtual_mailbox_domains = {{ config.mail_domain }}
|
||||||
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
|
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
|
||||||
|
|||||||
@@ -57,9 +57,10 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
|
|||||||
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
|
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
|
||||||
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
|
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
|
||||||
web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw))
|
web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw))
|
||||||
|
name = f"{dkim_selector}._domainkey.{mail_domain}."
|
||||||
return (
|
return (
|
||||||
f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"',
|
f'{name:<40} 3600 IN TXT "{dkim_value}"',
|
||||||
f'{dkim_selector}._domainkey.{mail_domain}. TXT "{web_dkim_value}"',
|
f'{name:<40} 3600 IN TXT "{web_dkim_value}"',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -94,7 +95,7 @@ def check_zonefile(zonefile, verbose=True):
|
|||||||
if not zf_line.strip() or zf_line.startswith(";"):
|
if not zf_line.strip() or zf_line.startswith(";"):
|
||||||
continue
|
continue
|
||||||
print(f"dns-checking {zf_line!r}") if verbose else log_progress("")
|
print(f"dns-checking {zf_line!r}") if verbose else log_progress("")
|
||||||
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
|
zf_domain, _ttl, _in, zf_typ, zf_value = zf_line.split(None, 4)
|
||||||
zf_domain = zf_domain.rstrip(".")
|
zf_domain = zf_domain.rstrip(".")
|
||||||
zf_value = zf_value.strip()
|
zf_value = zf_value.strip()
|
||||||
query_value = query_dns(zf_typ, zf_domain)
|
query_value = query_dns(zf_typ, zf_domain)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
|
|||||||
"-keyout", str(key_path),
|
"-keyout", str(key_path),
|
||||||
"-out", str(cert_path),
|
"-out", str(cert_path),
|
||||||
"-subj", f"/CN={domain}",
|
"-subj", f"/CN={domain}",
|
||||||
|
# Mark as end-entity cert so it cannot be used as a CA to sign others.
|
||||||
|
"-addext", "basicConstraints=critical,CA:FALSE",
|
||||||
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
|
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
|
||||||
"-addext",
|
"-addext",
|
||||||
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
|
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
; Required DNS entries for chatmail servers
|
; Required DNS entries
|
||||||
zftest.testrun.org. A 135.181.204.127
|
zftest.testrun.org. 3600 IN A 135.181.204.127
|
||||||
zftest.testrun.org. AAAA 2a01:4f9:c012:52f4::1
|
zftest.testrun.org. 3600 IN AAAA 2a01:4f9:c012:52f4::1
|
||||||
zftest.testrun.org. MX 10 zftest.testrun.org.
|
zftest.testrun.org. 3600 IN MX 10 zftest.testrun.org.
|
||||||
_mta-sts.zftest.testrun.org. TXT "v=STSv1; id=202403211706"
|
_mta-sts.zftest.testrun.org. 3600 IN TXT "v=STSv1; id=202403211706"
|
||||||
mta-sts.zftest.testrun.org. CNAME zftest.testrun.org.
|
mta-sts.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org.
|
||||||
www.zftest.testrun.org. CNAME zftest.testrun.org.
|
www.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org.
|
||||||
opendkim._domainkey.zftest.testrun.org. TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s"
|
opendkim._domainkey.zftest.testrun.org. 3600 IN TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s"
|
||||||
|
|
||||||
; Recommended DNS entries
|
; Recommended DNS entries
|
||||||
_submission._tcp.zftest.testrun.org. SRV 0 1 587 zftest.testrun.org.
|
zftest.testrun.org. 3600 IN TXT "v=spf1 a ~all"
|
||||||
_submissions._tcp.zftest.testrun.org. SRV 0 1 465 zftest.testrun.org.
|
_dmarc.zftest.testrun.org. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
||||||
_imap._tcp.zftest.testrun.org. SRV 0 1 143 zftest.testrun.org.
|
zftest.testrun.org. 3600 IN CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
|
||||||
_imaps._tcp.zftest.testrun.org. SRV 0 1 993 zftest.testrun.org.
|
_adsp._domainkey.zftest.testrun.org. 3600 IN TXT "dkim=discardable"
|
||||||
zftest.testrun.org. CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
|
_submission._tcp.zftest.testrun.org. 3600 IN SRV 0 1 587 zftest.testrun.org.
|
||||||
zftest.testrun.org. TXT "v=spf1 a:zftest.testrun.org ~all"
|
_submissions._tcp.zftest.testrun.org. 3600 IN SRV 0 1 465 zftest.testrun.org.
|
||||||
_dmarc.zftest.testrun.org. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
_imap._tcp.zftest.testrun.org. 3600 IN SRV 0 1 143 zftest.testrun.org.
|
||||||
_adsp._domainkey.zftest.testrun.org. TXT "dkim=discardable"
|
_imaps._tcp.zftest.testrun.org. 3600 IN SRV 0 1 993 zftest.testrun.org.
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ def pytest_runtest_setup(item):
|
|||||||
|
|
||||||
|
|
||||||
def _get_chatmail_config():
|
def _get_chatmail_config():
|
||||||
|
inipath = os.environ.get("CHATMAIL_INI")
|
||||||
|
if inipath:
|
||||||
|
path = Path(inipath).resolve()
|
||||||
|
return read_config(path), path
|
||||||
|
|
||||||
current = Path().resolve()
|
current = Path().resolve()
|
||||||
while 1:
|
while 1:
|
||||||
path = current.joinpath("chatmail.ini").resolve()
|
path = current.joinpath("chatmail.ini").resolve()
|
||||||
@@ -388,12 +393,15 @@ def cmfactory(rpc, gencreds, maildomain, chatmail_config):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def remote(sshdomain):
|
def remote(sshdomain):
|
||||||
return Remote(sshdomain)
|
r = Remote(sshdomain)
|
||||||
|
yield r
|
||||||
|
r.close()
|
||||||
|
|
||||||
|
|
||||||
class Remote:
|
class Remote:
|
||||||
def __init__(self, sshdomain):
|
def __init__(self, sshdomain):
|
||||||
self.sshdomain = sshdomain
|
self.sshdomain = sshdomain
|
||||||
|
self._procs = []
|
||||||
|
|
||||||
def iter_output(self, logcmd="", ready=None):
|
def iter_output(self, logcmd="", ready=None):
|
||||||
getjournal = "journalctl -f" if not logcmd else logcmd
|
getjournal = "journalctl -f" if not logcmd else logcmd
|
||||||
@@ -403,19 +411,32 @@ class Remote:
|
|||||||
case "localhost": command = []
|
case "localhost": command = []
|
||||||
case _: command = ["ssh", f"root@{self.sshdomain}"]
|
case _: command = ["ssh", f"root@{self.sshdomain}"]
|
||||||
[command.append(arg) for arg in getjournal.split()]
|
[command.append(arg) for arg in getjournal.split()]
|
||||||
self.popen = subprocess.Popen(
|
popen = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
while 1:
|
self._procs.append(popen)
|
||||||
line = self.popen.stdout.readline()
|
try:
|
||||||
res = line.decode().strip().lower()
|
while 1:
|
||||||
if not res:
|
line = popen.stdout.readline()
|
||||||
break
|
res = line.decode().strip().lower()
|
||||||
if ready is not None:
|
if not res:
|
||||||
ready()
|
break
|
||||||
ready = None
|
if ready is not None:
|
||||||
yield res
|
ready()
|
||||||
|
ready = None
|
||||||
|
yield res
|
||||||
|
finally:
|
||||||
|
popen.terminate()
|
||||||
|
popen.wait()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
while self._procs:
|
||||||
|
proc = self._procs.pop()
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -23,15 +23,19 @@ class TestCmdline:
|
|||||||
run = parser.parse_args(["run"])
|
run = parser.parse_args(["run"])
|
||||||
assert init and run
|
assert init and run
|
||||||
|
|
||||||
def test_init_not_overwrite(self, capsys):
|
def test_init_not_overwrite(self, capsys, tmp_path, monkeypatch):
|
||||||
assert main(["init", "chat.example.org"]) == 0
|
monkeypatch.delenv("CHATMAIL_INI", raising=False)
|
||||||
|
inipath = tmp_path / "chatmail.ini"
|
||||||
|
args = ["init", "--config", str(inipath), "chat.example.org"]
|
||||||
|
assert main(args) == 0
|
||||||
capsys.readouterr()
|
capsys.readouterr()
|
||||||
|
|
||||||
assert main(["init", "chat.example.org"]) == 1
|
assert main(args) == 1
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "path exists" in out.lower()
|
assert "path exists" in out.lower()
|
||||||
|
|
||||||
assert main(["init", "chat.example.org", "--force"]) == 0
|
args.insert(1, "--force")
|
||||||
|
assert main(args) == 0
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "deleting config file" in out.lower()
|
assert "deleting config file" in out.lower()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from copy import deepcopy
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cmdeploy import remote
|
from cmdeploy import remote
|
||||||
from cmdeploy.dns import check_full_zone, check_initial_remote_data
|
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -125,18 +125,49 @@ class TestPerformInitialChecks:
|
|||||||
assert not l
|
assert not l
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_zone_records():
|
||||||
|
text = """
|
||||||
|
; This is a comment
|
||||||
|
some.domain. 3600 IN A 1.1.1.1
|
||||||
|
|
||||||
|
; Another comment
|
||||||
|
www.some.domain. 3600 IN CNAME some.domain.
|
||||||
|
|
||||||
|
; Multi-word rdata
|
||||||
|
some.domain. 3600 IN MX 10 mail.some.domain.
|
||||||
|
|
||||||
|
; DKIM record (single line, multi-word TXT rdata)
|
||||||
|
dkim._domainkey.some.domain. 3600 IN TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG" "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
|
||||||
|
|
||||||
|
; Another TXT record
|
||||||
|
_dmarc.some.domain. 3600 IN TXT "v=DMARC1;p=reject"
|
||||||
|
"""
|
||||||
|
records = list(parse_zone_records(text))
|
||||||
|
assert records == [
|
||||||
|
("some.domain", "3600", "A", "1.1.1.1"),
|
||||||
|
("www.some.domain", "3600", "CNAME", "some.domain."),
|
||||||
|
("some.domain", "3600", "MX", "10 mail.some.domain."),
|
||||||
|
(
|
||||||
|
"dkim._domainkey.some.domain",
|
||||||
|
"3600",
|
||||||
|
"TXT",
|
||||||
|
'"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG" "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"',
|
||||||
|
),
|
||||||
|
("_dmarc.some.domain", "3600", "TXT", '"v=DMARC1;p=reject"'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_zone_records_invalid_line():
|
||||||
|
text = "invalid line"
|
||||||
|
with pytest.raises(ValueError, match="Bad zone record line"):
|
||||||
|
list(parse_zone_records(text))
|
||||||
|
|
||||||
|
|
||||||
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
|
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
|
||||||
for zf_line in zonefile.split("\n"):
|
if only_required:
|
||||||
if zf_line.startswith("#"):
|
zonefile = zonefile.split("; Recommended")[0]
|
||||||
if "Recommended" in zf_line and only_required:
|
for name, ttl, rtype, rdata in parse_zone_records(zonefile):
|
||||||
return
|
mockdns_base.setdefault(rtype, {})[name] = rdata
|
||||||
continue
|
|
||||||
if not zf_line.strip():
|
|
||||||
continue
|
|
||||||
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
|
|
||||||
zf_domain = zf_domain.rstrip(".")
|
|
||||||
zf_value = zf_value.strip()
|
|
||||||
mockdns_base.setdefault(zf_typ, {})[zf_domain] = zf_value
|
|
||||||
|
|
||||||
|
|
||||||
class MockSSHExec:
|
class MockSSHExec:
|
||||||
|
|||||||
Reference in New Issue
Block a user