Compare commits

...

61 Commits

Author SHA1 Message Date
link2xt
73859ebc11 Do not use redirect on /cgi-bin/newemail.py
Delta Chat does not follow redirects,
so it breaks old QR codes printed on paper
and published on various web pages.
2024-01-21 13:07:07 +00:00
missytake
38a9fc3d6e CI: fix GH action description 2024-01-19 20:36:49 +01:00
missytake
e676545f7a CI: DEFAULT_DNS_ZONE doesn't need to be secret 2024-01-19 20:36:49 +01:00
missytake
ef95627138 CI: don't reset staging.testrun.org VPS on every CI run 2024-01-19 20:36:49 +01:00
missytake
bfaedb5cf1 CI: save /var/lib/rspamd/dkim from getting wiped 2024-01-19 20:36:49 +01:00
missytake
ea8d53aa9b CI: test DNS entries after online tests, less flaky 2024-01-19 20:36:49 +01:00
missytake
be7a000de6 CI: try cmdeploy dns 3 times as it is a bit flaky 2024-01-19 20:36:49 +01:00
missytake
ad3cf9ecaa CI: enable tests with 2 chatmail servers, with nine.testrun.org for now 2024-01-19 20:36:49 +01:00
missytake
691324a3e8 DNS: revert hardcoded DNS server for reverse DNS checks 2024-01-19 20:36:49 +01:00
missytake
23a9f893b4 CI: save /var/lib/acme from getting wiped 2024-01-19 20:36:49 +01:00
missytake
3ea826aecb CI: don't deploy to nine.testrun.org automatically 2024-01-19 20:36:49 +01:00
missytake
532d094a08 CI: check whether cmdeploy dns --zonefile works 2024-01-19 20:36:49 +01:00
missytake
0cea5840df CI: don't reset staging.testrun.org after each run 2024-01-19 20:36:49 +01:00
missytake
45686778ea unbound: ensure systemd service can be started after root keys were generated 2024-01-19 20:36:49 +01:00
missytake
45108d9c93 CI: deploy on staging.testrun.org and if it works, on nine.testrun.org 2024-01-19 20:36:49 +01:00
missytake
3665d957a7 tests: fix tests for new fastCGI route and DKIM responses 2024-01-17 11:23:04 +01:00
link2xt
86940b2ee1 Stop requesting DMARC reports
Nobody reads these XML reports
and we know our DKIM is valid
when `cmdeploy dns` is happy.
2024-01-17 01:37:54 +00:00
link2xt
24fb9eb65b Nicer /new URL for new accounts and redirect GET requests
If user types in https://nine.testrun.org/new manually
in the browser, at least Firefox and Brave suggest
to open the app after following the redirect.
2024-01-15 13:06:29 +00:00
link2xt
700256c273 Split DKIM checks into separate rules
Now errors distinguish between missing DKIM singature,
missing DNS entry or invalid DKIM signature.
2024-01-15 02:36:10 +00:00
link2xt
d575d62b18 rspamd: give the reason to MTA when incoming mail is rejected
This is not secret but makes it easier for mail server admins
to debug why chatmail does not accept their emails.
If the server generates bounce messages, users will also see this
and can redirect to their server support.
It also shows up in /var/log/rspamd/rspamd.log on chatmail server.
2024-01-14 13:12:46 +00:00
link2xt
8cdf8ce376 Merge 'rspamd' branch, replacing OpenDKIM with rspamd
This adds DKIM and SPF checks and replaces OpenDKIM with rspamd for
DKIM signing.
2024-01-14 09:30:31 +00:00
link2xt
7c9abfbde3 Reject on DKIM PERMFAIL and SPF PERMFAIL as well 2024-01-14 09:19:04 +00:00
link2xt
95de87a325 Fixup rspamd disabled.conf deployment message 2024-01-14 08:45:39 +00:00
link2xt
5366df8dc6 Replace rspamd rule weights with a strict rule 2024-01-14 08:45:23 +00:00
link2xt
0a6db5161d Remove unused _configure_opendkim 2024-01-12 19:05:23 +00:00
link2xt
62e25e44fd Disable ratelimit module like other modules 2024-01-12 18:56:11 +00:00
link2xt
ce9fe920dc Do not return anything from remove_opendkim() 2024-01-12 18:47:57 +00:00
link2xt
c171866faf Actually disable phising, rbl and hfilter 2024-01-12 18:46:07 +00:00
missytake
7758c94e31 rspamd: remove redis (not needed) 2024-01-12 15:49:06 +00:00
missytake
66debb9245 lint fixes, final touch 2024-01-12 15:49:06 +00:00
missytake
3542232393 rspamd: reject emails with invalid SPF, DKIM, DMARC 2024-01-12 15:49:06 +00:00
missytake
536c12d989 tests: use generic recipient for DKIM testing 2024-01-12 15:49:06 +00:00
missytake
265403e110 revert "Significantly lower ratelimit" 2024-01-12 15:49:01 +00:00
missytake
fd679af577 rspamd: generate DKIM keys with rspamadm 2024-01-12 15:47:36 +00:00
missytake
ecbf135549 rspamd: install rspamd + redis 2024-01-12 15:47:36 +00:00
missytake
7b90b936dd tests: add test for rejecting SPF & DMARC fails 2024-01-12 15:47:36 +00:00
missytake
17a919ee53 lint: fix 3 issues 2024-01-12 15:47:36 +00:00
missytake
1b15ec0eae rspamd: Significantly lower ratelimit; without read receipts this should be more than fine 2024-01-12 15:47:36 +00:00
missytake
bf863f05b6 rspamd: add redis-server for caching 2024-01-12 15:47:36 +00:00
missytake
a2316beab1 rspamd: disable RBL checks 2024-01-12 15:47:36 +00:00
missytake
28fc91f5f3 rspamd: add rate limiting 2024-01-12 15:47:36 +00:00
missytake
67062677b0 disable some unnecessary rspamd modules 2024-01-12 15:47:36 +00:00
missytake
faf8ffe678 do DKIM signing with rspamd instead of openDKIM 2024-01-12 15:47:36 +00:00
missytake
5821098699 DNS: added www subdomain to zonefile 2024-01-12 13:34:23 +00:00
link2xt
542d63888a nginx: redirect www. to non-www 2024-01-12 13:34:23 +00:00
link2xt
449f8a014c Fix indentation in nginx.conf.j2 2024-01-12 13:34:23 +00:00
link2xt
57764d0cf5 dns: require www. subdomain and request TLS certificate for it 2024-01-12 13:34:23 +00:00
link2xt
c39a79e26a dns: check mta-sts CNAME directly without resolving to IP 2024-01-12 13:34:23 +00:00
link2xt
b6622fc68e chore: run scripts/cmdeploy fmt 2024-01-12 12:18:28 +00:00
link2xt
75b41641f0 doveauth: fix home directory returned from lookup_passdb
It is currently unused, but better have it correct
in case of enabling debugging options such as rawlogs.
2024-01-08 16:40:08 +00:00
link2xt
30a61972fb Update autoconfig XML URL with RFC draft
Old page does not exist anymore and linking to web archive is not nice.
2024-01-08 16:33:04 +01:00
missytake
bcc54602ee postfix: cleanup submission headers 2024-01-05 12:13:31 +01:00
missytake
f9998d5721 tests: if sender's public IP address is in the Received header 2024-01-05 12:13:31 +01:00
nudeldudel
8605ceba5e Update master.cf.j2
Add submission-header-cleanup to reduce the meta-data
2024-01-05 12:13:31 +01:00
missytake
30bcf9ff77 www: change nine.testrun.org occurence to mail_domain 2024-01-05 12:12:52 +01:00
link2xt
70b0e9d5e5 postfix: increase compatibility_level to 3.6 2023-12-27 00:29:12 +01:00
missytake
fdd533aa3b acmetool: stop nginx so acmetool-redirector can start 2023-12-25 23:45:40 +01:00
link2xt
a44ed0aeb3 Use dig +short option to simplify DNS parsing
Without this option parsing of answer was flaky
as for long records like
_submission._tcp.nine.testrun.org.
dig printed the result with a space rather
than tab as a separator and .split("\t") did not work.

