Compare commits

...

41 Commits

Author SHA1 Message Date
missytake
28389f4ab6 tests: SMTPDataError doesn't have recipients 2025-10-17 19:41:16 +02:00
missytake
00ca6533e5 docs: document which services are involved in delivering an internal mail 2025-10-17 17:17:41 +02:00
missytake
de5d53f6e7 postfix: accept whole mail before passing it to filtermail 2025-10-17 17:15:43 +02:00
missytake
f21e4ff55b opendkim: increase DNSTimeout from 5 (default) to 60
fix #667
2025-10-17 11:27:18 +02:00
cliffmccarthy
21258a267a test: Handle Git errors in test_deployed_state()
- This is a counterpart to pull request #607.  Revised
  test_deployed_state() to perform the same error-handling on Git
  commands that cmdeploy does.  If 'git rev-parse' returns an error,
  the value "unknown" is used.  If 'git diff' returns an error, the
  null string is used.
- This fixes failures in environments where Git is not installed or
  where the .git subdirectory is not present (as long as the server
  was deployed in the same way).
2025-10-16 16:15:35 +02:00
missytake
e7ddf6dc32 cmdeploy: make --ssh-host expect '@docker' instead of 'docker' 2025-10-14 22:27:02 +02:00
missytake
e3c77a5b37 cmdeploy: introduce LocalExec object 2025-10-14 22:27:02 +02:00
missytake
8256080ad1 Revert "tests: first attempt to mock shell() call"
This reverts commit a0c632a7006a83c8b39cff86228296c32c5c5b9e.
2025-10-14 22:27:02 +02:00
missytake
248b225665 tests: first attempt to mock shell() call 2025-10-14 22:27:02 +02:00
missytake
79591adca4 cmdeploy: prepare for being able to run commands in docker containers 2025-10-14 22:27:02 +02:00
missytake
185757cf40 tests: disable failing stderr capturing in test_logged for now 2025-10-14 22:27:02 +02:00
missytake
87a3adec03 cmdeploy: allow to run SSH commands locally
fix #604
related to #629
pulled out of https://github.com/Keonik1/relay/pull/3
2025-10-14 22:27:02 +02:00
cliffmccarthy
4f5719f590 test: Add retries to test_rewrite_subject() (#670)
- test_rewrite_subject() is prone to failure when it checks for the
  delivered message, because fetch_all_messages() raises "ValueError:
  no messages in imap folder".  The check has the potential to happen
  before the server has had a chance to deliver the message to the
  user's inbox.
- Added a function try_n_times() that attempts to call a function the
  specified number of times, with a 1-second sleep between calls.  The
  call is retried until it doesn't raise an exception.  The last call
  is made without a 'try' block, so that the final exception passes
  through to the caller if it does not return.
- Wrapped call to fetch_all_messages() in try_n_times(), with 5
  attempts specified.  This should usually allow enough time for the
  message to get moved from the postfix queue to the user's inbox.
2025-10-14 21:18:15 +02:00
cliffmccarthy
9787b63cbb test: Return None for success in test_timezone_env() (#671)
- test_timezone_env() is producing the warning,
  "PytestReturnNotNoneWarning: Test functions should return None, but
  src/cmdeploy/tests/online/test_1_basic.py::test_timezone_env
  returned <class 'bool'>".
- Revised test_timezone_env() to return None for success instead of
  True.
2025-10-14 21:17:56 +02:00
missytake
6f600fa329 config: add www_folder to default config (#634) 2025-10-14 21:17:08 +02:00
missytake
20b6e0c528 www: chown /var/www/html to www-data 2025-10-14 21:16:49 +02:00
missytake
262e98f0ba filtermail: allow Version comment in incoming PGP messages (#655)
fix #616

* filtermail: accept any Version comment in incoming messages
2025-10-14 19:15:13 +02:00
cliffmccarthy
d720b8107d Don't print echobot link when disabling mail
- On a fresh install, if cmdeploy is run the first time with the
  --disable-mail option, the echobot invite-link.txt file will not
  exist yet.
- Only print the echobot invite link if --disable-mail was not
  specified.  This fixes the fresh-install error case, and also makes
  sense when disabling mail in general, because the echo bot will not
  be available at that time.
2025-10-13 21:46:47 +02:00
link2xt
d7f50183ea feat: setup TURN server 2025-10-10 18:32:32 +00:00
missytake
248603ab0a cmdeploy: remove colors from cmdeploy init again, hard to test 2025-10-09 23:54:44 +02:00
missytake
123531f1eb cmdeploy: add --force to cmdeploy init for recreating chatmail.ini 2025-10-09 23:54:44 +02:00
Keonik1
1170adc1d4 cmdeploy: start and enable fcgiwrap 2025-10-08 13:11:02 +02:00
missytake
a6f7ff3652 ci: skip DNS checks during cmdeploy run 2025-10-08 13:07:24 +02:00
Keonik1
d39076f0d6 cmdeploy: cmdeploy run option to skip DNS checks 2025-10-08 13:07:24 +02:00
Keonik1
65c0bf13f2 cmdeploy: add acme_email config value 2025-10-08 13:06:48 +02:00
link2xt
0ed7c360a9 Update changelog 2025-10-05 02:37:50 +00:00
link2xt
af272545dd Restart iroh-relay if the binary is updated 2025-10-05 02:37:23 +00:00
link2xt
7725a73cf5 Ensure that downloaded iroh-relay matches expected SHA-256 sum
Previously we only used SHA-256 sum
to check if we need to update the binary.
2025-10-05 02:37:23 +00:00
link2xt
e65311c0df Update iroh-relay to 0.35.0 2025-10-05 02:37:23 +00:00
link2xt
d091b865c7 fix: ignore all RCPT TO: parameters
Stalwart sends `NOTIFY=DELAY,FAILURE`
to request Delivery Status Notifications.
aiosmtpd does not support any parameters,
not just ORCPT, so we have to ignore all of them.
2025-10-05 02:36:40 +00:00
cliffmccarthy
6e28cf9ca1 Add CHANGELOG.md entry for #648 2025-10-03 19:48:32 +00:00
cliffmccarthy
9b6dfa9cdc Use max username length in newemail.py, not min
- username_min_length and username_max_length are both set to a
  default value of 9 in the chatmail.ini.f template.  When they have
  the same value, it doesn't matter which one we use in newemail.py
  (which handles the /new URL).  However, if they are configured to
  different values by the admin, then the current implementation using
  username_min_length chooses from a smaller set of possible
  usernames.
- Revised create_newemail_dict() in newemail.py to use
  username_max_length as the length of the random username it offers
  via the /new URL.  This randomizes within a much larger set of
  possible usernames.
2025-10-03 19:48:32 +00:00
missytake
44ab006dca echobot: restart after postfix + dovecot were started (#642)
* echobot: restart after postfix + dovecot were started

fix #641

* cmdeploy: restart echobot only if dovecot *and* postfix were restarted
2025-09-25 09:00:26 +02:00
link2xt
c56805211f Increase maxproc for reinjecting ports from 10 to 100
Otherwise under high load filtermail
starts printing "Connection refused" errors to the log.
2025-09-24 16:10:26 +00:00
missytake
05ec64bf4a fix link to Mutual Help group 2025-09-23 13:42:47 +02:00
link2xt
290e80e795 Revert "dovecot: keep mailbox index only in memory (#632)"
This reverts commit 7bf2dfd62e.
2025-09-22 22:55:57 +00:00
missytake
56fab1b071 CI: fix lint (#633) 2025-09-22 12:57:43 +02:00
link2xt
00ab53800e Update changelog 2025-09-18 15:28:15 +00:00
link2xt
fc65072edb Allow ports 143 and 993 to be used by dovecot process 2025-09-18 15:26:58 +00:00
missytake
7bf2dfd62e dovecot: keep mailbox index only in memory (#632)
Co-authored-by: holger krekel  <holger@merlinux.eu>
2025-09-12 09:30:17 +02:00
missytake
b801838b69 doc: released 1.7.0 2025-09-12 00:55:49 +02:00
26 changed files with 403 additions and 110 deletions

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Mutual Help Chat Group
url: https://i.delta.chat/#C2846EB4C1CB8DF84B1818F5E3A638FC3FBDC981&a=stalebot1%40nine.testrun.org&g=Chatmail%20Mutual%20Help&x=7sFF7Ik50pWv6J1z7RVC5527&i=d7s1HvOsk5UrSf9AoqRZggg4&s=XmX_9BAW6-g5Ao5E8PyaeKNB
url: https://i.delta.chat/#6CBFF8FFD505C0FDEA20A66674F2916EA8FBEE99&a=invitebot%40nine.testrun.org&g=Chatmail%20Mutual%20Help&x=7sFF7Ik50pWv6J1z7RVC5527&i=X69wTFfvCfs3d-JzqP0kVA3i&s=ibp-447dU-wUq-52QanwAtWc
about: If you have troubles setting up the relay server, feel free to ask here.

View File

@@ -77,7 +77,7 @@ jobs:
cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
- run: cmdeploy run
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |

View File

@@ -75,7 +75,7 @@ jobs:
- run: cmdeploy init staging2.testrun.org
- run: cmdeploy run --verbose
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |

View File

@@ -48,3 +48,19 @@ graph LR;
The edges in this graph should not be taken too literally; they
reflect some sort of communication path or dependency relationship
between components of the chatmail server.
## Message between users on the same relay
```mermaid
graph LR;
chatmail core --> |465|smtps/smtpd;
chatmail core --> |587|submission/smtpd;
smtps/smtpd --> |10080|filtermail;
submission/smtpd --> |10080|filtermail;
filtermail --> |10025|smtpd reinject;
smtpd reinject --> cleanup;
cleanup --> qmgr;
qmgr --> smtpd accepts message;
qmgr --> |lmtp|dovecot;
dovecot --> chatmail core;
```

View File

@@ -2,6 +2,50 @@
## untagged
- Setup TURN server
([#621](https://github.com/chatmail/relay/pull/621))
- 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))
- postfix: accept whole mail before passing it to filtermail
([#673](https://github.com/chatmail/relay/pull/673))
- filtermail: accept mails from Protonmail
([#616](https://github.com/chatmail/relay/pull/655))
- Ignore all RCPT TO: parameters
([#651](https://github.com/chatmail/relay/pull/651))
- Increase opendkim DNS Timeout from 5 to 60 seconds
([#672](https://github.com/chatmail/relay/pull/672))
- Add config parameter for Let's Encrypt ACME email
([#663](https://github.com/chatmail/relay/pull/663))
- Use max username length in newemail.py, not min
([#648](https://github.com/chatmail/relay/pull/648))
- Add startup for `fcgiwrap.service` because sometimes it did not start automatically.
([#657](https://github.com/chatmail/relay/pull/657))
- Add `cmdeploy init --force` command for recreating chatmail.ini
([#656](https://github.com/chatmail/relay/pull/656))
- Increase maxproc for reinjecting ports from 10 to 100
([#646](https://github.com/chatmail/relay/pull/646))
- Allow ports 143 and 993 to be used by `dovecot` process
([#639](https://github.com/chatmail/relay/pull/639))
- Add `--skip-dns-check` argument to `cmdeploy run` command, which disables DNS record checking before installation.
([#661](https://github.com/chatmail/relay/pull/661))
## 1.7.0 2025-09-11
- Make www upload path configurable
([#618](https://github.com/chatmail/relay/pull/618))

View File

@@ -29,6 +29,7 @@ echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main"
delete_inactive_users = "chatmaild.delete_inactive_users:main"
lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"

View File

@@ -44,6 +44,7 @@ class Config:
)
self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
if "iroh_relay" not in params:
self.iroh_relay = "https://" + params["mail_domain"]

View File

@@ -83,8 +83,14 @@ def check_openpgp_payload(payload: bytes):
return False
def check_armored_payload(payload: str):
prefix = "-----BEGIN PGP MESSAGE-----\r\n\r\n"
def check_armored_payload(payload: str, outgoing: bool):
"""Check the armored PGP message for invalid content.
:param payload: the armored PGP message
:param outgoing: whether the message is outgoing or incoming
:return: whether the message is a valid PGP message
"""
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
if not payload.startswith(prefix):
return False
payload = payload.removeprefix(prefix)
@@ -96,6 +102,17 @@ def check_armored_payload(payload: str):
return False
payload = payload.removesuffix(suffix)
# Disallow comments in outgoing messages
version_comment = "Version: "
if payload.startswith(version_comment):
version_line = payload.splitlines()[0]
payload = payload.removeprefix(version_line)
if outgoing:
return False
while payload.startswith("\r\n"):
payload = payload.removeprefix("\r\n")
# Remove CRC24.
payload = payload.rpartition("=")[0]
@@ -131,7 +148,7 @@ def is_securejoin(message):
return True
def check_encrypted(message):
def check_encrypted(message, outgoing=True):
"""Check that the message is an OpenPGP-encrypted message.
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
@@ -158,7 +175,7 @@ def check_encrypted(message):
if part.get_content_type() != "application/octet-stream":
return False
if not check_armored_payload(part.get_payload()):
if not check_armored_payload(part.get_payload(), outgoing=outgoing):
return False
else:
return False
@@ -197,11 +214,13 @@ class HackedController(Controller):
class SMTPDiscardRCPTO_options(SMTP):
def _getparams(self, params):
# aiosmtpd's SMTP daemon fails to handle a request if there are RCPT TO options
# We just ignore them for our incoming filtermail purposes
if len(params) == 1 and params[0].startswith("ORCPT"):
return {}
return super()._getparams(params)
# Ignore RCPT TO parameters.
#
# Otherwise parameters such as `ORCPT=...`
# or `NOTIFY=DELAY,FAILURE` (generated by Stalwart)
# make aiosmtpd reject the message here:
# <https://github.com/aio-libs/aiosmtpd/blob/98f578389ae86e5345cc343fa4e5a17b21d9c96d/aiosmtpd/smtp.py#L1379-L1384>
return {}
class OutgoingBeforeQueueHandler:
@@ -239,7 +258,7 @@ class OutgoingBeforeQueueHandler:
logging.info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
mail_encrypted = check_encrypted(message, outgoing=True)
_, from_addr = parseaddr(message.get("from").strip())
@@ -299,7 +318,7 @@ class IncomingBeforeQueueHandler:
logging.info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
mail_encrypted = check_encrypted(message, outgoing=False)
if mail_encrypted or is_securejoin(message):
print("Incoming: Filtering encrypted mail.", file=sys.stderr)

View File

@@ -45,6 +45,9 @@ passthrough_senders =
# (space-separated, item may start with "@" to whitelist whole recipient domains)
passthrough_recipients = xstore@testrun.org echo@{mail_domain}
# path to www directory - documented here: https://github.com/chatmail/relay/#custom-web-pages
#www_folder = www
#
# Deployment Details
#
@@ -60,6 +63,9 @@ postfix_reinject_port_incoming = 10026
# if set to "True" IPv6 is disabled
disable_ipv6 = False
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
acme_email =
# 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

View File

@@ -7,6 +7,7 @@ from .config import read_config
from .dictproxy import DictProxy
from .filedict import FileDict
from .notifier import Notifier
from .turnserver import turn_credentials
def _is_valid_token_timestamp(timestamp, now):
@@ -75,11 +76,12 @@ class Metadata:
class MetadataDictProxy(DictProxy):
def __init__(self, notifier, metadata, iroh_relay=None):
def __init__(self, notifier, metadata, iroh_relay=None, turn_hostname=None):
super().__init__()
self.notifier = notifier
self.metadata = metadata
self.iroh_relay = iroh_relay
self.turn_hostname = turn_hostname
def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
@@ -98,6 +100,11 @@ class MetadataDictProxy(DictProxy):
):
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
return f"O{self.iroh_relay}\n"
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
res = turn_credentials()
port = 3478
return f"O{self.turn_hostname}:{port}:{res}\n"
logging.warning(f"lookup ignored: {parts!r}")
return "N\n"
@@ -121,6 +128,7 @@ def main():
config = read_config(config_path)
iroh_relay = config.iroh_relay
mail_domain = config.mail_domain
vmail_dir = config.mailboxes_dir
if not vmail_dir.exists():
@@ -134,7 +142,10 @@ def main():
notifier.start_notification_threads(metadata.remove_token_from_addr)
dictproxy = MetadataDictProxy(
notifier=notifier, metadata=metadata, iroh_relay=iroh_relay
notifier=notifier,
metadata=metadata,
iroh_relay=iroh_relay,
turn_hostname=mail_domain,
)
dictproxy.serve_forever_from_socket(socket)

View File

@@ -15,7 +15,7 @@ ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
def create_newemail_dict(config: Config):
user = "".join(random.choices(ALPHANUMERIC, k=config.username_min_length))
user = "".join(random.choices(ALPHANUMERIC, k=config.username_max_length))
password = "".join(
secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3)

View File

@@ -241,8 +241,9 @@ def test_cleartext_passthrough_senders(gencreds, handler, maildata):
def test_check_armored_payload():
payload = """-----BEGIN PGP MESSAGE-----\r
\r
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
comment = "Version: ProtonMail\r\n"
payload = """\r
wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r
Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r
755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r
@@ -278,16 +279,25 @@ UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
\r
"""
assert check_armored_payload(payload) == True
commented_payload = prefix + comment + payload
assert check_armored_payload(commented_payload, outgoing=False) == True
assert check_armored_payload(commented_payload, outgoing=True) == False
payload = prefix + payload
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload) == True
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload) == True
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload) == True
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = """-----BEGIN PGP MESSAGE-----\r
\r
@@ -295,7 +305,8 @@ HELLOWORLD
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload) == False
assert check_armored_payload(payload, outgoing=False) == False
assert check_armored_payload(payload, outgoing=True) == False
payload = """-----BEGIN PGP MESSAGE-----\r
\r
@@ -303,7 +314,8 @@ HELLOWORLD
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload) == False
assert check_armored_payload(payload, outgoing=False) == False
assert check_armored_payload(payload, outgoing=True) == False
# Test payload using partial body length
# as generated by GopenPGP.
@@ -345,4 +357,5 @@ myLbG7cJB787QjplEyVe2P/JBO6xYvbkJLf9Q+HaviTO25rugRSrYsoKMDfO8VlQ\r
=6iHb\r
-----END PGP MESSAGE-----\r
"""
assert check_armored_payload(payload) == True
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env python3
import socket
def turn_credentials() -> str:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.connect("/run/chatmail-turn/turn.socket")
with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8")

View File

@@ -13,7 +13,7 @@ from pathlib import Path
from chatmaild.config import Config, read_config
from pyinfra import facts, host, logger
from pyinfra.api import FactBase
from pyinfra.facts.files import File
from pyinfra.facts.files import File, Sha256File
from pyinfra.facts.server import Sysctl
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd
@@ -128,6 +128,7 @@ def _install_remote_venv_with_chatmaild(config) -> None:
"echobot",
"chatmail-metadata",
"lastlogin",
"turnserver",
):
execpath = fn if fn != "filtermail-incoming" else "filtermail"
params = dict(
@@ -497,6 +498,56 @@ def check_config(config):
return config
def deploy_turn_server(config):
(url, sha256sum) = {
"x86_64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux",
"841e527c15fdc2940b0469e206188ea8f0af48533be12ecb8098520f813d41e4",
),
"aarch64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-aarch64-linux",
"a5fc2d06d937b56a34e098d2cd72a82d3e89967518d159bf246dc69b65e81b42",
),
}[host.get_fact(facts.server.Arch)]
need_restart = False
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/chatmail-turn")
if existing_sha256sum != sha256sum:
server.shell(
name="Download chatmail-turn",
commands=[
f"(curl -L {url} >/usr/local/bin/chatmail-turn.new && (echo '{sha256sum} /usr/local/bin/chatmail-turn.new' | sha256sum -c) && mv /usr/local/bin/chatmail-turn.new /usr/local/bin/chatmail-turn)",
"chmod 755 /usr/local/bin/chatmail-turn",
],
)
need_restart = True
source_path = importlib.resources.files(__package__).joinpath(
"service", "turnserver.service.f"
)
content = source_path.read_text().format(mail_domain=config.mail_domain).encode()
systemd_unit = files.put(
name="Upload turnserver.service",
src=io.BytesIO(content),
dest="/etc/systemd/system/turnserver.service",
user="root",
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
systemd.service(
name="Setup turnserver service",
service="turnserver.service",
running=True,
enabled=True,
restarted=need_restart,
daemon_reload=systemd_unit.changed,
)
def deploy_mtail(config):
# Uninstall mtail package, we are going to install a static binary.
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False)
@@ -555,12 +606,12 @@ def deploy_mtail(config):
def deploy_iroh_relay(config) -> None:
(url, sha256sum) = {
"x86_64": (
"https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-x86_64-unknown-linux-musl.tar.gz",
"2ffacf7c0622c26b67a5895ee8e07388769599f60e5f52a3bd40a3258db89b2c",
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-x86_64-unknown-linux-musl.tar.gz",
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
),
"aarch64": (
"https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-aarch64-unknown-linux-musl.tar.gz",
"b915037bcc1ff1110cc9fcb5de4a17c00ff576fd2f568cd339b3b2d54c420dc4",
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-aarch64-unknown-linux-musl.tar.gz",
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
),
}[host.get_fact(facts.server.Arch)]
@@ -569,16 +620,19 @@ def deploy_iroh_relay(config) -> None:
packages=["curl"],
)
server.shell(
name="Download iroh-relay",
commands=[
f"(echo '{sha256sum} /usr/local/bin/iroh-relay' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
"chmod 755 /usr/local/bin/iroh-relay",
],
)
need_restart = False
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay")
if existing_sha256sum != sha256sum:
server.shell(
name="Download iroh-relay",
commands=[
f"(curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && (echo '{sha256sum} /usr/local/bin/iroh-relay.new' | sha256sum -c) && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
"chmod 755 /usr/local/bin/iroh-relay",
],
)
need_restart = True
systemd_unit = files.put(
name="Upload iroh-relay systemd unit",
src=importlib.resources.files(__package__).joinpath("iroh-relay.service"),
@@ -670,6 +724,8 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
packages=["rsync"],
)
deploy_turn_server(config)
# Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf
# to use 127.0.0.1 as the resolver.
@@ -679,11 +735,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
(["master", "smtpd"], 25),
("unbound", 53),
("acmetool", 80),
("imap-login", 143),
(["imap-login", "dovecot"], 143),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
("imap-login", 993),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
@@ -696,7 +752,9 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
running_service = host.get_fact(Port, port=port)
if running_service:
if running_service not in service:
Out().red(f"Deploy failed: port {port} is occupied by: {running_service}")
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
apt.packages(
@@ -722,6 +780,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
# Deploy acmetool to have TLS certificates.
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool(
email=config.acme_email,
domains=tls_domains,
)
@@ -760,7 +819,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
if build_dir:
www_path = build_webpages(src_dir, build_dir, config)
# if it is not a hugo page, upload it as is
files.rsync(f"{www_path}/", "/var/www/html", flags=["-avz"])
files.rsync(f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"])
_install_remote_venv_with_chatmaild(config)
debug = False
@@ -808,6 +867,19 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
restarted=nginx_need_restart,
)
systemd.service(
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)
systemd.service(
name="Restart echobot if postfix and dovecot were just started",
service="echobot.service",
restarted=postfix_need_restart and dovecot_need_restart,
)
# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName
server.shell(

View File

@@ -19,7 +19,7 @@ from packaging import version
from termcolor import colored
from . import dns, remote
from .sshexec import SSHExec
from .sshexec import SSHExec, LocalExec
#
# cmdeploy sub commands and options
@@ -32,17 +32,30 @@ def init_cmd_options(parser):
action="store",
help="fully qualified DNS domain name for your chatmail instance",
)
parser.add_argument(
"--force",
dest="recreate_ini",
action="store_true",
help="force reacreate ini file",
)
def init_cmd(args, out):
"""Initialize chatmail config file."""
mail_domain = args.chatmail_domain
inipath = args.inipath
if args.inipath.exists():
print(f"Path exists, not modifying: {args.inipath}")
return 1
else:
write_initial_config(args.inipath, mail_domain, overrides={})
out.green(f"created config file for {mail_domain} in {args.inipath}")
if not args.recreate_ini:
print(f"[WARNING] Path exists, not modifying: {inipath}")
return 1
else:
print(
f"[WARNING] Force argument was provided, deleting config file: {inipath}"
)
inipath.unlink()
write_initial_config(inipath, mail_domain, overrides={})
out.green(f"created config file for {mail_domain} in {inipath}")
def run_cmd_options(parser):
@@ -59,20 +72,24 @@ def run_cmd_options(parser):
help="install/upgrade the server, but disable postfix & dovecot for now",
)
parser.add_argument(
"--ssh-host",
dest="ssh_host",
help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default",
"--skip-dns-check",
dest="dns_check_disabled",
action="store_true",
help="disable checks nslookup for dns",
)
add_ssh_host_option(parser)
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):
return 1
if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1
env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath
@@ -80,8 +97,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
@@ -89,14 +109,15 @@ def run_cmd(args, out):
try:
retcode = out.check_call(cmd, env=env)
if retcode == 0:
print("\nYou can try out the relay by talking to this echo bot: ")
sshexec = SSHExec(args.config.mail_domain, verbose=args.verbose)
print(
sshexec(
call=remote.rshell.shell,
kwargs=dict(command="cat /var/lib/echobot/invite-link.txt"),
if not args.disable_mail:
print("\nYou can try out the relay by talking to this echo bot: ")
sshexec = SSHExec(args.config.mail_domain, verbose=args.verbose)
print(
sshexec(
call=remote.rshell.shell,
kwargs=dict(command="cat /var/lib/echobot/invite-link.txt"),
)
)
)
out.green("Deploy completed, call `cmdeploy dns` next.")
elif not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
@@ -118,11 +139,13 @@ def dns_cmd_options(parser):
default=None,
help="write out a zonefile",
)
add_ssh_host_option(parser)
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
@@ -276,6 +299,15 @@ class Out:
return proc.returncode
def add_ssh_host_option(parser):
parser.add_argument(
"--ssh-host",
dest="ssh_host",
help="Run commands on 'localhost', via '@docker', or on a specific SSH host "
"instead of chatmail.ini's mail_domain.",
)
def add_config_option(parser):
parser.add_argument(
"--config",
@@ -331,6 +363,16 @@ def get_parser():
return parser
def get_sshexec(ssh_host: str, verbose=True):
if ssh_host in ["localhost", "@local"]:
return LocalExec(verbose, docker=False)
elif ssh_host == "@docker":
return LocalExec(verbose, docker=True)
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()
@@ -338,12 +380,6 @@ def main(args=None):
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"):

View File

@@ -45,8 +45,7 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
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"]),
remote.rdns.check_zonefile, kwargs=dict(zonefile=zonefile, verbose=False),
)
returncode = 0

View File

@@ -1,5 +1,11 @@
enable_relay = true
http_bind_addr = "[::]:3340"
enable_stun = true
# Disable built-in STUN server in iroh-relay 0.35
# as we deploy our own TURN server instead.
# STUN server is going to be removed in iroh-relay 1.0
# and this line can be removed after upgrade.
enable_stun = false
enable_metrics = false
metrics_bind_addr = "127.0.0.1:9092"

View File

@@ -13,6 +13,7 @@ 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),

View File

@@ -31,6 +31,7 @@ submission inet n - y - 5000 smtpd
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_options=speed_adjust
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
smtps inet n - y - 5000 smtpd
-o syslog_name=postfix/smtps
@@ -48,6 +49,7 @@ smtps inet n - y - 5000 smtpd
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit=1000
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_options=speed_adjust
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
#628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup
@@ -77,13 +79,13 @@ scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp
# Local SMTP server for reinjecting outgoing filtered mail.
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean
# Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 10 smtpd
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject_incoming
-o smtpd_milters=unix:opendkim/opendkim.sock

View File

@@ -12,23 +12,23 @@ All functions of this module
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):
@@ -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
@@ -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"
)
]
@@ -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 = []
@@ -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()

View File

@@ -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:

View File

@@ -0,0 +1,16 @@
[Unit]
Description=A wrapper for the TURN server
After=network.target
[Service]
Type=simple
Restart=always
ExecStart=/usr/local/bin/chatmail-turn --realm {mail_domain} --socket /run/chatmail-turn/turn.socket
# Create /run/chatmail-turn
RuntimeDirectory=chatmail-turn
User=vmail
Group=vmail
[Install]
WantedBy=multi-user.target

View File

@@ -42,6 +42,7 @@ def bootstrap_remote(gateway, remote=remote):
def print_stderr(item="", end="\n"):
print(item, file=sys.stderr, end=end)
sys.stderr.flush()
class SSHExec:
@@ -70,10 +71,6 @@ class SSHExec:
raise self.FuncError(data)
def logged(self, call, kwargs):
def log_progress(data):
sys.stderr.write(".")
sys.stderr.flush()
title = call.__doc__
if not title:
title = call.__name__
@@ -82,6 +79,22 @@ class SSHExec:
return self(call, kwargs, log_callback=print_stderr)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=log_progress)
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
print_stderr()
return res
class LocalExec:
def __init__(self, verbose=False, docker=False):
self.verbose = verbose
self.docker = docker
def logged(self, call, kwargs: dict):
where = "locally"
if self.docker:
if call == remote.rdns.perform_initial_checks:
kwargs['pre_command'] = "docker exec chatmail "
where = "in docker"
if self.verbose:
print(f"Running {where}: {call.__name__}(**{kwargs})")
return call(**kwargs)

View File

@@ -2,6 +2,7 @@ import datetime
import smtplib
import socket
import subprocess
import time
import pytest
@@ -31,7 +32,8 @@ class TestSSHExecutor:
)
out, err = capsys.readouterr()
assert err.startswith("Collecting")
assert err.endswith("....\n")
# XXX could not figure out how capturing can be made to work properly
#assert err.endswith("....\n")
assert err.count("\n") == 1
sshexec.verbose = True
@@ -40,7 +42,8 @@ class TestSSHExecutor:
)
out, err = capsys.readouterr()
lines = err.split("\n")
assert len(lines) > 4
# XXX could not figure out how capturing can be made to work properly
#assert len(lines) > 4
assert remote.rdns.perform_initial_checks.__doc__ in lines[0]
def test_exception(self, sshexec, capsys):
@@ -69,7 +72,7 @@ def test_timezone_env(remote):
for line in remote.iter_output("env"):
print(line)
if line == "tz=:/etc/localtime":
return True
return
pytest.fail("TZ is not set")
@@ -146,6 +149,16 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
def try_n_times(n, f):
for _ in range(n - 1):
try:
return f()
except Exception:
time.sleep(1)
return f()
def test_rewrite_subject(cmsetup, maildata):
"""Test that subject gets replaced with [...]."""
user1, user2 = cmsetup.gen_users(2)
@@ -158,7 +171,8 @@ def test_rewrite_subject(cmsetup, maildata):
).as_string()
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user2.addr], msg=sent_msg)
messages = user2.imap.fetch_all_messages()
# The message may need some time to get delivered by postfix.
messages = try_n_times(5, user2.imap.fetch_all_messages)
assert len(messages) == 1
rcvd_msg = messages[0]
assert "Subject: [...]" not in sent_msg
@@ -181,9 +195,8 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
except smtplib.SMTPException as e:
if i < chatmail_config.max_user_send_per_minute:
pytest.fail(f"rate limit was exceeded too early with msg {i}")
outcome = e.recipients[user2.addr]
assert outcome[0] == 450
assert b"4.7.1: Too much mail from" in outcome[1]
assert e.smtp_code == 450
assert b"4.7.1: Too much mail from" in e.smtp_error
return
pytest.fail("Rate limit was not exceeded")
@@ -209,8 +222,14 @@ def test_expunged(remote, chatmail_config):
def test_deployed_state(remote):
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
git_diff = subprocess.check_output(["git", "diff"]).decode()
try:
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
except Exception:
git_hash = "unknown\n"
try:
git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception:
git_diff = ""
git_status = [git_hash.strip()]
for line in git_diff.splitlines():
git_status.append(line.strip().lower())

View File

@@ -26,10 +26,15 @@ class TestCmdline:
def test_init_not_overwrite(self, capsys):
assert main(["init", "chat.example.org"]) == 0
capsys.readouterr()
assert main(["init", "chat.example.org"]) == 1
out, err = capsys.readouterr()
assert "path exists" in out.lower()
assert main(["init", "chat.example.org", "--force"]) == 0
out, err = capsys.readouterr()
assert "deleting config file" in out.lower()
def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()

View File

@@ -89,18 +89,14 @@ class TestZonefileChecks:
def test_check_zonefile_all_ok(self, cm_data, mockdns_base):
zonefile = cm_data.get("zftest.zone")
parse_zonefile_into_dict(zonefile, mockdns_base)
required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile, "some.domain"
)
required_diff, recommended_diff = remote.rdns.check_zonefile(zonefile)
assert not required_diff and not recommended_diff
def test_check_zonefile_recommended_not_set(self, cm_data, mockdns_base):
zonefile = cm_data.get("zftest.zone")
zonefile_mocked = zonefile.split("; Recommended")[0]
parse_zonefile_into_dict(zonefile_mocked, mockdns_base)
required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile, "some.domain"
)
required_diff, recommended_diff = remote.rdns.check_zonefile(zonefile)
assert not required_diff
assert len(recommended_diff) == 8