mirror of
https://github.com/chatmail/relay.git
synced 2026-05-19 12:28:06 +00:00
Compare commits
16 Commits
fix/acmeto
...
1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a1dd65a6 | ||
|
|
046552061e | ||
|
|
1fba4a3cdf | ||
|
|
44ff6da5d2 | ||
|
|
71160b8f65 | ||
|
|
9f74d0a608 | ||
|
|
c9078d7c92 | ||
|
|
aa4259477f | ||
|
|
21f9885ffe | ||
|
|
f9e885c442 | ||
|
|
b45be700a8 | ||
|
|
9c381e1fbf | ||
|
|
3cc9bc3ceb | ||
|
|
2a89be8209 | ||
|
|
c848b61346 | ||
|
|
49787044ff |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -2,6 +2,19 @@
|
|||||||
|
|
||||||
## untagged
|
## untagged
|
||||||
|
|
||||||
|
|
||||||
|
## 1.6.0 2025-04-11
|
||||||
|
|
||||||
|
- Handle Port-25 connect errors more gracefully (common with VPNs)
|
||||||
|
([#552](https://github.com/chatmail/relay/pull/552))
|
||||||
|
|
||||||
|
- Avoid "acmetool not found" during initial run
|
||||||
|
([#550](https://github.com/chatmail/relay/pull/550))
|
||||||
|
|
||||||
|
- Fix timezone handling such that client/servers do not need to use
|
||||||
|
same timezone.
|
||||||
|
([#553](https://github.com/chatmail/relay/pull/553))
|
||||||
|
|
||||||
- Enforce end-to-end encryption for incoming messages.
|
- Enforce end-to-end encryption for incoming messages.
|
||||||
New user address mailboxes now get a `enforceE2EEincoming` file
|
New user address mailboxes now get a `enforceE2EEincoming` file
|
||||||
which prohibits incoming cleartext messages from other domains.
|
which prohibits incoming cleartext messages from other domains.
|
||||||
@@ -14,6 +27,12 @@
|
|||||||
- Enforce end-to-end encryption between local addresses
|
- Enforce end-to-end encryption between local addresses
|
||||||
([#535](https://github.com/chatmail/server/pull/535))
|
([#535](https://github.com/chatmail/server/pull/535))
|
||||||
|
|
||||||
|
- unbound: check that port 53 is not occupied by a different process
|
||||||
|
([#537](https://github.com/chatmail/server/pull/537))
|
||||||
|
|
||||||
|
- unbound: before unbound is there, use 9.9.9.9 for resolving
|
||||||
|
([#518](https://github.com/chatmail/relay/pull/518))
|
||||||
|
|
||||||
- Limit the bind for the HTTPS server on 8443 to 127.0.0.1
|
- Limit the bind for the HTTPS server on 8443 to 127.0.0.1
|
||||||
([#522](https://github.com/chatmail/server/pull/522))
|
([#522](https://github.com/chatmail/server/pull/522))
|
||||||
([#532](https://github.com/chatmail/server/pull/532))
|
([#532](https://github.com/chatmail/server/pull/532))
|
||||||
@@ -21,6 +40,9 @@
|
|||||||
- Send SNI when connecting to outside servers
|
- Send SNI when connecting to outside servers
|
||||||
([#524](https://github.com/chatmail/server/pull/524))
|
([#524](https://github.com/chatmail/server/pull/524))
|
||||||
|
|
||||||
|
- postfix master.cf: use 127.0.0.1 for consistency
|
||||||
|
([#544](https://github.com/chatmail/relay/pull/544))
|
||||||
|
|
||||||
- Pass through `original_content` instead of `content` in filtermail
|
- Pass through `original_content` instead of `content` in filtermail
|
||||||
([#509](https://github.com/chatmail/server/pull/509))
|
([#509](https://github.com/chatmail/server/pull/509))
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from chatmaild.config import Config, read_config
|
from chatmaild.config import Config, read_config
|
||||||
from pyinfra import facts, host
|
from pyinfra import facts, host
|
||||||
|
from pyinfra.api import FactBase
|
||||||
from pyinfra.facts.files import File
|
from pyinfra.facts.files import File
|
||||||
from pyinfra.facts.systemd import SystemdEnabled
|
from pyinfra.facts.systemd import SystemdEnabled
|
||||||
from pyinfra.operations import apt, files, pip, server, systemd
|
from pyinfra.operations import apt, files, pip, server, systemd
|
||||||
@@ -18,6 +19,21 @@ from pyinfra.operations import apt, files, pip, server, systemd
|
|||||||
from .acmetool import deploy_acmetool
|
from .acmetool import deploy_acmetool
|
||||||
|
|
||||||
|
|
||||||
|
class Port(FactBase):
|
||||||
|
"""
|
||||||
|
Returns the process occuping a port.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def command(self, port: int) -> str:
|
||||||
|
return (
|
||||||
|
"ss -lptn 'src :%d' | awk 'NR>1 {print $6,$7}' | sed 's/users:((\"//;s/\".*//'"
|
||||||
|
% (port,)
|
||||||
|
)
|
||||||
|
|
||||||
|
def process(self, output: [str]) -> str:
|
||||||
|
return output[0]
|
||||||
|
|
||||||
|
|
||||||
def _build_chatmaild(dist_dir) -> None:
|
def _build_chatmaild(dist_dir) -> None:
|
||||||
dist_dir = Path(dist_dir).resolve()
|
dist_dir = Path(dist_dir).resolve()
|
||||||
if dist_dir.exists():
|
if dist_dir.exists():
|
||||||
@@ -230,7 +246,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
|
|||||||
)
|
)
|
||||||
need_restart |= service_file.changed
|
need_restart |= service_file.changed
|
||||||
|
|
||||||
|
|
||||||
return need_restart
|
return need_restart
|
||||||
|
|
||||||
|
|
||||||
@@ -577,6 +592,12 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
|
|||||||
ensure_newline=True,
|
ensure_newline=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if host.get_fact(Port, port=53) != "unbound":
|
||||||
|
files.line(
|
||||||
|
name="Add 9.9.9.9 to resolv.conf",
|
||||||
|
path="/etc/resolv.conf",
|
||||||
|
line="nameserver 9.9.9.9",
|
||||||
|
)
|
||||||
apt.update(name="apt update", cache_time=24 * 3600)
|
apt.update(name="apt update", cache_time=24 * 3600)
|
||||||
apt.upgrade(name="upgrade apt packages", auto_remove=True)
|
apt.upgrade(name="upgrade apt packages", auto_remove=True)
|
||||||
|
|
||||||
@@ -588,6 +609,12 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
|
|||||||
# 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
|
||||||
# to use 127.0.0.1 as the resolver.
|
# to use 127.0.0.1 as the resolver.
|
||||||
|
from cmdeploy.cmdeploy import Out
|
||||||
|
|
||||||
|
process_on_53 = host.get_fact(Port, port=53)
|
||||||
|
if process_on_53 not in (None, "unbound"):
|
||||||
|
Out().red(f"Can't install unbound: port 53 is occupied by: {process_on_53}")
|
||||||
|
exit(1)
|
||||||
apt.packages(
|
apt.packages(
|
||||||
name="Install unbound",
|
name="Install unbound",
|
||||||
packages=["unbound", "unbound-anchor", "dnsutils"],
|
packages=["unbound", "unbound-anchor", "dnsutils"],
|
||||||
|
|||||||
@@ -86,15 +86,19 @@ def run_cmd(args, out):
|
|||||||
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
|
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
retcode = out.check_call(cmd, env=env)
|
try:
|
||||||
if retcode == 0:
|
retcode = out.check_call(cmd, env=env)
|
||||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
if retcode == 0:
|
||||||
elif not remote_data["acme_account_url"]:
|
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||||
out.red("Deploy completed but letsencrypt not configured")
|
elif not remote_data["acme_account_url"]:
|
||||||
out.red("Run 'cmdeploy run' again")
|
out.red("Deploy completed but letsencrypt not configured")
|
||||||
retcode = 0
|
out.red("Run 'cmdeploy run' again")
|
||||||
else:
|
retcode = 0
|
||||||
|
else:
|
||||||
|
out.red("Deploy failed")
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
out.red("Deploy failed")
|
out.red("Deploy failed")
|
||||||
|
retcode = 1
|
||||||
return retcode
|
return retcode
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -77,13 +77,13 @@ scache unix - - y - 1 scache
|
|||||||
postlog unix-dgram n - n - 1 postlogd
|
postlog unix-dgram n - n - 1 postlogd
|
||||||
filter unix - n n - - lmtp
|
filter unix - n n - - lmtp
|
||||||
# Local SMTP server for reinjecting outgoing filtered mail.
|
# Local SMTP server for reinjecting outgoing filtered mail.
|
||||||
localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
|
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
|
||||||
-o syslog_name=postfix/reinject
|
-o syslog_name=postfix/reinject
|
||||||
-o smtpd_milters=unix:opendkim/opendkim.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
|
||||||
localhost:{{ config.postfix_reinject_port_incoming }} inet n - n - 10 smtpd
|
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 10 smtpd
|
||||||
-o syslog_name=postfix/reinject_incoming
|
-o syslog_name=postfix/reinject_incoming
|
||||||
-o smtpd_milters=unix:opendkim/opendkim.sock
|
-o smtpd_milters=unix:opendkim/opendkim.sock
|
||||||
|
|
||||||
|
|||||||
@@ -11,49 +11,10 @@ All functions of this module
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import os
|
|
||||||
import glob
|
|
||||||
|
|
||||||
from .rshell import CalledProcessError, shell
|
from .rshell import CalledProcessError, shell
|
||||||
|
|
||||||
|
|
||||||
def get_acme_account_url():
|
|
||||||
"""Get the acmetool account URL with fallback methods.
|
|
||||||
|
|
||||||
First tries the acmetool command, then falls back to searching the filesystem
|
|
||||||
if the command fails or returns empty.
|
|
||||||
"""
|
|
||||||
# Try the acmetool command first
|
|
||||||
acme_url = shell("acmetool account-url", fail_ok=True)
|
|
||||||
if acme_url:
|
|
||||||
return acme_url
|
|
||||||
|
|
||||||
# Fallback: search for URL files in acme accounts directory
|
|
||||||
try:
|
|
||||||
acct_base = "/var/lib/acme/accounts/"
|
|
||||||
# Find Let's Encrypt directory
|
|
||||||
le_dirs = glob.glob(os.path.join(acct_base, "*letsencrypt*"))
|
|
||||||
if not le_dirs:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# Find account directories
|
|
||||||
for le_dir in le_dirs:
|
|
||||||
acct_dirs = glob.glob(os.path.join(le_dir, "*"))
|
|
||||||
for acct_dir in acct_dirs:
|
|
||||||
url_file = os.path.join(acct_dir, "url")
|
|
||||||
if os.path.isfile(url_file):
|
|
||||||
# Read the URL file content
|
|
||||||
with open(url_file, "r") as f:
|
|
||||||
url = f.read().strip()
|
|
||||||
if url:
|
|
||||||
return url
|
|
||||||
except Exception:
|
|
||||||
# Any exception during fallback should be ignored
|
|
||||||
pass
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def perform_initial_checks(mail_domain):
|
def perform_initial_checks(mail_domain):
|
||||||
"""Collecting initial DNS settings."""
|
"""Collecting initial DNS settings."""
|
||||||
assert mail_domain
|
assert mail_domain
|
||||||
@@ -65,7 +26,7 @@ def perform_initial_checks(mail_domain):
|
|||||||
WWW = query_dns("CNAME", f"www.{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 = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS, WWW=WWW)
|
||||||
res["acme_account_url"] = get_acme_account_url()
|
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
|
||||||
res["dkim_entry"], res["web_dkim_entry"] = get_dkim_entry(
|
res["dkim_entry"], res["web_dkim_entry"] = get_dkim_entry(
|
||||||
mail_domain, dkim_selector="opendkim"
|
mail_domain, dkim_selector="opendkim"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from subprocess import CalledProcessError, check_output
|
from subprocess import DEVNULL, CalledProcessError, check_output
|
||||||
|
|
||||||
|
|
||||||
def shell(command, fail_ok=False):
|
def shell(command, fail_ok=False):
|
||||||
print(f"$ {command}")
|
print(f"$ {command}")
|
||||||
|
args = dict(shell=True)
|
||||||
|
if fail_ok:
|
||||||
|
args["stderr"] = DEVNULL
|
||||||
try:
|
try:
|
||||||
return check_output(command, shell=True).decode().rstrip()
|
return check_output(command, **args).decode().rstrip()
|
||||||
except CalledProcessError:
|
except CalledProcessError:
|
||||||
if not fail_ok:
|
if not fail_ok:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -90,8 +90,13 @@ def test_concurrent_logins_same_account(
|
|||||||
|
|
||||||
|
|
||||||
def test_no_vrfy(chatmail_config):
|
def test_no_vrfy(chatmail_config):
|
||||||
|
domain = chatmail_config.mail_domain
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.connect((chatmail_config.mail_domain, 25))
|
sock.settimeout(10)
|
||||||
|
try:
|
||||||
|
sock.connect((domain, 25))
|
||||||
|
except socket.timeout:
|
||||||
|
pytest.skip(f"port 25 not reachable for {domain}")
|
||||||
banner = sock.recv(1024)
|
banner = sock.recv(1024)
|
||||||
print(banner)
|
print(banner)
|
||||||
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
||||||
|
|||||||
@@ -55,11 +55,12 @@ class TestSSHExecutor:
|
|||||||
|
|
||||||
def test_opendkim_restarted(self, sshexec):
|
def test_opendkim_restarted(self, sshexec):
|
||||||
"""check that opendkim is not running for longer than a day."""
|
"""check that opendkim is not running for longer than a day."""
|
||||||
out = sshexec(call=remote.rshell.shell, kwargs=dict(command="systemctl status opendkim"))
|
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
|
||||||
assert type(out) == str
|
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
|
||||||
since_date_str = out.split("since ")[1].split(";")[0]
|
datestring = out.split("=")[1]
|
||||||
since_date = datetime.datetime.strptime(since_date_str, "%a %Y-%m-%d %H:%M:%S %Z")
|
since_date = datetime.datetime.strptime(datestring, "%a %Y-%m-%d %H:%M:%S %Z")
|
||||||
assert (datetime.datetime.now() - since_date).total_seconds() < 60 * 60 * 24
|
now = datetime.datetime.now(since_date.tzinfo)
|
||||||
|
assert (now - since_date).total_seconds() < 60 * 60 * 51
|
||||||
|
|
||||||
|
|
||||||
def test_remote(remote, imap_or_smtp):
|
def test_remote(remote, imap_or_smtp):
|
||||||
@@ -118,7 +119,12 @@ def test_authenticated_from(cmsetup, maildata):
|
|||||||
def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
||||||
recipient = cmsetup.gen_users(1)[0]
|
recipient = cmsetup.gen_users(1)[0]
|
||||||
msg = maildata("encrypted.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
|
msg = maildata("encrypted.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
|
||||||
with smtplib.SMTP(cmsetup.maildomain, 25) as s:
|
try:
|
||||||
|
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
|
||||||
|
except TimeoutError:
|
||||||
|
pytest.skip(f"port 25 not reachable for {cmsetup.maildomain}")
|
||||||
|
|
||||||
|
with conn as s:
|
||||||
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -6,29 +6,6 @@ interoperable e-mail service for everyone. What's behind a `chatmail` is
|
|||||||
effectively a normal e-mail address just like any other but optimized
|
effectively a normal e-mail address just like any other but optimized
|
||||||
for the usage in chats, especially DeltaChat.
|
for the usage in chats, especially DeltaChat.
|
||||||
|
|
||||||
### Choosing a chatmail address instead of using a random one
|
|
||||||
|
|
||||||
In the Delta Chat account setup you may tap `Create a profile` then `Use other server` and choose `Classic e-mail login`. Here fill the two fields like this:
|
|
||||||
|
|
||||||
- `E-Mail Address`: invent a word with
|
|
||||||
{% if username_min_length == username_max_length %}
|
|
||||||
*exactly* {{ username_min_length }}
|
|
||||||
{% else %}
|
|
||||||
{{ username_min_length}}
|
|
||||||
{% if username_max_length == "more" %}
|
|
||||||
or more
|
|
||||||
{% else %}
|
|
||||||
to {{ username_max_length }}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
characters
|
|
||||||
and append `@{{config.mail_domain}}` to it.
|
|
||||||
|
|
||||||
- `Existing Password`: invent at least {{ password_min_length }} characters.
|
|
||||||
|
|
||||||
If the e-mail address is not yet taken, you'll get that account.
|
|
||||||
The first login sets your password.
|
|
||||||
|
|
||||||
|
|
||||||
### Rate and storage limits
|
### Rate and storage limits
|
||||||
|
|
||||||
@@ -38,10 +15,11 @@ The first login sets your password.
|
|||||||
|
|
||||||
- You may send up to {{ config.max_user_send_per_minute }} messages per minute.
|
- You may send up to {{ config.max_user_send_per_minute }} messages per minute.
|
||||||
|
|
||||||
- Messages are unconditionally removed {{ config.delete_mails_after }} days after arriving on the server.
|
|
||||||
|
|
||||||
- You can store up to [{{ config.max_mailbox_size }} messages on the server](https://delta.chat/en/help#what-happens-if-i-turn-on-delete-old-messages-from-server).
|
- You can store up to [{{ config.max_mailbox_size }} messages on the server](https://delta.chat/en/help#what-happens-if-i-turn-on-delete-old-messages-from-server).
|
||||||
|
|
||||||
|
- Messages are unconditionally removed latest {{ config.delete_mails_after }} days after arriving on the server.
|
||||||
|
Earlier, if storage may exceed otherwise.
|
||||||
|
|
||||||
|
|
||||||
### <a name="account-deletion"></a> Account deletion
|
### <a name="account-deletion"></a> Account deletion
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user