This change makes the `dig` command print the answer
in the form we need so there is no need for complex parsing
other than taking the first line.

`-r` option is added to make sure options are not changed by .digrc
in the root home directory.
2023-12-22 21:49:12 +00:00
link2xt
f5bfa6bd56 test: test scanning QR code 2023-12-21 22:22:38 +00:00
link2xt
81a6f8808b fix: escape login and password when passed from dovecot to doveauth
This should allow to use / in the password
2023-12-21 22:22:38 +00:00
link2xt
be3685519f Document ports 80 and 443 and add more hyperlinks 2023-12-21 16:16:17 +00:00
25 changed files with 467 additions and 137 deletions

View File

@@ -0,0 +1,20 @@
;; Zone file for staging.testrun.org
$ORIGIN staging.testrun.org.
$TTL 300
@ IN SOA ns.testrun.org. root.nine.testrun.org (
2023010101 ; Serial
7200 ; Refresh
3600 ; Retry
1209600 ; Expire
3600 ; Negative response caching TTL
)
;; Nameservers.
@ IN NS ns.testrun.org.
;; DNS records.
@ IN A 37.27.37.98
mta-sts.staging.testrun.org. CNAME staging.testrun.org.
www.staging.testrun.org. CNAME staging.testrun.org.

72
.github/workflows/test-and-deploy.yaml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: deploy on staging.testrun.org, and run tests
on:
push:
branches:
- main
- staging-ci
jobs:
deploy:
name: deploy on staging.testrun.org, and run tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging.testrun.org > ~/.ssh/known_hosts
# rsync -avz root@staging.testrun.org:/var/lib/acme . || true
# rsync -avz root@staging.testrun.org:/var/lib/rspamd/dkim . || true
#- name: rebuild staging.testrun.org to have a clean VPS
# run: |
# curl -X POST \
# -H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
# -H "Content-Type: application/json" \
# -d '{"image":"debian-12"}' \
# "https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: run formatting checks
run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
#- name: upload TLS cert after rebuilding
# run: |
# echo " --- wait until staging.testrun.org VPS is rebuilt --- "
# rm ~/.ssh/known_hosts
# while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u ; do sleep 1 ; done
# ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u
# rsync -avz acme root@staging.testrun.org:/var/lib/ || true
# rsync -avz dkim root@staging.testrun.org:/var/lib/rspamd/ || true
- run: cmdeploy init staging.testrun.org
- run: cmdeploy run
- name: set DNS entries
run: |
#ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown _rspamd:_rspamd -R /var/lib/rspamd/dkim
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging.testrun.org /etc/nsd/staging.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=nine.testrun.org cmdeploy test --slow
- name: cmdeploy dns (try 3 times)
run: cmdeploy dns || cmdeploy dns || cmdeploy dns

