mirror of
https://github.com/chatmail/relay.git
synced 2026-05-19 04:18:09 +00:00
Compare commits
29 Commits
j-g00da/dk
...
ssh-host-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcdef806d7 | ||
|
|
e024d837dc | ||
|
|
6981331466 | ||
|
|
4d8aacae6b | ||
|
|
a6ddb4fe92 | ||
|
|
2042c94bb6 | ||
|
|
6d28cf0f15 | ||
|
|
36eb63faa1 | ||
|
|
91df11015e | ||
|
|
d4f8a29243 | ||
|
|
0144fc3ea8 | ||
|
|
e7ce6679b9 | ||
|
|
d1adf52f89 | ||
|
|
56d0e2ca27 | ||
|
|
2613558db6 | ||
|
|
6843fcb1a0 | ||
|
|
ff54ad88d8 | ||
|
|
cce2b27ae7 | ||
|
|
87022e3681 | ||
|
|
06560dd071 | ||
|
|
1b0337a5f7 | ||
|
|
dfcaf415b1 | ||
|
|
c0718325ef | ||
|
|
7d72b0e592 | ||
|
|
8f1e23d98e | ||
|
|
56aaf2649b | ||
|
|
2660b4d24c | ||
|
|
ea60ecfb57 | ||
|
|
2a3a224cc2 |
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.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
|
- name: run chatmaild tests
|
||||||
working-directory: chatmaild
|
working-directory: chatmaild
|
||||||
run: pipx run tox
|
run: pipx run tox
|
||||||
|
|||||||
29
.github/workflows/test-and-deploy-ipv4only.yaml
vendored
29
.github/workflows/test-and-deploy-ipv4only.yaml
vendored
@@ -71,26 +71,35 @@ jobs:
|
|||||||
- name: run deploy-chatmail offline tests
|
- name: run deploy-chatmail offline tests
|
||||||
run: pytest --pyargs cmdeploy
|
run: pytest --pyargs cmdeploy
|
||||||
|
|
||||||
- run: |
|
- name: setup dependencies
|
||||||
cmdeploy init staging-ipv4.testrun.org
|
run: |
|
||||||
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
|
ssh root@staging-ipv4.testrun.org apt update
|
||||||
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
|
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
|
- name: set DNS entries
|
||||||
run: |
|
run: |
|
||||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown dkim-milter:dkim-milter -R /etc/dkimkeys
|
ssh root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
|
||||||
cmdeploy dns --zonefile staging-generated.zone
|
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone"
|
||||||
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.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
|
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
|
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 nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
|
||||||
ssh root@ns.testrun.org systemctl reload nsd
|
ssh root@ns.testrun.org systemctl reload nsd
|
||||||
|
|
||||||
- name: cmdeploy test
|
- 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
|
- name: cmdeploy dns
|
||||||
run: cmdeploy dns -v
|
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v"
|
||||||
|
|
||||||
|
|||||||
3
.github/workflows/test-and-deploy.yaml
vendored
3
.github/workflows/test-and-deploy.yaml
vendored
@@ -76,13 +76,14 @@ jobs:
|
|||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
cmdeploy init staging2.testrun.org
|
cmdeploy init staging2.testrun.org
|
||||||
|
sed -i 's/^ssh_host/#ssh_host/' chatmail.ini
|
||||||
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
|
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
|
||||||
|
|
||||||
- run: cmdeploy run --verbose --skip-dns-check
|
- run: cmdeploy run --verbose --skip-dns-check
|
||||||
|
|
||||||
- name: set DNS entries
|
- name: set DNS entries
|
||||||
run: |
|
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
|
cmdeploy dns --zonefile staging-generated.zone --verbose
|
||||||
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
|
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
|
||||||
cat .github/workflows/staging.testrun.org-default.zone
|
cat .github/workflows/staging.testrun.org-default.zone
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import iniconfig
|
import iniconfig
|
||||||
@@ -8,30 +9,28 @@ from chatmaild.user import User
|
|||||||
def read_config(inipath):
|
def read_config(inipath):
|
||||||
assert Path(inipath).exists(), inipath
|
assert Path(inipath).exists(), inipath
|
||||||
cfg = iniconfig.IniConfig(inipath)
|
cfg = iniconfig.IniConfig(inipath)
|
||||||
params = cfg.sections["params"]
|
return Config(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)
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
def __init__(self, inipath, params):
|
def __init__(self, inipath, params):
|
||||||
self._inipath = inipath
|
self._inipath = inipath
|
||||||
self.mail_domain = params["mail_domain"]
|
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_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_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
|
||||||
self.max_mailbox_size = params["max_mailbox_size"]
|
self.max_mailbox_size = params.get("max_mailbox_size", "500M")
|
||||||
self.max_message_size = int(params.get("max_message_size", "31457280"))
|
self.max_message_size = int(params.get("max_message_size", 31457280))
|
||||||
self.delete_mails_after = params["delete_mails_after"]
|
self.delete_mails_after = params.get("delete_mails_after", "20")
|
||||||
self.delete_large_after = params["delete_large_after"]
|
self.delete_large_after = params.get("delete_large_after", "7")
|
||||||
self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
|
self.delete_inactive_users_after = int(
|
||||||
self.username_min_length = int(params["username_min_length"])
|
params.get("delete_inactive_users_after", 100)
|
||||||
self.username_max_length = int(params["username_max_length"])
|
)
|
||||||
self.password_min_length = int(params["password_min_length"])
|
self.username_min_length = int(params.get("username_min_length", 9))
|
||||||
self.passthrough_senders = params["passthrough_senders"].split()
|
self.username_max_length = int(params.get("username_max_length", 9))
|
||||||
self.passthrough_recipients = params["passthrough_recipients"].split()
|
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.www_folder = params.get("www_folder", "")
|
||||||
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
|
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
|
||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
@@ -43,6 +42,8 @@ class Config:
|
|||||||
)
|
)
|
||||||
self.mtail_address = params.get("mtail_address")
|
self.mtail_address = params.get("mtail_address")
|
||||||
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
|
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.acme_email = params.get("acme_email", "")
|
||||||
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
|
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
|
||||||
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
|
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
# mail domain (MUST be set to fully qualified chat mail domain)
|
# mail domain (MUST be set to fully qualified chat mail domain)
|
||||||
mail_domain = {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
|
# If you only do private test deploys, you don't need to modify any settings below
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ def run_cmd_options(parser):
|
|||||||
def run_cmd(args, out):
|
def run_cmd(args, out):
|
||||||
"""Deploy chatmail services on the remote server."""
|
"""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)
|
sshexec = get_sshexec(ssh_host)
|
||||||
require_iroh = args.config.enable_iroh_relay
|
require_iroh = args.config.enable_iroh_relay
|
||||||
if not args.dns_check_disabled:
|
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_WEBSITE_ONLY"] = "True" if args.website_only else ""
|
||||||
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
|
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
|
||||||
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh 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()
|
deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve()
|
||||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||||
|
|
||||||
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
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"
|
cmd = f"{pyinf} @local {deploy_path} -y"
|
||||||
|
|
||||||
if version.parse(pyinfra.__version__) < version.parse("3"):
|
if version.parse(pyinfra.__version__) < version.parse("3"):
|
||||||
@@ -121,7 +124,7 @@ def run_cmd(args, out):
|
|||||||
out.red("Website deployment failed.")
|
out.red("Website deployment failed.")
|
||||||
elif retcode == 0:
|
elif retcode == 0:
|
||||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
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("Deploy completed but letsencrypt not configured")
|
||||||
out.red("Run 'cmdeploy run' again")
|
out.red("Run 'cmdeploy run' again")
|
||||||
retcode = 0
|
retcode = 0
|
||||||
@@ -146,7 +149,7 @@ def dns_cmd_options(parser):
|
|||||||
|
|
||||||
def dns_cmd(args, out):
|
def dns_cmd(args, out):
|
||||||
"""Check DNS entries and optionally generate dns zone file."""
|
"""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)
|
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
||||||
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
||||||
if not remote_data:
|
if not remote_data:
|
||||||
@@ -180,7 +183,7 @@ def status_cmd_options(parser):
|
|||||||
def status_cmd(args, out):
|
def status_cmd(args, out):
|
||||||
"""Display status for online chatmail instance."""
|
"""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)
|
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
||||||
|
|
||||||
out.green(f"chatmail domain: {args.config.mail_domain}")
|
out.green(f"chatmail domain: {args.config.mail_domain}")
|
||||||
@@ -200,6 +203,7 @@ def test_cmd_options(parser):
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="also run slow tests",
|
help="also run slow tests",
|
||||||
)
|
)
|
||||||
|
add_ssh_host_option(parser)
|
||||||
|
|
||||||
|
|
||||||
def test_cmd(args, out):
|
def test_cmd(args, out):
|
||||||
@@ -211,6 +215,9 @@ def test_cmd(args, out):
|
|||||||
x = importlib.util.find_spec("deltachat")
|
x = importlib.util.find_spec("deltachat")
|
||||||
if x is None:
|
if x is None:
|
||||||
out.check_call(f"{sys.executable} -m pip install deltachat")
|
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_path = shutil.which("pytest")
|
||||||
pytest_args = [
|
pytest_args = [
|
||||||
@@ -224,7 +231,7 @@ def test_cmd(args, out):
|
|||||||
]
|
]
|
||||||
if args.slow:
|
if args.slow:
|
||||||
pytest_args.append("--slow")
|
pytest_args.append("--slow")
|
||||||
ret = out.run_ret(pytest_args)
|
ret = out.run_ret(pytest_args, env=env)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from chatmaild.config import read_config
|
from chatmaild.config import read_config
|
||||||
from pyinfra import facts, host, logger
|
from pyinfra import facts, host, logger
|
||||||
|
from pyinfra.facts import hardware
|
||||||
from pyinfra.api import FactBase
|
from pyinfra.api import FactBase
|
||||||
from pyinfra.facts.files import Sha256File
|
from pyinfra.facts.files import Sha256File
|
||||||
from pyinfra.facts.systemd import SystemdEnabled
|
from pyinfra.facts.systemd import SystemdEnabled
|
||||||
@@ -25,18 +26,18 @@ from .basedeploy import (
|
|||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
get_resource,
|
||||||
)
|
)
|
||||||
from .dkim_milter.deployer import DkimMilterDeployer
|
|
||||||
from .dovecot.deployer import DovecotDeployer
|
from .dovecot.deployer import DovecotDeployer
|
||||||
from .filtermail.deployer import FiltermailDeployer
|
from .filtermail.deployer import FiltermailDeployer
|
||||||
from .mtail.deployer import MtailDeployer
|
from .mtail.deployer import MtailDeployer
|
||||||
from .nginx.deployer import NginxDeployer
|
from .nginx.deployer import NginxDeployer
|
||||||
|
from .opendkim.deployer import OpendkimDeployer
|
||||||
from .postfix.deployer import PostfixDeployer
|
from .postfix.deployer import PostfixDeployer
|
||||||
from .www import build_webpages, find_merge_conflict, get_paths
|
from .www import build_webpages, find_merge_conflict, get_paths
|
||||||
|
|
||||||
|
|
||||||
class Port(FactBase):
|
class Port(FactBase):
|
||||||
"""
|
"""
|
||||||
Returns the process occuping a port.
|
Returns the process occupying a port.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def command(self, port: int) -> str:
|
def command(self, port: int) -> str:
|
||||||
@@ -141,6 +142,10 @@ def _configure_remote_venv_with_chatmaild(config) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class UnboundDeployer(Deployer):
|
class UnboundDeployer(Deployer):
|
||||||
|
def __init__(self, config):
|
||||||
|
self.config = config
|
||||||
|
self.need_restart = False
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
# Run local DNS resolver `unbound`.
|
# Run local DNS resolver `unbound`.
|
||||||
# `resolvconf` takes care of setting up /etc/resolv.conf
|
# `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",
|
"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):
|
def activate(self):
|
||||||
server.shell(
|
server.shell(
|
||||||
@@ -191,6 +217,7 @@ class UnboundDeployer(Deployer):
|
|||||||
service="unbound.service",
|
service="unbound.service",
|
||||||
running=True,
|
running=True,
|
||||||
enabled=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(
|
files.line(
|
||||||
name="Add 9.9.9.9 to resolv.conf",
|
name="Add 9.9.9.9 to resolv.conf",
|
||||||
path="/etc/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 = [
|
port_services = [
|
||||||
(["master", "smtpd"], 25),
|
(["master", "smtpd"], 25),
|
||||||
("unbound", 53),
|
("unbound", 53),
|
||||||
@@ -541,7 +577,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
(["imap-login", "dovecot"], 993),
|
(["imap-login", "dovecot"], 993),
|
||||||
("iroh-relay", 3340),
|
("iroh-relay", 3340),
|
||||||
("mtail", 3903),
|
("mtail", 3903),
|
||||||
("dovecot-stats", 3904),
|
("stats", 3904),
|
||||||
("nginx", 8443),
|
("nginx", 8443),
|
||||||
(["master", "smtpd"], config.postfix_reinject_port),
|
(["master", "smtpd"], config.postfix_reinject_port),
|
||||||
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
(["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:
|
for service, port in port_services:
|
||||||
print(f"Checking if port {port} is available for {service}...")
|
print(f"Checking if port {port} is available for {service}...")
|
||||||
running_service = host.get_fact(Port, port=port)
|
running_service = host.get_fact(Port, port=port)
|
||||||
|
services = [service] if isinstance(service, str) else service
|
||||||
if running_service:
|
if running_service:
|
||||||
if running_service not in service:
|
if running_service not in services:
|
||||||
Out().red(
|
Out().red(
|
||||||
f"Deploy failed: port {port} is occupied by: {running_service}"
|
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(),
|
LegacyRemoveDeployer(),
|
||||||
FiltermailDeployer(),
|
FiltermailDeployer(),
|
||||||
JournaldDeployer(),
|
JournaldDeployer(),
|
||||||
UnboundDeployer(),
|
UnboundDeployer(config),
|
||||||
TurnDeployer(mail_domain),
|
TurnDeployer(mail_domain),
|
||||||
IrohDeployer(config.enable_iroh_relay),
|
IrohDeployer(config.enable_iroh_relay),
|
||||||
AcmetoolDeployer(config.acme_email, tls_domains),
|
AcmetoolDeployer(config.acme_email, tls_domains),
|
||||||
WebsiteDeployer(config),
|
WebsiteDeployer(config),
|
||||||
ChatmailVenvDeployer(config),
|
ChatmailVenvDeployer(config),
|
||||||
MtastsDeployer(),
|
MtastsDeployer(),
|
||||||
DkimMilterDeployer(mail_domain),
|
OpendkimDeployer(mail_domain),
|
||||||
# Dovecot should be started before Postfix
|
# Dovecot should be started before Postfix
|
||||||
# because it creates authentication socket
|
# because it creates authentication socket
|
||||||
# required by Postfix.
|
# required by Postfix.
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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 }}
|
|
||||||
@@ -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
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Key name Signing key
|
|
||||||
{{ config.signing_key_name }} </etc/dkimkeys/{{ config.signing_key_filename }}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# Sender expression Domain Selector Key name
|
|
||||||
.{{ config.domain }} {{ config.domain }} {{ config.selector }} {{ config.signing_key_name }}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
## Dovecot configuration file
|
## Dovecot configuration file
|
||||||
|
|
||||||
{% if disable_ipv6 %}
|
{% if disable_ipv6 %}
|
||||||
listen = *
|
listen = 0.0.0.0
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
protocols = imap lmtp
|
protocols = imap lmtp
|
||||||
|
|||||||
@@ -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.2.0/filtermail-{arch}-musl"
|
url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
|
||||||
sha256sum = {
|
sha256sum = {
|
||||||
"x86_64": "1e5bbb646582cb16740c6dfbbca39edba492b78cc96ec9fa2528c612bb504edd",
|
"x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
|
||||||
"aarch64": "3564fba8605f8f9adfeefff3f4580533205da043f47c5968d0d10db17e50f44e",
|
"aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
|
||||||
}[arch]
|
}[arch]
|
||||||
self.need_restart |= files.download(
|
self.need_restart |= files.download(
|
||||||
name="Download filtermail",
|
name="Download filtermail",
|
||||||
|
|||||||
1
cmdeploy/src/cmdeploy/opendkim/KeyTable
Normal file
1
cmdeploy/src/cmdeploy/opendkim/KeyTable
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ config.opendkim_selector }}._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/{{ config.opendkim_selector }}.private
|
||||||
1
cmdeploy/src/cmdeploy/opendkim/SigningTable
Normal file
1
cmdeploy/src/cmdeploy/opendkim/SigningTable
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*@{{ config.domain_name }} {{ config.opendkim_selector }}._domainkey.{{ config.domain_name }}
|
||||||
123
cmdeploy/src/cmdeploy/opendkim/deployer.py
Normal file
123
cmdeploy/src/cmdeploy/opendkim/deployer.py
Normal 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
|
||||||
42
cmdeploy/src/cmdeploy/opendkim/final.lua
Normal file
42
cmdeploy/src/cmdeploy/opendkim/final.lua
Normal 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
|
||||||
73
cmdeploy/src/cmdeploy/opendkim/opendkim.conf
Normal file
73
cmdeploy/src/cmdeploy/opendkim/opendkim.conf
Normal 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 -
|
||||||
21
cmdeploy/src/cmdeploy/opendkim/screen.lua
Normal file
21
cmdeploy/src/cmdeploy/opendkim/screen.lua
Normal 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
|
||||||
3
cmdeploy/src/cmdeploy/opendkim/systemd.conf
Normal file
3
cmdeploy/src/cmdeploy/opendkim/systemd.conf
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RuntimeMaxSec=1d
|
||||||
@@ -4,7 +4,7 @@ from cmdeploy.basedeploy import Deployer, get_resource
|
|||||||
|
|
||||||
|
|
||||||
class PostfixDeployer(Deployer):
|
class PostfixDeployer(Deployer):
|
||||||
required_users = [("postfix", None, ["dkim-milter"])]
|
required_users = [("postfix", None, ["opendkim"])]
|
||||||
daemon_reload = False
|
daemon_reload = False
|
||||||
|
|
||||||
def __init__(self, config, disable_mail):
|
def __init__(self, config, disable_mail):
|
||||||
@@ -61,6 +61,20 @@ class PostfixDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
need_restart |= lmtp_header_cleanup.changed
|
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 that 1:1 maps email address to login.
|
||||||
login_map = files.put(
|
login_map = files.put(
|
||||||
src=get_resource("postfix/login_map"),
|
src=get_resource("postfix/login_map"),
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
/^DKIM-Signature:/ IGNORE
|
/^DKIM-Signature:/ IGNORE
|
||||||
/^Authentication-Results:/ IGNORE
|
/^Authentication-Results:/ IGNORE
|
||||||
|
/^Received:/ IGNORE
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ smtp_tls_security_level=verify
|
|||||||
# <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
|
||||||
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
|
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_protocols = >=TLSv1.2
|
||||||
smtp_tls_mandatory_protocols = >=TLSv1.2
|
smtp_tls_mandatory_protocols = >=TLSv1.2
|
||||||
|
|
||||||
@@ -64,7 +64,20 @@ alias_database = hash:/etc/aliases
|
|||||||
mydestination =
|
mydestination =
|
||||||
|
|
||||||
relayhost =
|
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
|
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
|
mailbox_size_limit = 0
|
||||||
message_size_limit = {{config.max_message_size}}
|
message_size_limit = {{config.max_message_size}}
|
||||||
recipient_delimiter = +
|
recipient_delimiter = +
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ filter unix - n n - - lmtp
|
|||||||
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
|
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
|
||||||
-o syslog_name=postfix/reinject
|
-o syslog_name=postfix/reinject
|
||||||
-o milter_macro_daemon_name=ORIGINATING
|
-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
|
-o cleanup_service_name=authclean
|
||||||
|
|
||||||
# Local SMTP server for reinjecting incoming filtered mail
|
# Local SMTP server for reinjecting incoming filtered mail
|
||||||
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
|
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
|
||||||
-o syslog_name=postfix/reinject_incoming
|
-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
|
# Cleanup `Received` headers for authenticated mail
|
||||||
# to avoid leaking client IP.
|
# to avoid leaking client IP.
|
||||||
|
|||||||
2
cmdeploy/src/cmdeploy/postfix/smtp_tls_policy_map
Normal file
2
cmdeploy/src/cmdeploy/postfix/smtp_tls_policy_map
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/^\[[^]]+\]$/ encrypt
|
||||||
|
/^nauta\.cu$/ may
|
||||||
@@ -85,16 +85,31 @@ class SSHExec:
|
|||||||
|
|
||||||
|
|
||||||
class LocalExec:
|
class LocalExec:
|
||||||
|
FuncError = FuncError
|
||||||
|
|
||||||
def __init__(self, verbose=False, docker=False):
|
def __init__(self, verbose=False, docker=False):
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.docker = docker
|
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):
|
def logged(self, call, kwargs: dict):
|
||||||
|
title = call.__doc__
|
||||||
|
if not title:
|
||||||
|
title = call.__name__
|
||||||
where = "locally"
|
where = "locally"
|
||||||
if self.docker:
|
if self.docker:
|
||||||
if call == remote.rdns.perform_initial_checks:
|
if call == remote.rdns.perform_initial_checks:
|
||||||
kwargs["pre_command"] = "docker exec chatmail "
|
kwargs["pre_command"] = "docker exec chatmail "
|
||||||
where = "in docker"
|
where = "in docker"
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
print(f"Running {where}: {call.__name__}(**{kwargs})")
|
print_stderr(f"Running {where}: {title}(**{kwargs})")
|
||||||
return call(**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
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
import smtplib
|
import smtplib
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -6,13 +7,13 @@ import time
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cmdeploy import remote
|
from cmdeploy import remote
|
||||||
from cmdeploy.sshexec import SSHExec
|
from cmdeploy.cmdeploy import get_sshexec
|
||||||
|
|
||||||
|
|
||||||
class TestSSHExecutor:
|
class TestSSHExecutor:
|
||||||
@pytest.fixture(scope="class")
|
@pytest.fixture(scope="class")
|
||||||
def sshexec(self, sshdomain):
|
def sshexec(self, sshdomain):
|
||||||
return SSHExec(sshdomain)
|
return get_sshexec(sshdomain)
|
||||||
|
|
||||||
def test_ls(self, sshexec):
|
def test_ls(self, sshexec):
|
||||||
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
|
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
|
||||||
@@ -26,6 +27,7 @@ class TestSSHExecutor:
|
|||||||
assert res["A"] or res["AAAA"]
|
assert res["A"] or res["AAAA"]
|
||||||
|
|
||||||
def test_logged(self, sshexec, maildomain, capsys):
|
def test_logged(self, sshexec, maildomain, capsys):
|
||||||
|
sshexec.verbose = False
|
||||||
sshexec.logged(
|
sshexec.logged(
|
||||||
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||||
)
|
)
|
||||||
@@ -51,12 +53,23 @@ class TestSSHExecutor:
|
|||||||
remote.rdns.perform_initial_checks,
|
remote.rdns.perform_initial_checks,
|
||||||
kwargs=dict(mail_domain=None),
|
kwargs=dict(mail_domain=None),
|
||||||
)
|
)
|
||||||
|
except AssertionError:
|
||||||
|
pass
|
||||||
except sshexec.FuncError as e:
|
except sshexec.FuncError as e:
|
||||||
assert "rdns.py" in str(e)
|
assert "rdns.py" in str(e)
|
||||||
assert "AssertionError" in str(e)
|
assert "AssertionError" in str(e)
|
||||||
else:
|
else:
|
||||||
pytest.fail("didn't raise exception")
|
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):
|
def test_timezone_env(remote):
|
||||||
for line in remote.iter_output("env"):
|
for line in remote.iter_output("env"):
|
||||||
@@ -136,7 +149,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
|||||||
conn.starttls()
|
conn.starttls()
|
||||||
|
|
||||||
with conn as s:
|
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)
|
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from cmdeploy.remote import rshell
|
from cmdeploy.remote import rshell
|
||||||
from cmdeploy.sshexec import SSHExec
|
from cmdeploy.cmdeploy import get_sshexec
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -90,7 +90,7 @@ class TestEndToEndDeltaChat:
|
|||||||
lp.sec(f"filling remote inbox for {user}")
|
lp.sec(f"filling remote inbox for {user}")
|
||||||
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
|
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
|
||||||
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn)
|
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))
|
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
|
||||||
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
|
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
|
||||||
assert res["percent"] >= 100
|
assert res["percent"] >= 100
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ from cmdeploy.cmdeploy import main
|
|||||||
|
|
||||||
def test_status_cmd(chatmail_config, capsys, request):
|
def test_status_cmd(chatmail_config, capsys, request):
|
||||||
os.chdir(request.config.invocation_params.dir)
|
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()
|
status_out = capsys.readouterr()
|
||||||
print(status_out.out)
|
print(status_out.out)
|
||||||
|
|
||||||
@@ -24,7 +28,7 @@ def test_status_cmd(chatmail_config, capsys, request):
|
|||||||
"filtermail",
|
"filtermail",
|
||||||
"lastlogin",
|
"lastlogin",
|
||||||
"nginx",
|
"nginx",
|
||||||
"dkim-milter",
|
"opendkim",
|
||||||
"postfix@-",
|
"postfix@-",
|
||||||
"systemd-journald",
|
"systemd-journald",
|
||||||
"turnserver",
|
"turnserver",
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ def maildomain(chatmail_config):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def sshdomain(maildomain):
|
def sshdomain(chatmail_config):
|
||||||
return os.environ.get("CHATMAIL_SSH", maildomain)
|
return os.environ.get("CHATMAIL_SSH", chatmail_config.ssh_host)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -337,8 +337,14 @@ class Remote:
|
|||||||
|
|
||||||
def iter_output(self, logcmd=""):
|
def iter_output(self, logcmd=""):
|
||||||
getjournal = "journalctl -f" if not logcmd else 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(
|
self.popen = subprocess.Popen(
|
||||||
["ssh", f"root@{self.sshdomain}", getjournal],
|
command,
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
)
|
)
|
||||||
while 1:
|
while 1:
|
||||||
|
|||||||
4
cmdeploy/src/cmdeploy/unbound/unbound.conf.j2
Normal file
4
cmdeploy/src/cmdeploy/unbound/unbound.conf.j2
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Managed by cmdeploy: disable IPv6 in unbound.
|
||||||
|
server:
|
||||||
|
interface: 127.0.0.1
|
||||||
|
do-ip6: no
|
||||||
@@ -16,18 +16,11 @@ You will need the following:
|
|||||||
|
|
||||||
- Control over a domain through a DNS provider of your choice.
|
- 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
|
IPv6 is encouraged if available. Chatmail relay servers only require
|
||||||
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
|
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
|
||||||
chatmail addresses.
|
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
|
|
||||||
can’t 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``
|
Setup with ``scripts/cmdeploy``
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
@@ -35,7 +28,7 @@ Setup with ``scripts/cmdeploy``
|
|||||||
We use ``chat.example.org`` as the chatmail domain in the following
|
We use ``chat.example.org`` as the chatmail domain in the following
|
||||||
steps. Please substitute it with your own domain.
|
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
|
The following is an example in the
|
||||||
familiar BIND zone file format with a TTL of 1 hour (3600 seconds).
|
familiar BIND zone file format with a TTL of 1 hour (3600 seconds).
|
||||||
Please substitute your domain and IP addresses.
|
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.
|
www.chat.example.org. 3600 IN CNAME chat.example.org.
|
||||||
mta-sts.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.
|
virtualenv.
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
|
ssh root@chat.example.org
|
||||||
git clone https://github.com/chatmail/relay
|
git clone https://github.com/chatmail/relay
|
||||||
cd relay
|
cd relay
|
||||||
scripts/initenv.sh
|
scripts/initenv.sh
|
||||||
|
|
||||||
3. On your local build machine (PC), create a chatmail configuration file
|
3. Then, create a chatmail configuration file
|
||||||
``chatmail.ini``:
|
``chatmail.ini``:
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
scripts/cmdeploy init chat.example.org # <-- use your domain
|
scripts/cmdeploy init chat.example.org # <-- use your domain
|
||||||
|
|
||||||
4. Verify that SSH root login to the deployment server server works:
|
4. Now run the deployment script to install the relay to the server:
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
ssh root@chat.example.org # <-- use your domain
|
|
||||||
|
|
||||||
5. From your local build machine, setup and configure the remote deployment 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
|
configure at your DNS provider (it can take some time until they are
|
||||||
public).
|
public).
|
||||||
|
|
||||||
Other helpful commands
|
Next Steps
|
||||||
----------------------
|
----------
|
||||||
|
|
||||||
To check the status of your deployment server running the chatmail service:
|
Now you should display and check all recommended DNS records
|
||||||
|
to enable federation with other relays:
|
||||||
::
|
|
||||||
|
|
||||||
scripts/cmdeploy status
|
|
||||||
|
|
||||||
To display and check all recommended DNS records:
|
|
||||||
|
|
||||||
::
|
::
|
||||||
|
|
||||||
scripts/cmdeploy dns
|
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
|
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:
|
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
|
directory and generating HTML files and copying assets to the
|
||||||
``www/build`` directory.
|
``www/build`` directory.
|
||||||
|
|
||||||
- Starts a browser window automatically where you can “refresh” as
|
- if you are running scripts/cmdeploy webdev on the relay itself,
|
||||||
needed.
|
you need to configure a route in /etc/nginx/nginx.conf
|
||||||
|
to expose the build directory.
|
||||||
|
|
||||||
Custom web pages
|
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
|
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.
|
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.
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ in this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
|
|||||||
|
|
||||||
ssh root@$NEW_IP4
|
ssh root@$NEW_IP4
|
||||||
chown root: -R /var/lib/acme
|
chown root: -R /var/lib/acme
|
||||||
chown dkim-milter: -R /etc/dkimkeys
|
chown opendkim: -R /etc/dkimkeys
|
||||||
chown vmail: -R /home/vmail/mail
|
chown vmail: -R /home/vmail/mail
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The deployed system components of a chatmail relay are:
|
|||||||
- `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS
|
- `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS
|
||||||
certificates for Dovecot, Postfix, and Nginx
|
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
|
DKIM and rejecting inbound messages without DKIM
|
||||||
|
|
||||||
- `mtail <https://google.github.io/mtail/>`_ for collecting anonymized
|
- `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
|
Incoming emails must have a valid DKIM signature with
|
||||||
Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature
|
Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature
|
||||||
header) equal to the ``From:`` header domain. This property is checked
|
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``).
|
corresponds to strict :rfc:`DMARC <7489>` alignment (``adkim=s``).
|
||||||
If there is no valid DKIM signature on the incoming email, the
|
If there is no valid DKIM signature on the incoming email, the
|
||||||
sender receives a “5.7.1 No valid DKIM signature found” error.
|
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
|
Note that chatmail relays
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
it to support all of the features and configuration settings required
|
||||||
to operate as a chatmail relay.
|
to operate as a chatmail relay.
|
||||||
|
|
||||||
- `Madmail <https://github.com/omidz4t/madmail>`_: an
|
- `Madmail <https://github.com/themadorg/madmail>`_: an
|
||||||
experimental fork of Maddy Mail Server <https://maddy.email/>`_ optimized
|
experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified
|
||||||
for chatmail deployments. It provides a single binary solution
|
for chatmail deployments. It provides a single binary solution
|
||||||
for running a chatmail relay.
|
for running a chatmail relay.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user