Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,10 @@ cython_debug/
#.idea/

chatmail.zone

# docker
/data/
/custom/
docker-compose.yaml
.env
/traefik/data/
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## untagged

- cmdeploy: make --ssh-host work with localhost
([#659](https://github.com/chatmail/relay/pull/659))

- Update iroh-relay to 0.35.0
([#650](https://github.com/chatmail/relay/pull/650))

Expand All @@ -17,6 +20,15 @@
- Allow ports 143 and 993 to be used by `dovecot` process
([#639](https://github.com/chatmail/relay/pull/639))

- Add installation via docker compose (MVP 1). The instructions, known issues and limitations are located in `/docs`
([#614](https://github.com/chatmail/relay/pull/614))

- Add configuration parameters
([#614](https://github.com/chatmail/relay/pull/614)):
- `use_foreign_cert_manager` - Use a third-party certificate manager instead of acmetool (default: `False`)
- `change_kernel_settings` - Whether to change kernel parameters during installation (default: `True`)
- `fs_inotify_max_user_instances_and_watchers` - Value for kernel parameters `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches` (default: `65535`)

## 1.7.0 2025-09-11

- Make www upload path configurable
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,22 +74,23 @@ Please substitute it with your own domain.
```
git clone https://github.com/chatmail/relay
cd relay
scripts/initenv.sh
```

3. On your local PC, create chatmail configuration file `chatmail.ini`:
### Manual installation
1. On your local PC, create chatmail configuration file `chatmail.ini`:

```
scripts/initenv.sh
scripts/cmdeploy init chat.example.org # <-- use your domain
```

4. Verify that SSH root login to your remote server works:
2. Verify that SSH root login to your remote server works:

```
ssh root@chat.example.org # <-- use your domain
```

5. From your local PC, deploy the remote chatmail relay server:
3. From your local PC, deploy the remote chatmail relay server:

```
scripts/cmdeploy run
Expand All @@ -99,6 +100,9 @@ Please substitute it with your own domain.
which you should configure at your DNS provider
(it can take some time until they are public).

### Docker installation
Installation using docker compose is presented [here](./docs/DOCKER_INSTALLATION_EN.md)

### Other helpful commands

To check the status of your remotely running chatmail service:
Expand Down
9 changes: 9 additions & 0 deletions chatmaild/src/chatmaild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ def __init__(self, inipath, params):
)
self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.use_foreign_cert_manager = (
params.get("use_foreign_cert_manager", "false").lower() == "true"
)
self.change_kernel_settings = (
params.get("change_kernel_settings", "true").lower() == "true"
)
self.fs_inotify_max_user_instances_and_watchers = int(
params["fs_inotify_max_user_instances_and_watchers"]
)
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
if "iroh_relay" not in params:
self.iroh_relay = "https://" + params["mail_domain"]
Expand Down
15 changes: 14 additions & 1 deletion chatmaild/src/chatmaild/ini/chatmail.ini.f
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
# Deployment Details
#

# SMTP outgoing filtermail and reinjection
# SMTP outgoing filtermail and reinjection
filtermail_smtp_port = 10080
postfix_reinject_port = 10025

Expand All @@ -60,6 +60,19 @@
# if set to "True" IPv6 is disabled
disable_ipv6 = False

# if you set "True", acmetool will not be installed and you will have to manage certificates yourself.
use_foreign_cert_manager = False

#
# Kernel settings
#

# if you set "True", the kernel settings will be configured according to the values below
change_kernel_settings = True

# change fs.inotify.max_user_instances and fs.inotify.max_user_watches kernel settings
fs_inotify_max_user_instances_and_watchers = 65535

# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
# service.
# If you set it to anything else, the service will be disabled
Expand Down
36 changes: 19 additions & 17 deletions cmdeploy/src/cmdeploy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,20 +395,21 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
config=config,
)

# as per https://doc.dovecot.org/configuration_manual/os/
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] > 65535:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)
if config.change_kernel_settings:
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] == config.fs_inotify_max_user_instances_and_watchers:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=config.fs_inotify_max_user_instances_and_watchers,
persist=True,
)

timezone_env = files.line(
name="Set TZ environment variable",
Expand Down Expand Up @@ -725,10 +726,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
deploy_iroh_relay(config)

# Deploy acmetool to have TLS certificates.
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool(
domains=tls_domains,
)
if not config.use_foreign_cert_manager:
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool(
domains=tls_domains,
)

apt.packages(
# required for setfacl for echobot
Expand Down
37 changes: 27 additions & 10 deletions cmdeploy/src/cmdeploy/cmdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,15 @@ def run_cmd_options(parser):
parser.add_argument(
"--ssh-host",
dest="ssh_host",
help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default",
help="Deploy to 'localhost', via 'docker', or to a specific SSH host",
)


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

sshexec = args.get_sshexec()
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red):
Expand All @@ -80,8 +81,11 @@ def run_cmd(args, out):
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
ssh_host = args.config.mail_domain if not args.ssh_host else args.ssh_host

cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host in ["localhost", "docker"]:
cmd = f"{pyinf} @local {deploy_path} -y"

if version.parse(pyinfra.__version__) < version.parse("3"):
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
return 1
Expand All @@ -97,6 +101,9 @@ def run_cmd(args, out):
kwargs=dict(command="cat /var/lib/echobot/invite-link.txt"),
)
)
server_deployed_message = f"Chatmail server started: https://{args.config.mail_domain}/"
delimiter_line = "=" * len(server_deployed_message)
out.green(f"{delimiter_line}\n{server_deployed_message}\n{delimiter_line}")
out.green("Deploy completed, call `cmdeploy dns` next.")
elif not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
Expand All @@ -118,11 +125,17 @@ def dns_cmd_options(parser):
default=None,
help="write out a zonefile",
)
parser.add_argument(
"--ssh-host",
dest="ssh_host",
help="Run the DNS queries on 'localhost', in the chatmail 'docker' container, or on a specific SSH host",
)


def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file."""
sshexec = args.get_sshexec()
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not remote_data:
return 1
Expand Down Expand Up @@ -331,19 +344,23 @@ def get_parser():
return parser


def get_sshexec(ssh_host: str, verbose=True):
if ssh_host in ["localhost", "@local"]:
return "localhost"
elif ssh_host == "docker":
return "docker"
if verbose:
print(f"[ssh] login to {ssh_host}")
return SSHExec(ssh_host, verbose=verbose)


def main(args=None):
"""Provide main entry point for 'cmdeploy' CLI invocation."""
parser = get_parser()
args = parser.parse_args(args=args)
if not hasattr(args, "func"):
return parser.parse_args(["-h"])

def get_sshexec():
print(f"[ssh] login to {args.config.mail_domain}")
return SSHExec(args.config.mail_domain, verbose=args.verbose)

args.get_sshexec = get_sshexec

out = Out()
kwargs = {}
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
Expand Down
24 changes: 17 additions & 7 deletions cmdeploy/src/cmdeploy/dns.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@


def get_initial_remote_data(sshexec, mail_domain):
return sshexec.logged(
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
)
if sshexec == "localhost":
result = remote.rdns.perform_initial_checks(mail_domain)
elif sshexec == "docker":
result = remote.rdns.perform_initial_checks(mail_domain, pre_command="docker exec chatmail ")
else:
result = sshexec.logged(
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
)
return result


def check_initial_remote_data(remote_data, *, print=print):
Expand Down Expand Up @@ -44,10 +50,14 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
"""Check existing DNS records, optionally write them to zone file
and return (exitcode, remote_data) tuple."""

required_diff, recommended_diff = sshexec.logged(
remote.rdns.check_zonefile,
kwargs=dict(zonefile=zonefile, mail_domain=remote_data["mail_domain"]),
)
if sshexec in ["localhost", "docker"]:
required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile=zonefile, verbose=False
)
else:
required_diff, recommended_diff = sshexec.logged(
remote.rdns.check_zonefile, kwargs=dict(zonefile=zonefile, verbose=False),
)

returncode = 0
if required_diff:
Expand Down
27 changes: 14 additions & 13 deletions cmdeploy/src/cmdeploy/remote/rdns.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,23 @@

import re

from .rshell import CalledProcessError, shell
from .rshell import CalledProcessError, shell, log_progress


def perform_initial_checks(mail_domain):
def perform_initial_checks(mail_domain, pre_command=""):
"""Collecting initial DNS settings."""
assert mail_domain
if not shell("dig", fail_ok=True):
shell("apt-get update && apt-get install -y dnsutils")
if not shell("dig", fail_ok=True, print=log_progress):
shell("apt-get update && apt-get install -y dnsutils", print=log_progress)
A = query_dns("A", mail_domain)
AAAA = query_dns("AAAA", mail_domain)
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
WWW = query_dns("CNAME", f"www.{mail_domain}")

res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS, WWW=WWW)
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
res["acme_account_url"] = shell(pre_command + "acmetool account-url", fail_ok=True, print=log_progress)
res["dkim_entry"], res["web_dkim_entry"] = get_dkim_entry(
mail_domain, dkim_selector="opendkim"
mail_domain, pre_command, dkim_selector="opendkim"
)

if not MTA_STS or not WWW or (not A and not AAAA):
Expand All @@ -40,11 +40,12 @@ def perform_initial_checks(mail_domain):
return res


def get_dkim_entry(mail_domain, dkim_selector):
def get_dkim_entry(mail_domain, pre_command, dkim_selector):
try:
dkim_pubkey = shell(
f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
f"{pre_command}openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'",
print=log_progress
)
except CalledProcessError:
return
Expand All @@ -61,7 +62,7 @@ def query_dns(typ, domain):
# Get autoritative nameserver from the SOA record.
soa_answers = [
x.split()
for x in shell(f"dig -r -q {domain} -t SOA +noall +authority +answer").split(
for x in shell(f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress).split(
"\n"
)
]
Expand All @@ -71,13 +72,13 @@ def query_dns(typ, domain):
ns = soa[0][4]

# Query authoritative nameserver directly to bypass DNS cache.
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short")
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress)
if res:
return res.split("\n")[0]
return ""


def check_zonefile(zonefile, mail_domain):
def check_zonefile(zonefile, verbose=True):
"""Check expected zone file entries."""
required = True
required_diff = []
Expand All @@ -89,7 +90,7 @@ def check_zonefile(zonefile, mail_domain):
continue
if not zf_line.strip() or zf_line.startswith(";"):
continue
print(f"dns-checking {zf_line!r}")
print(f"dns-checking {zf_line!r}") if verbose else log_progress("")
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip()
Expand Down
9 changes: 8 additions & 1 deletion cmdeploy/src/cmdeploy/remote/rshell.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import sys

from subprocess import DEVNULL, CalledProcessError, check_output


def shell(command, fail_ok=False):
def log_progress(data):
sys.stderr.write(".")
sys.stderr.flush()


def shell(command, fail_ok=False, print=print):
print(f"$ {command}")
args = dict(shell=True)
if fail_ok:
Expand Down
Loading