View File

@@ -151,10 +151,12 @@ While this file is present, account creation will be blocked.
### Ports
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
Dovecot listens on ports 143(imap) and 993 (imaps).
[Postfix](http://www.postfix.org/) listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
[Dovecot](https://www.dovecot.org/) listens on ports 143 (imap) and 993 (imaps).
[nginx](https://www.nginx.com/) listens on port 443 (https).
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (http).
Delta Chat apps will, however, discover all ports and configurations
automatically by reading the `autoconfig.xml` file from the chatmail service.
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail service.

View File

@@ -91,13 +91,39 @@ def lookup_passdb(db, config: Config, user, cleartext_password):
VALUES (?, ?, ?)"""
conn.execute(q, (user, encrypted_password, int(time.time())))
return dict(
home=f"/home/vmail/{user}",
home=f"/home/vmail/mail/{config.mail_domain}/{user}",
uid="vmail",
gid="vmail",
password=encrypted_password,
)
def split_and_unescape(s):
"""Split strings using double quote as a separator and backslash as escape character
into parts."""
out = ""
i = 0
while i < len(s):
c = s[i]
if c == "\\":
# Skip escape character.
i += 1
# This will raise IndexError if there is no character
# after escape character. This is expected
# as this is an invalid input.
out += s[i]
elif c == '"':
# Separator
yield out
out = ""
else:
out += c
i += 1
yield out
def handle_dovecot_request(msg, db, config: Config):
short_command = msg[0]
if short_command == "L": # LOOKUP
@@ -107,7 +133,9 @@ def handle_dovecot_request(msg, db, config: Config):
# do not attempt to read any other parts for compatibility.
keyname = parts[0]
namespace, type, *args = keyname.split("/")
namespace, type, args = keyname.split("/", 2)
args = list(split_and_unescape(args))
reply_command = "F"
res = ""
if namespace == "shared":

View File

@@ -7,7 +7,7 @@ Date: Sun, 15 Oct 2023 16:41:44 +0000
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr=foobar@c2.testrun.org; prefer-encrypt=mutual;
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
@@ -20,4 +20,4 @@ Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
Hi!

View File

@@ -52,15 +52,19 @@ def test_too_high_db_version(db):
def test_handle_dovecot_request(db, example_config):
# Test that password can contain ", ', \ and /
msg = (
"Lshared/passdb/laksjdlaksjdlaksjdlk12j3l1k2j3123/"
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
"some42123@chat.example.org\tsome42123@chat.example.org"
)
res = handle_dovecot_request(msg, db, example_config)
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert userdata["home"] == "/home/vmail/some42123@chat.example.org"
assert (
userdata["home"]
== "/home/vmail/mail/chat.example.org/some42123@chat.example.org"
)
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")

View File

@@ -126,71 +126,6 @@ def _install_remote_venv_with_chatmaild(config) -> None:
)
def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
"""Configures OpenDKIM"""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("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
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
keytable = files.template(
src=importlib.resources.files(__package__).joinpath("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=importlib.resources.files(__package__).joinpath("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"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
],
_sudo=True,
_sudo_user="opendkim",
)
return need_restart
def _install_mta_sts_daemon() -> bool:
need_restart = False
@@ -254,6 +189,17 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
)
need_restart |= master_config.changed
header_cleanup = files.put(
src=importlib.resources.files(__package__).joinpath(
"postfix/submission_header_cleanup"
),
dest="/etc/postfix/submission_header_cleanup",
user="root",
group="root",
mode="644",
)
need_restart |= header_cleanup.changed
return need_restart
@@ -359,6 +305,107 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
return need_restart
def remove_opendkim() -> None:
"""Remove OpenDKIM, deprecated"""
files.file(
name="Remove legacy opendkim.conf",
path="/etc/opendkim.conf",
present=False,
)
files.directory(
name="Remove legacy opendkim socket directory from /var/spool/postfix",
path="/var/spool/postfix/opendkim",
present=False,
)
apt.packages(name="Remove openDKIM", packages="opendkim", present=False)
def _configure_rspamd(dkim_selector: str, mail_domain: str) -> bool:
"""Configures rspamd for Rate Limiting."""
need_restart = False
apt.packages(
name="apt install rspamd",
packages="rspamd",
)
for module in ["phishing", "rbl", "hfilter", "ratelimit"]:
disabled_module_conf = files.put(
name=f"disable {module} rspamd plugin",
src=importlib.resources.files(__package__).joinpath("rspamd/disabled.conf"),
dest=f"/etc/rspamd/local.d/{module}.conf",
user="root",
group="root",
mode="644",
)
need_restart |= disabled_module_conf.changed
options_inc = files.put(
name="disable fuzzy checks",
src=importlib.resources.files(__package__).joinpath("rspamd/options.inc"),
dest="/etc/rspamd/local.d/options.inc",
user="root",
group="root",
mode="644",
)
need_restart |= options_inc.changed
# https://rspamd.com/doc/modules/force_actions.html
force_actions_conf = files.put(
name="Set up rules to reject on DKIM, SPF and DMARC fails",
src=importlib.resources.files(__package__).joinpath(
"rspamd/force_actions.conf"
),
dest="/etc/rspamd/local.d/force_actions.conf",
user="root",
group="root",
mode="644",
)
need_restart |= force_actions_conf.changed
dkim_directory = "/var/lib/rspamd/dkim/"
dkim_key_path = f"{dkim_directory}{mail_domain}.{dkim_selector}.key"
dkim_dns_file = f"{dkim_directory}{mail_domain}.{dkim_selector}.zone"
dkim_config = files.template(
src=importlib.resources.files(__package__).joinpath(
"rspamd/dkim_signing.conf.j2"
),
dest="/etc/rspamd/local.d/dkim_signing.conf",
user="root",
group="root",
mode="644",
config={
"dkim_selector": str(dkim_selector),
"mail_domain": mail_domain,
"dkim_key_path": dkim_key_path,
},
)
need_restart |= dkim_config.changed
files.directory(
name="ensure DKIM key directory exists",
path=dkim_directory,
present=True,
user="_rspamd",
group="_rspamd",
)
if not host.get_fact(File, dkim_key_path):
server.shell(
name="Generate DKIM domain keys with rspamd",
commands=[
f"rspamadm dkim_keygen -b 2048 -s {dkim_selector} -d {mail_domain} -k {dkim_key_path} > {dkim_dns_file}"
],
_sudo=True,
_sudo_user="_rspamd",
)
return need_restart
def check_config(config):
mail_domain = config.mail_domain
if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"):
@@ -386,14 +433,6 @@ def deploy_chatmail(config_path: Path) -> None:
server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
# Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf
# to use 127.0.0.1 as the resolver.
@@ -403,7 +442,10 @@ def deploy_chatmail(config_path: Path) -> None:
)
server.shell(
name="Generate root keys for validating DNSSEC",
commands=["unbound-anchor -a /var/lib/unbound/root.key || true"],
commands=[
"unbound-anchor -a /var/lib/unbound/root.key || true",
"systemctl reset-failed unbound.service",
],
)
systemd.service(
name="Start and enable unbound",
@@ -413,7 +455,10 @@ def deploy_chatmail(config_path: Path) -> None:
)
# Deploy acmetool to have TLS certificates.
deploy_acmetool(nginx_hook=True, domains=[mail_domain, f"mta-sts.{mail_domain}"])
deploy_acmetool(
nginx_hook=True,
domains=[mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"],
)
apt.packages(
name="Install Postfix",
@@ -425,14 +470,6 @@ def deploy_chatmail(config_path: Path) -> None:
packages=["dovecot-imapd", "dovecot-lmtpd"],
)
apt.packages(
name="Install OpenDKIM",
packages=[
"opendkim",
"opendkim-tools",
],
)
apt.packages(
name="Install nginx",
packages=["nginx"],
@@ -454,16 +491,18 @@ def deploy_chatmail(config_path: Path) -> None:
debug = False
dovecot_need_restart = _configure_dovecot(config, debug=debug)
postfix_need_restart = _configure_postfix(config, debug=debug)
opendkim_need_restart = _configure_opendkim(mail_domain)
mta_sts_need_restart = _install_mta_sts_daemon()
nginx_need_restart = _configure_nginx(mail_domain)
remove_opendkim()
rspamd_need_restart = _configure_rspamd("dkim", mail_domain)
systemd.service(
name="Start and enable OpenDKIM",
service="opendkim.service",
name="Start and enable rspamd",
service="rspamd.service",
running=True,
enabled=True,
restarted=opendkim_need_restart,
restarted=rspamd_need_restart,
)
systemd.service(

View File

@@ -1,6 +1,8 @@
import importlib.resources
from pyinfra.operations import apt, files, systemd, server
from pyinfra import host
from pyinfra.facts.systemd import SystemdStatus
def deploy_acmetool(nginx_hook=False, email="", domains=[]):
@@ -55,6 +57,13 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
group="root",
mode="644",
)
if host.get_fact(SystemdStatus).get("nginx.service"):
systemd.service(
name="Stop nginx service to free port 80",
service="nginx",
running=False,
)
systemd.service(
name="Setup acmetool-redirector service",
service="acmetool-redirector.service",

View File

@@ -7,8 +7,9 @@ _imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}.
_imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}.
{chatmail_domain}. CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}"
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all"
_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;rua=mailto:{email};ruf=mailto:{email};fo=1;adkim=r;aspf=r"
_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}"
mta-sts.{chatmail_domain}. CNAME {chatmail_domain}.
www.{chatmail_domain}. CNAME {chatmail_domain}.
_smtp._tls.{chatmail_domain}. TXT "v=TLSRPTv1;rua=mailto:{email}"
{dkim_entry}

View File

@@ -82,7 +82,8 @@ def dns_cmd_options(parser):
def dns_cmd(args, out):
"""Generate dns zone file."""
show_dns(args, out)
exit_code = show_dns(args, out)
exit(exit_code)
def status_cmd(args, out):

View File

@@ -4,7 +4,6 @@ import requests
import importlib
import subprocess
import datetime
from ipaddress import ip_address
class DNS:
@@ -37,24 +36,19 @@ class DNS:
def get(self, typ: str, domain: str) -> str | None:
"""Get a DNS entry"""
dig_result = self.shell(f"dig {typ} {domain}")
line_num = 0
for line in dig_result.splitlines():
line_num += 1
if line.strip() == ";; ANSWER SECTION:":
return dig_result.splitlines()[line_num].split("\t")[-1]
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
line = dig_result.partition("\n")[0]
if line:
return line
def check_ptr_record(self, ip: str, mail_domain) -> str:
def check_ptr_record(self, ip: str, mail_domain) -> bool:
"""Check the PTR record for an IPv4 or IPv6 address."""
result = self.get("-x", ip)
if result:
if ip_address(ip).version == 6:
result = result.split()[-1]
if result[:-1] == mail_domain:
return result
result = self.shell(f"dig -r -x {ip} +short").rstrip()
return result == f"{mail_domain}."
def show_dns(args, out):
def show_dns(args, out) -> int:
"""Check existing DNS records, optionally write them to zone file, return exit code 0 or 1."""
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
mail_domain = args.config.mail_domain
ssh = f"ssh root@{mail_domain}"
@@ -67,6 +61,9 @@ def show_dns(args, out):
continue
line = line.replace("\t", " ")
lines.append(line)
lines[0] = f"dkim._domainkey.{mail_domain}. IN TXT " + lines[0].strip(
"dkim._domainkey IN TXT "
)
return "\n".join(lines)
print("Checking your DKIM keys and DNS entries...")
@@ -74,8 +71,10 @@ def show_dns(args, out):
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
except subprocess.CalledProcessError:
print("Please run `cmdeploy run` first.")
return
dkim_entry = read_dkim_entries(out.shell_output(f"{ssh} -- opendkim-genzone -F"))
return 1
dkim_entry = read_dkim_entries(
out.shell_output(f"{ssh} -- cat /var/lib/rspamd/dkim/{mail_domain}.dkim.zone")
)
ipv6 = dns.get_ipv6()
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
@@ -101,7 +100,7 @@ def show_dns(args, out):
with open(args.zonefile, "w+") as zf:
zf.write(zonefile)
print(f"DNS records successfully written to: {args.zonefile}")
return
return 0
except TypeError:
pass
started_dkim_parsing = False
@@ -149,12 +148,13 @@ def show_dns(args, out):
domain, data = "\n".join(dkim_lines).split(" IN TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if current:
current = "( %s )" % (current.replace('" "', '"\n "'))
if current.replace(";", "\\;") != data:
current = "( %s" % (current.replace('" "', '"\n "'))
if current != data:
to_print.append(dkim_entry)
else:
to_print.append(dkim_entry)
exit_code = 0
if to_print:
to_print.insert(
0, "You should configure the following DNS entries at your provider:\n"
@@ -163,6 +163,7 @@ def show_dns(args, out):
"\nIf you already configured the DNS entries, wait a bit until the DNS entries propagate to the Internet."
)
print("\n".join(to_print))
exit_code = 1
else:
out.green("Great! All your DNS entries are correct.")
@@ -182,6 +183,8 @@ def show_dns(args, out):
print(
"You can do so at your hosting provider (maybe this isn't your DNS provider)."
)
exit_code = 1
return exit_code
def check_necessary_dns(out, mail_domain):
@@ -190,14 +193,14 @@ def check_necessary_dns(out, mail_domain):
ipv4 = dns.get("A", mail_domain)
ipv6 = dns.get("AAAA", mail_domain)
mta_entry = dns.get("CNAME", "mta-sts." + mail_domain)
mta_ip = dns.get("A", mta_entry)
if not mta_ip:
mta_ip = dns.get("AAAA", mta_entry)
www_entry = dns.get("CNAME", "www." + mail_domain)
to_print = []
if not (ipv4 or ipv6):
to_print.append(f"\t{mail_domain}.\t\t\tA<your server's IPv4 address>")
if not mta_ip or not (mta_ip == ipv4 or mta_ip == ipv6):
if mta_entry != mail_domain + ".":
to_print.append(f"\tmta-sts.{mail_domain}.\tCNAME\t{mail_domain}.")
if www_entry != mail_domain + ".":
to_print.append(f"\twww.{mail_domain}.\tCNAME\t{mail_domain}.")
if to_print:
to_print.insert(
0,

View File

@@ -1,5 +1,10 @@
uri = proxy:/run/dovecot/doveauth.socket:auth
iterate_disable = yes
default_pass_scheme = plain
password_key = passdb/%w/%u
user_key = userdb/%u
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
# See <https://doc.dovecot.org/configuration_manual/config_file/config_variables/#modifiers>
# for documentation.
#
# We escape user-provided input and use double quote as a separator.
password_key = passdb/%Ew"%Eu
user_key = userdb/%Eu

View File

@@ -6,7 +6,7 @@ import io
def gen_qr_png_data(maildomain):
url = f"DCACCOUNT:https://{maildomain}/cgi-bin/newemail.py"
url = f"DCACCOUNT:https://{maildomain}/new"
image = gen_qr(maildomain, url)
temp = io.BytesIO()
image.save(temp, format="png")

View File

@@ -41,11 +41,44 @@ http {
try_files $uri $uri/ =404;
}
location /metrics {
default_type text/plain;
}
location /metrics {
default_type text/plain;
}
# add cgi-bin support
include /usr/share/doc/fcgiwrap/examples/nginx.conf;
location /new {
if ($request_method = GET) {
# Redirect to Delta Chat,
# which will in turn do a POST request.
return 301 dcaccount:https://{{ config.domain_name }}/new;
}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
}
# Old URL for compatibility with e.g. printed QR codes.
#
# Copy-paste instead of redirect to /new
# because Delta Chat core does not follow redirects.
#
# Redirects are only for browsers.
location /cgi-bin/newemail.py {
if ($request_method = GET) {
return 301 dcaccount:https://{{ config.domain_name }}/new;
}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
}
}
# Redirect www. to non-www
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.domain_name }}$request_uri;
}
}

View File

@@ -11,9 +11,8 @@ append_dot_mydomain = no
readme_directory = no
# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 2 on
# fresh installs.
compatibility_level = 2
# See http://www.postfix.org/COMPATIBILITY_README.html
compatibility_level = 3.6
# TLS parameters
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain
@@ -47,5 +46,7 @@ inet_protocols = all
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }}
smtpd_milters = unix:opendkim/opendkim.sock
smtpd_milters = inet:127.0.0.1:11332
non_smtpd_milters = $smtpd_milters
header_checks = regexp:/etc/postfix/submission_header_cleanup

View File

@@ -0,0 +1,4 @@
/^Received:/ IGNORE
/^X-Originating-IP:/ IGNORE
/^X-Mailer:/ IGNORE
/^User-Agent:/ IGNORE

View File

@@ -0,0 +1 @@
enabled = false;

View File

@@ -0,0 +1,10 @@
selector = {{ config.dkim_selector }}
use_esld = false # don't cut c1.testrun.org down to testrun.org
domain = {
{{ config.mail_domain }} {
selectors [
selector = {{ config.dkim_selector }}
path = {{ config.dkim_key_path }}
]
}
}

View File

@@ -0,0 +1,60 @@
rules {
## Reject on missing or invalid DKIM signatures.
##
## We require DKIM signature on incoming mails regardless of DMARC policy.
# R_DKIM_REJECT: DKIM reject inserted by `dkim` module.
REJECT_INVALID_DKIM {
action = "reject";
expression = "R_DKIM_REJECT";
message = "Rejected due to invalid DKIM signature";
}
# R_DKIM_PERMFAIL: permanent failure inserted by `dkim` module e.g. no DKIM DNS record found.
REJECT_PERMFAIL_DKIM {
action = "reject";
expression = "R_DKIM_PERMFAIL";
message = "Rejected due to missing DKIM DNS entry";
}
# No DKIM signature (R_DKIM_NA symbol inserted by `dkim` module).
REJECT_MISSING_DKIM {
action = "reject";
expression = "R_DKIM_NA";
message = "Rejected due to missing DKIM signature";
}
## Reject on SPF failure.
# - SPF failure (R_SPF_FAIL)
# - SPF permanent failure, e.g. failed to resolve DNS record referenced from SPF (R_SPF_PERMFAIL)
REJECT_SPF {
action = "reject";
expression = "R_SPF_FAIL | R_SPF_PERMFAIL";
message = "Rejected due to failed SPF check";
}
# Reject on DMARC policy check failure.
REJECT_DMARC {
action = "reject";
expression = "DMARC_POLICY_REJECT";
message = "Rejected due to DMARC policy";
}
# Do not reject if:
# - R_DKIM_TEMPFAIL, it is a DNS resolution failure
# and we do not want to lose messages because of faulty network.
#
# - R_SPF_SOFTFAIL
# - R_SPF_NEUTRAL
# - R_SPF_DNSFAIL
# - R_SPF_NA
#
# - DMARC_DNSFAIL
# - DMARC_NA
# - DMARC_POLICY_SOFTFAIL
# - DMARC_POLICY_QUARANTINE
# - DMARC_BAD_POLICY
}

View File

@@ -0,0 +1 @@
filters = "dkim";

View File

@@ -9,8 +9,17 @@ def test_gen_qr_png_data(maildomain):
def test_fastcgi_working(maildomain, chatmail_config):
url = f"https://{maildomain}/cgi-bin/newemail.py"
url = f"https://{maildomain}/new"
print(url)
res = requests.post(url)
assert maildomain in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length
def test_newemail_configure(maildomain, rpc):
"""Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new"
for i in range(3):
account_id = rpc.add_account()
rpc.set_config_from_qr(account_id, url)
rpc.configure(account_id)

View File

@@ -42,6 +42,16 @@ def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr):
assert "500" in str(e.value)
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr):
"""Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
recipient = cmsetup.gen_users(1)[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
with smtplib.SMTP(cmsetup.maildomain, 25) as s:
with pytest.raises(smtplib.SMTPDataError, match="missing DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
@pytest.mark.slow
def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
"""Test that the per-account send-mail limit is exceeded."""

View File

@@ -1,7 +1,10 @@
import time
import re
import random
import pytest
import requests
import ipaddress
class TestEndToEndDeltaChat:
@@ -119,3 +122,17 @@ class TestEndToEndDeltaChat:
for msg in msgs:
assert "error" not in m.get_message_info()
time.sleep(1)
def test_hide_senders_ip_address(cmfactory):
public_ip = requests.get("http://icanhazip.com").content.decode().strip()
assert ipaddress.ip_address(public_ip)
user1, user2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup")
user2.wait_next_incoming_message()
user2.direct_imap.select_folder("Inbox")
msg = user2.direct_imap.get_all_messages()[0]
assert public_ip not in msg.obj.as_string()

View File

@@ -7,7 +7,7 @@ Welcome to instant, interoperable and [privacy-preserving](privacy.html) messagi
👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address
<a href="DCACCOUNT:https://{{ config.mail_domain }}/cgi-bin/newemail.py">
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
🐣 **Choose** your Avatar and Name

View File

@@ -3,7 +3,7 @@
## More information
`nine.testrun.org` provides a low-maintenance, resource efficient and
{{ config.mail_domain }} provides a low-maintenance, resource efficient and
interoperable e-mail service for everyone. What's behind a `chatmail` is
effectively a normal e-mail address just like any other but optimized
for the usage in chats, especially DeltaChat.