Compare commits

..

29 Commits

Author SHA1 Message Date
missytake
fcdef806d7 ci: deploy with ssh_host=localhost on staging-ipv4 2026-02-18 01:54:15 +01:00
missytake
e024d837dc tests: make tests work with --ssh-host localhost 2026-02-18 01:54:15 +01:00
missytake
6981331466 docs: webdev needs to be exposed via nginx if run on the relay 2026-02-18 01:54:15 +01:00
missytake
4d8aacae6b docs: cmdeploy dns + test are kind of necessary 2026-02-18 01:54:15 +01:00
missytake
a6ddb4fe92 docs: remove mentions of the build machine / deployment server separation 2026-02-18 01:53:56 +01:00
missytake
2042c94bb6 cmdeploy: add ssh_host chatmail.ini option to deploy remotely 2026-02-18 01:51:29 +01:00
missytake
6d28cf0f15 config: load default values from Config(), not chatmail.ini.f 2026-02-18 00:41:36 +01:00
Mark Felder
36eb63faa1 feat: Strip Received headers before delivery 2026-02-17 21:16:11 +01:00
Jagoda Estera Ślązak
91df11015e chore(deps): upgrade to filtermail v0.3 (#850)
## 0.3.0 - 2026-02-14

### Features

- Support legacy, pre-OpenPGP packet format

### Miscellaneous Tasks

- *(dist)* Switch to musl targets

### Refactor

- Remove unnecessary Arc
- Use a custom, minimal SMTP client instead of lettre

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-14 18:02:05 +01:00
link2xt
d4f8a29243 docs: fix link to Maddy and update madmail URL 2026-02-13 09:49:29 +00:00
missytake
0144fc3ea8 postfix: only look for square brackets, they are only allowed for address literals 2026-02-12 10:45:15 +01:00
missytake
e7ce6679b9 postfix: IPv6 literals have a prefix 2026-02-12 10:45:15 +01:00
missytake
d1adf52f89 postfix: also accept self-signed for IPv6-only 2026-02-12 10:45:15 +01:00
missytake
56d0e2ca27 postfix: be more exact with nauta.cu 2026-02-12 10:45:15 +01:00
missytake
2613558db6 postfix uses POSIX EREs, not PCRE, so some stuff doesn't work 2026-02-12 10:45:15 +01:00
missytake
6843fcb1a0 postfix: fix tls policy regexp map 2026-02-12 10:45:15 +01:00
missytake
ff54ad88d8 postfix: use regexp to match IPv4 addresses 2026-02-12 10:45:15 +01:00
missytake
cce2b27ae7 postfix: accept self-signed certificates for IP-only relays 2026-02-12 10:45:15 +01:00
j4n
87022e3681 fix(cmdeploy): check if dns_check_disabled before trying to warn about LE
If --skip-dns-check is used and retcode != 0, remote_data is undefined.
2026-02-11 12:13:24 +01:00
j4n
06560dd071 feat(postfix): bind to mail_domain's A/AAAA addresses for outbound mail
Carry forward A/AAAA address from the DNS check to the postfix deploy
stage and set accordingly in main.cf.
2026-02-11 12:13:24 +01:00
j4n
1b0337a5f7 fix(cmdeploy): port check: check addresses, fix single services
Ensure that the interface for mtail_address is available and fix a bug
in port checking where single services were always passing regardless of
the specified service name.
2026-02-11 09:36:04 +01:00
373[Ø]™
dfcaf415b1 Merge pull request #834 from chatmail/373/fix-dns-resolver-injection
fix: remediates issue with improper concat on resolver injection
2026-01-30 23:36:46 +00:00
ccclxxiii
c0718325ef fix: simplify resolver fix 2026-01-30 22:17:53 +00:00
ccclxxiii
7d72b0e592 fix:[wip] fix concact issue which causes dns failure 2026-01-30 21:10:19 +00:00
373[Ø]™
8f1e23d98e Merge pull request #832 from chatmail/373/respect-ipv4-ipv6-boolean-config
remediates ipv6 boolean not being respected during operations
2026-01-30 17:53:36 +00:00
ccclxxiii
56aaf2649b chore: fixes bug in dovecot template 2026-01-30 15:52:32 +00:00
ccclxxiii
2660b4d24c feat: updates postfix for ipv4/v6 2026-01-30 15:27:02 +00:00
ccclxxiii
ea60ecfb57 feat: updates deployers for ipv4/v6 bool 2026-01-30 15:26:45 +00:00
ccclxxiii
2a3a224cc2 feat: adds template for unbound v4/v6 2026-01-30 15:24:26 +00:00
36 changed files with 487 additions and 336 deletions

View File

@@ -15,7 +15,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.2.0/filtermail-x86_64-musl -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests
working-directory: chatmaild
run: pipx run tox

View File

@@ -71,26 +71,35 @@ jobs:
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: |
cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- name: setup dependencies
run: |
ssh root@staging-ipv4.testrun.org apt update
ssh root@staging-ipv4.testrun.org apt install -y git python3.11-venv python3-dev gcc
ssh root@staging-ipv4.testrun.org git clone https://github.com/chatmail/relay
ssh root@staging-ipv4.testrun.org "cd relay && git checkout " ${{ github.head_ref }}
ssh root@staging-ipv4.testrun.org "cd relay && scripts/initenv.sh"
- run: cmdeploy run --verbose --skip-dns-check
- name: initialize config
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy init staging-ipv4.testrun.org"
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check"
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown dkim-milter:dkim-milter -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
ssh root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone"
ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow"
- name: cmdeploy dns
run: cmdeploy dns -v
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v"

View File

@@ -76,13 +76,14 @@ jobs:
- run: |
cmdeploy init staging2.testrun.org
sed -i 's/^ssh_host/#ssh_host/' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown dkim-milter:dkim-milter -R /etc/dkimkeys
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone --verbose
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone

View File

@@ -1,3 +1,4 @@
import os
from pathlib import Path
import iniconfig
@@ -8,30 +9,28 @@ from chatmaild.user import User
def read_config(inipath):
assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath)
params = cfg.sections["params"]
default_config_content = get_default_config_content(params["mail_domain"])
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
return Config(inipath, params=cfg.sections["params"])
class Config:
def __init__(self, inipath, params):
self._inipath = inipath
self.mail_domain = params["mail_domain"]
self.ssh_host = params.get("ssh_host", self.mail_domain)
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params["max_mailbox_size"]
self.max_message_size = int(params.get("max_message_size", "31457280"))
self.delete_mails_after = params["delete_mails_after"]
self.delete_large_after = params["delete_large_after"]
self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.max_mailbox_size = params.get("max_mailbox_size", "500M")
self.max_message_size = int(params.get("max_message_size", 31457280))
self.delete_mails_after = params.get("delete_mails_after", "20")
self.delete_large_after = params.get("delete_large_after", "7")
self.delete_inactive_users_after = int(
params.get("delete_inactive_users_after", 100)
)
self.username_min_length = int(params.get("username_min_length", 9))
self.username_max_length = int(params.get("username_max_length", 9))
self.password_min_length = int(params.get("password_min_length", 9))
self.passthrough_senders = params.get("passthrough_senders", "").split()
self.passthrough_recipients = params.get("passthrough_recipients", "").split()
self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int(
@@ -43,6 +42,8 @@ class Config:
)
self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true"

