Compare commits

..

1 Commits

Author SHA1 Message Date
raymond
3cefeee25c fix: Add fallback mechanism for acmetool account URL retrieval
When acmetool account-url command fails, directly read the URL from filesystem.
This ensures cmdeploy dns can proceed even if the acmetool command
is not functioning properly.

Fixes #545
2025-04-09 14:44:33 +02:00
9 changed files with 85 additions and 91 deletions

View File

@@ -2,19 +2,6 @@
## 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.
@@ -27,12 +14,6 @@
- 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))
@@ -40,9 +21,6 @@
- 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))

View File

@@ -11,7 +11,6 @@ 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
@@ -19,21 +18,6 @@ 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():
@@ -246,6 +230,7 @@ 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
@@ -592,12 +577,6 @@ 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)
@@ -609,12 +588,6 @@ 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"],

View File

@@ -86,19 +86,15 @@ 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
try: retcode = out.check_call(cmd, env=env)
retcode = out.check_call(cmd, env=env) if retcode == 0:
if 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 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 else:
else:
out.red("Deploy failed")
except subprocess.CalledProcessError:
out.red("Deploy failed") out.red("Deploy failed")
retcode = 1
return retcode return retcode

View File

@@ -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.
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd localhost:{{ 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
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 10 smtpd localhost:{{ 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

View File

@@ -11,10 +11,49 @@ 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
@@ -26,7 +65,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"] = shell("acmetool account-url", fail_ok=True) res["acme_account_url"] = get_acme_account_url()
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"
) )

View File

@@ -1,13 +1,10 @@
from subprocess import DEVNULL, CalledProcessError, check_output from subprocess import 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, **args).decode().rstrip() return check_output(command, shell=True).decode().rstrip()
except CalledProcessError: except CalledProcessError:
if not fail_ok: if not fail_ok:
raise raise

View File

@@ -90,13 +90,8 @@ 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.settimeout(10) sock.connect((chatmail_config.mail_domain, 25))
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(),))

View File

@@ -55,12 +55,11 @@ 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."""
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp" out = sshexec(call=remote.rshell.shell, kwargs=dict(command="systemctl status opendkim"))
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd)) assert type(out) == str
datestring = out.split("=")[1] since_date_str = out.split("since ")[1].split(";")[0]
since_date = datetime.datetime.strptime(datestring, "%a %Y-%m-%d %H:%M:%S %Z") since_date = datetime.datetime.strptime(since_date_str, "%a %Y-%m-%d %H:%M:%S %Z")
now = datetime.datetime.now(since_date.tzinfo) assert (datetime.datetime.now() - since_date).total_seconds() < 60 * 60 * 24
assert (now - since_date).total_seconds() < 60 * 60 * 51
def test_remote(remote, imap_or_smtp): def test_remote(remote, imap_or_smtp):
@@ -119,12 +118,7 @@ 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()
try: with smtplib.SMTP(cmsetup.maildomain, 25) as s:
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)

View File

@@ -6,6 +6,29 @@ 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
@@ -15,10 +38,9 @@ for the usage in chats, especially DeltaChat.
- 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.
- 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 {{ config.delete_mails_after }} days after arriving on the server.
- Messages are unconditionally removed latest {{ 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).
Earlier, if storage may exceed otherwise.
### <a name="account-deletion"></a> Account deletion ### <a name="account-deletion"></a> Account deletion