View File

@@ -3,6 +3,9 @@
# mail domain (MUST be set to fully qualified chat mail domain)
mail_domain = {mail_domain}
# Where to deploy the relay - if unspecified, mail_domain will be used.
ssh_host = localhost
#
# If you only do private test deploys, you don't need to modify any settings below
#

View File

@@ -88,7 +88,7 @@ def run_cmd_options(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
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay
if not args.dns_check_disabled:
@@ -101,11 +101,14 @@ def run_cmd(args, out):
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else ""
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
if not args.dns_check_disabled:
env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or ""
env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or ""
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"
if ssh_host in ["localhost", "@docker"]:
if ssh_host in ["localhost", "@local", "@docker"]:
cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -121,7 +124,7 @@ def run_cmd(args, out):
out.red("Website deployment failed.")
elif retcode == 0:
out.green("Deploy completed, call `cmdeploy dns` next.")
elif not remote_data["acme_account_url"]:
elif not args.dns_check_disabled and not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again")
retcode = 0
@@ -146,7 +149,7 @@ def dns_cmd_options(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
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not remote_data:
@@ -180,7 +183,7 @@ def status_cmd_options(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
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}")
@@ -200,6 +203,7 @@ def test_cmd_options(parser):
action="store_true",
help="also run slow tests",
)
add_ssh_host_option(parser)
def test_cmd(args, out):
@@ -211,6 +215,9 @@ def test_cmd(args, out):
x = importlib.util.find_spec("deltachat")
if x is None:
out.check_call(f"{sys.executable} -m pip install deltachat")
env = os.environ.copy()
if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host
pytest_path = shutil.which("pytest")
pytest_args = [
@@ -224,7 +231,7 @@ def test_cmd(args, out):
]
if args.slow:
pytest_args.append("--slow")
ret = out.run_ret(pytest_args)
ret = out.run_ret(pytest_args, env=env)
return ret

View File

@@ -10,6 +10,7 @@ from pathlib import Path
from chatmaild.config import read_config
from pyinfra import facts, host, logger
from pyinfra.facts import hardware
from pyinfra.api import FactBase
from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled
@@ -25,18 +26,18 @@ from .basedeploy import (
configure_remote_units,
get_resource,
)
from .dkim_milter.deployer import DkimMilterDeployer
from .dovecot.deployer import DovecotDeployer
from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer
from .postfix.deployer import PostfixDeployer
from .www import build_webpages, find_merge_conflict, get_paths
class Port(FactBase):
"""
Returns the process occuping a port.
Returns the process occupying a port.
"""
def command(self, port: int) -> str:
@@ -141,6 +142,10 @@ def _configure_remote_venv_with_chatmaild(config) -> None:
class UnboundDeployer(Deployer):
def __init__(self, config):
self.config = config
self.need_restart = False
def install(self):
# Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf
@@ -177,6 +182,27 @@ class UnboundDeployer(Deployer):
"unbound-anchor -a /var/lib/unbound/root.key || true",
],
)
if self.config.disable_ipv6:
files.directory(
path="/etc/unbound/unbound.conf.d",
present=True,
user="root",
group="root",
mode="755",
)
conf = files.put(
src=get_resource("unbound/unbound.conf.j2"),
dest="/etc/unbound/unbound.conf.d/chatmail.conf",
user="root",
group="root",
mode="644",
)
else:
conf = files.file(
path="/etc/unbound/unbound.conf.d/chatmail.conf",
present=False,
)
self.need_restart |= conf.changed
def activate(self):
server.shell(
@@ -191,6 +217,7 @@ class UnboundDeployer(Deployer):
service="unbound.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
@@ -527,9 +554,18 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
files.line(
name="Add 9.9.9.9 to resolv.conf",
path="/etc/resolv.conf",
line="nameserver 9.9.9.9",
# Guard against resolv.conf missing a trailing newline (SolusVM bug).
line="\nnameserver 9.9.9.9",
)
# 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'):
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")
exit(1)
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
@@ -541,7 +577,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
("dovecot-stats", 3904),
("stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
@@ -551,8 +587,9 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service:
if running_service not in service:
if running_service not in services:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
@@ -565,14 +602,14 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
LegacyRemoveDeployer(),
FiltermailDeployer(),
JournaldDeployer(),
UnboundDeployer(),
UnboundDeployer(config),
TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay),
AcmetoolDeployer(config.acme_email, tls_domains),
WebsiteDeployer(config),
ChatmailVenvDeployer(config),
MtastsDeployer(),
DkimMilterDeployer(mail_domain),
OpendkimDeployer(mail_domain),
# Dovecot should be started before Postfix
# because it creates authentication socket
# required by Postfix.

View File

@@ -1,169 +0,0 @@
"""
Installs DKIM Milter.
"""
from pyinfra import facts, host
from pyinfra.facts.files import File, Sha256File
from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import Deployer, get_resource
class DkimMilterDeployer(Deployer):
required_users = [("dkim-milter", None, ["dkim-milter"])]
def __init__(self, mail_domain):
self.mail_domain = mail_domain
self.need_restart = False
def install(self):
"""Builds and installs dkim-milter"""
# openssl is required to generate the signing key
apt.packages(
name="Install openssl required by DKIM Milter",
packages=["openssl"],
)
(url, sha256sum) = {
"x86_64": (
"https://github.com/chatmail/dkim-milter/releases/download/0.1.0/dkim-milter-x86_64",
"e676837b362ebef461881079e3e1151ed2db2d942d98b7103974921ac69ce5de",
),
"aarch64": (
"https://github.com/chatmail/dkim-milter/releases/download/0.1.0/dkim-milter-aarch64",
"b853ab85a535b7e7e548ae0e4d85a61d4c0fd44f2912c3439662c56ca8a369e6",
),
}[host.get_fact(facts.server.Arch)]
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/sbin/dkim-milter")
if existing_sha256sum != sha256sum:
server.shell(
name="Download DKIM Milter",
commands=[
f"(curl -L {url} >/usr/local/sbin/dkim-milter.new && (echo '{sha256sum} /usr/local/sbin/dkim-milter.new' | sha256sum -c) && mv /usr/local/sbin/dkim-milter.new /usr/local/sbin/dkim-milter)",
"chmod 755 /usr/local/sbin/dkim-milter",
],
)
self.need_restart = True
def configure(self):
"""Configures dkim-milter"""
domain = self.mail_domain
# note - we are using "opendkim" for backward compatibility
# for relays that were set up before we migrated from OpenDKIM
# to DKIM Milter.
selector = "opendkim"
signing_key_name = selector
# for backward compatibility with opendkim-genkey
signing_key_filename = f"{signing_key_name}.private"
config_common = {
"domain": domain,
"selector": selector,
"signing_key_name": signing_key_name,
"signing_key_filename": signing_key_filename,
}
config_verify = {
**config_common,
"mode": "verify",
"config_file": "/etc/dkim-milter/dkim-milter-verify.conf",
"socket_name": "dkim-milter-verify.sock",
}
config_sign = {
**config_common,
"mode": "sign",
"config_file": "/etc/dkim-milter/dkim-milter-sign.conf",
"socket_name": "dkim-milter-sign.sock",
}
self.need_restart |= files.directory(
name="Create a directory for DKIM Milter configs",
path="/etc/dkim-milter",
user="dkim-milter",
group="dkim-milter",
mode="750",
present=True,
).changed
for config in [config_verify, config_sign]:
self.need_restart |= files.template(
src=get_resource("dkim_milter/dkim-milter.conf.j2"),
dest=config["config_file"],
user="dkim-milter",
group="dkim-milter",
mode="644",
config=config,
).changed
self.need_restart |= files.directory(
name="Create dkimkeys directory",
path="/etc/dkimkeys",
user="dkim-milter",
group="dkim-milter",
mode="750",
present=True,
).changed
self.need_restart |= files.template(
src=get_resource("dkim_milter/signing-keys"),
dest="/etc/dkim-milter/signing-keys",
user="dkim-milter",
group="dkim-milter",
mode="644",
config=config_common,
).changed
self.need_restart |= files.template(
src=get_resource("dkim_milter/signing-senders"),
dest="/etc/dkim-milter/signing-senders",
user="dkim-milter",
group="dkim-milter",
mode="644",
config=config_common,
).changed
self.need_restart |= files.directory(
name="Create DKIM Milter unix sockets directory",
path="/var/spool/postfix/dkim-milter",
user="dkim-milter",
group="dkim-milter",
mode="770",
).changed
if not host.get_fact(File, f"/etc/dkimkeys/{signing_key_filename}"):
server.shell(
name=f"Generate DKIM Milter signing key '{signing_key_name}'",
commands=[
f"openssl genpkey -algorithm RSA -out /etc/dkimkeys/{signing_key_filename}"
],
)
self.need_restart = True
# enforce restrictive permissions for the signing key
self.need_restart |= files.file(
path=f"/etc/dkimkeys/{signing_key_filename}",
present=True,
user="dkim-milter",
group="dkim-milter",
mode="0400",
).changed
self.need_restart |= files.put(
name="Create dkim-milter service",
src=get_resource("dkim_milter/dkim-milter@.service"),
dest=f"/etc/systemd/system/dkim-milter@.service",
).changed
def activate(self):
"""Start and enable DKIM Milter"""
for mode in ["sign", "verify"]:
systemd.service(
name=f"Start and enable DKIM Milter in {mode} mode",
service=f"dkim-milter@{mode}",
running=True,
enabled=True,
daemon_reload=self.need_restart,
restarted=self.need_restart,
)
self.need_restart = False

View File

@@ -1,30 +0,0 @@
mode = {{ config.mode }}
{% if config.mode == "verify" %}
# DKIM milter will skip verification for trusted sources,
# which in our case is everything, since we run DKIM milter on a reinjection port,
# and all connections are local.
# We force verification for local connections by not trusting anyone.
trusted_networks =
{% endif %}
log_destination = syslog
log_level = info
canonicalization = relaxed/simple
lookup_timeout = 60s
signing_keys = /etc/dkim-milter/signing-keys
signing_senders = /etc/dkim-milter/signing-senders
# Signing
sign_headers = default; autocrypt:content-type
oversign_headers = signed-extended
# Verification
required_signed_headers = From*
forbid_unsigned_content = yes
reject_failures = missing, no-pass, author-mismatch
socket = unix:/var/spool/postfix/dkim-milter/{{ config.socket_name }}

View File

@@ -1,15 +0,0 @@
[Unit]
Description=DKIM Milter %i
Documentation=man:dkim-milter(8) man:dkim-milter.conf(5)
After=network-online.target nss-lookup.target
Wants=network-online.target
[Service]
User=dkim-milter
UMask=007
ExecStart=/usr/local/sbin/dkim-milter -c /etc/dkim-milter/dkim-milter-%i.conf
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
[Install]
WantedBy=multi-user.target

View File

@@ -1,2 +0,0 @@
# Key name Signing key
{{ config.signing_key_name }} </etc/dkimkeys/{{ config.signing_key_filename }}

View File

@@ -1,2 +0,0 @@
# Sender expression Domain Selector Key name
.{{ config.domain }} {{ config.domain }} {{ config.selector }} {{ config.signing_key_name }}

View File

@@ -1,7 +1,7 @@
## Dovecot configuration file
{% if disable_ipv6 %}
listen = *
listen = 0.0.0.0
{% endif %}
protocols = imap lmtp

View File

@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
def install(self):
arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.2.0/filtermail-{arch}-musl"
url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
sha256sum = {
"x86_64": "1e5bbb646582cb16740c6dfbbca39edba492b78cc96ec9fa2528c612bb504edd",
"aarch64": "3564fba8605f8f9adfeefff3f4580533205da043f47c5968d0d10db17e50f44e",
"x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
"aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
}[arch]
self.need_restart |= files.download(
name="Download filtermail",

View File

@@ -0,0 +1 @@
{{ config.opendkim_selector }}._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/{{ config.opendkim_selector }}.private

View File

@@ -0,0 +1 @@
*@{{ config.domain_name }} {{ config.opendkim_selector }}._domainkey.{{ config.domain_name }}

View File

@@ -0,0 +1,123 @@
"""
Installs OpenDKIM
"""
from pyinfra import host
from pyinfra.facts.files import File
from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import Deployer, get_resource
class OpendkimDeployer(Deployer):
required_users = [("opendkim", None, ["opendkim"])]
def __init__(self, mail_domain):
self.mail_domain = mail_domain
def install(self):
apt.packages(
name="apt install opendkim opendkim-tools",
packages=["opendkim", "opendkim-tools"],
)
def configure(self):
domain = self.mail_domain
dkim_selector = "opendkim"
"""Configures OpenDKIM"""
need_restart = False
main_config = files.template(
src=get_resource("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= main_config.changed
screen_script = files.put(
src=get_resource("opendkim/screen.lua"),
dest="/etc/opendkim/screen.lua",
user="root",
group="root",
mode="644",
)
need_restart |= screen_script.changed
final_script = files.put(
src=get_resource("opendkim/final.lua"),
dest="/etc/opendkim/final.lua",
user="root",
group="root",
mode="644",
)
need_restart |= final_script.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
keytable = files.template(
src=get_resource("opendkim/KeyTable"),
dest="/etc/dkimkeys/KeyTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= keytable.changed
signing_table = files.template(
src=get_resource("opendkim/SigningTable"),
dest="/etc/dkimkeys/SigningTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= signing_table.changed
files.directory(
name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
server.shell(
name="Generate OpenDKIM domain keys",
commands=[
f"/usr/sbin/opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
],
_use_su_login=True,
_su_user="opendkim",
)
service_file = files.put(
name="Configure opendkim to restart once a day",
src=get_resource("opendkim/systemd.conf"),
dest="/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
)
need_restart |= service_file.changed
self.need_restart = need_restart
def activate(self):
systemd.service(
name="Start and enable OpenDKIM",
service="opendkim.service",
running=True,
enabled=True,
daemon_reload=self.need_restart,
restarted=self.need_restart,
)
self.need_restart = False

View File

@@ -0,0 +1,42 @@
mtaname = odkim.get_mtasymbol(ctx, "{daemon_name}")
if mtaname == "ORIGINATING" then
-- Outgoing message will be signed,
-- no need to look for signatures.
return nil
end
nsigs = odkim.get_sigcount(ctx)
if nsigs == nil then
return nil
end
local valid = false
local error_msg = "No valid DKIM signature found."
for i = 1, nsigs do
sig = odkim.get_sighandle(ctx, i - 1)
sigres = odkim.sig_result(sig)
-- All signatures that do not correspond to From:
-- were ignored in screen.lua and return sigres -1.
--
-- Any valid signature that was not ignored like this
-- means the message is acceptable.
if sigres == 0 then
valid = true
else
error_msg = "DKIM signature is invalid, error code " .. tostring(sigres) .. ", search https://github.com/trusteddomainproject/OpenDKIM/blob/master/libopendkim/dkim.h#L108"
end
end
if valid then
-- Strip all DKIM-Signature headers after successful validation
-- Delete in reverse order to avoid index shifting.
for i = nsigs, 1, -1 do
odkim.del_header(ctx, "DKIM-Signature", i)
end
else
odkim.set_reply(ctx, "554", "5.7.1", error_msg)
odkim.set_result(ctx, SMFIS_REJECT)
end
return nil

View File

@@ -0,0 +1,73 @@
# OpenDKIM configuration.
Syslog yes
SyslogSuccess yes
#LogWhy no
# Common signing and verification parameters. In Debian, the "From" header is
# oversigned, because it is often the identity key used by reputation systems
# and thus somewhat security sensitive.
Canonicalization relaxed/simple
OversignHeaders From
On-BadSignature reject
On-KeyNotFound reject
On-NoSignature reject
DNSTimeout 60
# Signing domain, selector, and key (required). For example, perform signing
# for domain "example.com" with selector "2020" (2020._domainkey.example.com),
# using the private key stored in /etc/dkimkeys/example.private. More granular
# setup options can be found in /usr/share/doc/opendkim/README.opendkim.
Domain {{ config.domain_name }}
Selector {{ config.opendkim_selector }}
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
KeyTable /etc/dkimkeys/KeyTable
SigningTable refile:/etc/dkimkeys/SigningTable
# Sign Autocrypt header in addition to the default specified in RFC 6376.
#
# Default list is here:
# <https://github.com/trusteddomainproject/OpenDKIM/blob/5c539587561785a66c1f67f720f2fb741f320785/libopendkim/dkim.c#L221-L245>
SignHeaders *,+autocrypt,+content-type
# Prevent addition of second Content-Type header
# and other important headers that should not be added
# after signing the message.
# See
# <https://www.zone.eu/blog/2024/05/17/bimi-and-dmarc-cant-save-you/>
# and RFC 6376 (page 41) for reference.
#
# We don't use "l=" body length so the problem described in RFC 6376
# is not applicable, but adding e.g. a second "From" header
# or second "Autocrypt" header is better prevented in any case.
#
# Default is empty.
OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
# Script to ignore signatures that do not correspond to the From: domain.
ScreenPolicyScript /etc/opendkim/screen.lua
# Script to reject mails without a valid DKIM signature.
FinalPolicyScript /etc/opendkim/final.lua
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged
# user (for example, Postfix). You may need to add user "postfix" to group
# "opendkim" in that case.
UserID opendkim
UMask 007
Socket local:/var/spool/postfix/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
# by the package dns-root-data.
TrustAnchorFile /usr/share/dns/root.key
# Sign messages when `-o milter_macro_daemon_name=ORIGINATING` is set.
MTA ORIGINATING
# No hosts are treated as internal, ORIGINATING daemon name should be set explicitly.
InternalHosts -

View File

@@ -0,0 +1,21 @@
-- Ignore signatures that do not correspond to the From: domain.
from_domain = odkim.get_fromdomain(ctx)
if from_domain == nil then
return nil
end
n = odkim.get_sigcount(ctx)
if n == nil then
return nil
end
for i = 1, n do
sig = odkim.get_sighandle(ctx, i - 1)
sig_domain = odkim.sig_getdomain(sig)
if from_domain ~= sig_domain then
odkim.sig_ignore(sig)
end
end
return nil

View File

@@ -0,0 +1,3 @@
[Service]
Restart=always
RuntimeMaxSec=1d

View File

@@ -4,7 +4,7 @@ from cmdeploy.basedeploy import Deployer, get_resource
class PostfixDeployer(Deployer):
required_users = [("postfix", None, ["dkim-milter"])]
required_users = [("postfix", None, ["opendkim"])]
daemon_reload = False
def __init__(self, config, disable_mail):
@@ -61,6 +61,20 @@ class PostfixDeployer(Deployer):
)
need_restart |= lmtp_header_cleanup.changed
tls_policy_map = files.put(
name="Upload SMTP TLS Policy that accepts self-signed certificates for IP-only hosts",
src=get_resource("postfix/smtp_tls_policy_map"),
dest="/etc/postfix/smtp_tls_policy_map",
user="root",
group="root",
mode="644",
)
need_restart |= tls_policy_map.changed
if tls_policy_map.changed:
server.shell(
commands=["postmap /etc/postfix/smtp_tls_policy_map"],
)
# Login map that 1:1 maps email address to login.
login_map = files.put(
src=get_resource("postfix/login_map"),

View File

@@ -1,2 +1,3 @@
/^DKIM-Signature:/ IGNORE
/^Authentication-Results:/ IGNORE
/^Received:/ IGNORE

View File

@@ -25,7 +25,7 @@ smtp_tls_security_level=verify
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = inline:{nauta.cu=may}
smtp_tls_policy_maps = regexp:/etc/postfix/smtp_tls_policy_map
smtp_tls_protocols = >=TLSv1.2
smtp_tls_mandatory_protocols = >=TLSv1.2
@@ -64,7 +64,20 @@ alias_database = hash:/etc/aliases
mydestination =
relayhost =
{% if disable_ipv6 %}
mynetworks = 127.0.0.0/8
{% else %}
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
{% endif %}
{% if config.addr_v4 %}
smtp_bind_address = {{ config.addr_v4 }}
{% endif %}
{% if config.addr_v6 %}
smtp_bind_address6 = {{ config.addr_v6 }}
{% endif %}
{% if config.addr_v4 or config.addr_v6 %}
smtp_bind_address_enforce = yes
{% endif %}
mailbox_size_limit = 0
message_size_limit = {{config.max_message_size}}
recipient_delimiter = +

View File

@@ -80,13 +80,13 @@ filter unix - n n - - lmtp
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_milters=unix:dkim-milter/dkim-milter-sign.sock
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean
# Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject_incoming
-o smtpd_milters=unix:dkim-milter/dkim-milter-verify.sock
-o smtpd_milters=unix:opendkim/opendkim.sock
# Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP.

View File

@@ -0,0 +1,2 @@
/^\[[^]]+\]$/ encrypt
/^nauta\.cu$/ may

View File

@@ -85,16 +85,31 @@ class SSHExec:
class LocalExec:
FuncError = FuncError
def __init__(self, verbose=False, docker=False):
self.verbose = verbose
self.docker = docker
def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None:
kwargs = {}
return call(**kwargs)
def logged(self, call, kwargs: dict):
title = call.__doc__
if not title:
title = call.__name__
where = "locally"
if self.docker:
if call == remote.rdns.perform_initial_checks:
kwargs["pre_command"] = "docker exec chatmail "
where = "in docker"
if self.verbose:
print(f"Running {where}: {call.__name__}(**{kwargs})")
return call(**kwargs)
print_stderr(f"Running {where}: {title}(**{kwargs})")
return self(call, kwargs, log_callback=print_stderr)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
print_stderr()
return res

View File

@@ -1,3 +1,4 @@
import datetime
import smtplib
import socket
import subprocess
@@ -6,13 +7,13 @@ import time
import pytest
from cmdeploy import remote
from cmdeploy.sshexec import SSHExec
from cmdeploy.cmdeploy import get_sshexec
class TestSSHExecutor:
@pytest.fixture(scope="class")
def sshexec(self, sshdomain):
return SSHExec(sshdomain)
return get_sshexec(sshdomain)
def test_ls(self, sshexec):
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
@@ -26,6 +27,7 @@ class TestSSHExecutor:
assert res["A"] or res["AAAA"]
def test_logged(self, sshexec, maildomain, capsys):
sshexec.verbose = False
sshexec.logged(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
)
@@ -51,12 +53,23 @@ class TestSSHExecutor:
remote.rdns.perform_initial_checks,
kwargs=dict(mail_domain=None),
)
except AssertionError:
pass
except sshexec.FuncError as e:
assert "rdns.py" in str(e)
assert "AssertionError" in str(e)
else:
pytest.fail("didn't raise exception")
def test_opendkim_restarted(self, sshexec):
"""check that opendkim is not running for longer than a day."""
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
datestring = out.split("=")[1]
since_date = datetime.datetime.strptime(datestring, "%a %Y-%m-%d %H:%M:%S %Z")
now = datetime.datetime.now(since_date.tzinfo)
assert (now - since_date).total_seconds() < 60 * 60 * 51
def test_timezone_env(remote):
for line in remote.iter_output("env"):
@@ -136,7 +149,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
conn.starttls()
with conn as s:
with pytest.raises(smtplib.SMTPDataError, match="No DKIM signature found"):
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)

View File

@@ -7,7 +7,7 @@ import pytest
import requests
from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec
from cmdeploy.cmdeploy import get_sshexec
@pytest.fixture
@@ -90,7 +90,7 @@ class TestEndToEndDeltaChat:
lp.sec(f"filling remote inbox for {user}")
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn)
sshexec = SSHExec(sshdomain)
sshexec = get_sshexec(sshdomain)
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
assert res["percent"] >= 100

View File

@@ -5,7 +5,11 @@ from cmdeploy.cmdeploy import main
def test_status_cmd(chatmail_config, capsys, request):
os.chdir(request.config.invocation_params.dir)
assert main(["status"]) == 0
command = ["status"]
if os.getenv("CHATMAIL_SSH"):
command.append("--ssh-host")
command.append(os.getenv("CHATMAIL_SSH"))
assert main(command) == 0
status_out = capsys.readouterr()
print(status_out.out)
@@ -24,7 +28,7 @@ def test_status_cmd(chatmail_config, capsys, request):
"filtermail",
"lastlogin",
"nginx",
"dkim-milter",
"opendkim",
"postfix@-",
"systemd-journald",
"turnserver",

View File

@@ -54,8 +54,8 @@ def maildomain(chatmail_config):
@pytest.fixture(scope="session")
def sshdomain(maildomain):
return os.environ.get("CHATMAIL_SSH", maildomain)
def sshdomain(chatmail_config):
return os.environ.get("CHATMAIL_SSH", chatmail_config.ssh_host)
@pytest.fixture
@@ -337,8 +337,14 @@ class Remote:
def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain)
match self.sshdomain:
case "@local": command = []
case "localhost": command = []
case _: command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()]
self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal],
command,
stdout=subprocess.PIPE,
)
while 1:

View File

@@ -0,0 +1,4 @@
# Managed by cmdeploy: disable IPv6 in unbound.
server:
interface: 127.0.0.1
do-ip6: no

View File

@@ -16,18 +16,11 @@ You will need the following:
- Control over a domain through a DNS provider of your choice.
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
- A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
chatmail addresses.
- A Linux or Unix **build machine** with key-based SSH access to the root
user of the deployment server.
You must add a passphrase-protected private key to your local ssh-agent because you
cant type in your passphrase during deployment.
(An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
Setup with ``scripts/cmdeploy``
-------------------------------------
@@ -35,7 +28,7 @@ Setup with ``scripts/cmdeploy``
We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain.
1. Setup the initial DNS records for your deployment server.
1. Setup the initial DNS records for your relay.
The following is an example in the
familiar BIND zone file format with a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses.
@@ -47,29 +40,24 @@ steps. Please substitute it with your own domain.
www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
2. On your local PC, clone the repository and bootstrap the Python
2. Login to the server with SSH, clone the repository and bootstrap the Python
virtualenv.
::
ssh root@chat.example.org
git clone https://github.com/chatmail/relay
cd relay
scripts/initenv.sh
3. On your local build machine (PC), create a chatmail configuration file
3. Then, create a chatmail configuration file
``chatmail.ini``:
::
scripts/cmdeploy init chat.example.org # <-- use your domain
4. Verify that SSH root login to the deployment server server works:
::
ssh root@chat.example.org # <-- use your domain
5. From your local build machine, setup and configure the remote deployment server:
4. Now run the deployment script to install the relay to the server:
::
@@ -80,27 +68,32 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are
public).
Other helpful commands
----------------------
Next Steps
----------
To check the status of your deployment server running the chatmail service:
::
scripts/cmdeploy status
To display and check all recommended DNS records:
Now you should display and check all recommended DNS records
to enable federation with other relays:
::
scripts/cmdeploy dns
To test whether your chatmail service is working correctly:
You should also test whether your chatmail service is working correctly:
::
scripts/cmdeploy test
Other Helpful Commands
----------------------
To check the status of your chatmail relay:
::
scripts/cmdeploy status
To measure the performance of your chatmail service:
::
@@ -141,8 +134,9 @@ This starts a local live development cycle for chatmail web pages:
directory and generating HTML files and copying assets to the
``www/build`` directory.
- Starts a browser window automatically where you can “refresh” as
needed.
- if you are running scripts/cmdeploy webdev on the relay itself,
you need to configure a route in /etc/nginx/nginx.conf
to expose the build directory.
Custom web pages
----------------
@@ -160,7 +154,7 @@ Disable automatic address creation
--------------------------------------------------------
If you need to stop address creation, e.g. because some script is wildly
creating addresses, login with ssh to the deployment machine and run:
creating addresses, login with ssh to the relay and run:
::
@@ -168,24 +162,3 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present.
Migrating to a new build machine
----------------------------------
To move or add a build machine,
clone the relay repository on the new build machine, and copy the ``chatmail.ini`` file from the old build machine.
Make sure ``rsync`` is installed, then initialize the environment:
::
./scripts/initenv.sh
Run safety checks before a new deployment:
::
./scripts/cmdeploy dns
./scripts/cmdeploy status
If you keep multiple build machines (ie laptop and desktop), keep ``chatmail.ini`` in sync between
them.

View File

@@ -72,7 +72,7 @@ in this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
ssh root@$NEW_IP4
chown root: -R /var/lib/acme
chown dkim-milter: -R /etc/dkimkeys
chown opendkim: -R /etc/dkimkeys
chown vmail: -R /home/vmail/mail

View File

@@ -52,7 +52,7 @@ The deployed system components of a chatmail relay are:
- `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS
certificates for Dovecot, Postfix, and Nginx
- `DKIM Milter <https://github.com/chatmail/dkim-milter>`_ for signing messages with
- `OpenDKIM <http://www.opendkim.org/>`_ for signing messages with
DKIM and rejecting inbound messages without DKIM
- `mtail <https://google.github.io/mtail/>`_ for collecting anonymized
@@ -268,10 +268,12 @@ Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails.
Incoming emails must have a valid DKIM signature with
Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature
header) equal to the ``From:`` header domain. This property is checked
by dkim-milter ``reject_failures = author-mismatch `` policy. This
by OpenDKIM screen policy script before validating the signatures. This
corresponds to strict :rfc:`DMARC <7489>` alignment (``adkim=s``).
If there is no valid DKIM signature on the incoming email, the
sender receives a “5.7.1 No valid DKIM signature found” error.
After validating the DKIM signature,
the `final.lua` script strips all ``OpenDKIM:`` headers to reduce message size on disc.
Note that chatmail relays

View File

@@ -14,8 +14,8 @@ We know of three work-in-progress alternative implementation efforts:
it to support all of the features and configuration settings required
to operate as a chatmail relay.
- `Madmail <https://github.com/omidz4t/madmail>`_: an
experimental fork of Maddy Mail Server <https://maddy.email/>`_ optimized
- `Madmail <https://github.com/themadorg/madmail>`_: an
experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified
for chatmail deployments. It provides a single binary solution
for running a chatmail relay.