Compare commits

...

93 Commits

Author SHA1 Message Date
missytake
4b9b0f5f44 lint: fix issues 2024-01-18 17:26:24 +01:00
link2xt
10c671ebda Reject on DKIM PERMFAIL and SPF PERMFAIL as well 2024-01-18 17:24:36 +01:00
link2xt
f2be32ac6f Fixup rspamd disabled.conf deployment message 2024-01-18 17:24:36 +01:00
link2xt
b702848c33 Replace rspamd rule weights with a strict rule 2024-01-18 17:24:36 +01:00
link2xt
a6f2f74520 Remove unused _configure_opendkim 2024-01-18 17:24:36 +01:00
link2xt
01ec341364 Disable ratelimit module like other modules 2024-01-18 17:24:36 +01:00
link2xt
998799fe3f Do not return anything from remove_opendkim() 2024-01-18 17:24:36 +01:00
link2xt
6186dc5259 Actually disable phising, rbl and hfilter 2024-01-18 17:24:36 +01:00
missytake
5880133b5b rspamd: remove redis (not needed) 2024-01-18 17:24:36 +01:00
missytake
6772bfe630 lint fixes, final touch 2024-01-18 17:24:36 +01:00
missytake
101c3a6b47 rspamd: reject emails with invalid SPF, DKIM, DMARC 2024-01-18 17:24:36 +01:00
missytake
5ef2100765 tests: use generic recipient for DKIM testing 2024-01-18 17:24:36 +01:00
missytake
d49aae365c revert "Significantly lower ratelimit" 2024-01-18 17:24:36 +01:00
missytake
998a185332 rspamd: generate DKIM keys with rspamadm 2024-01-18 17:24:36 +01:00
missytake
3e78555ca1 rspamd: install rspamd + redis 2024-01-18 17:24:36 +01:00
missytake
01cfd0be19 tests: add test for rejecting SPF & DMARC fails 2024-01-18 17:24:36 +01:00
missytake
1bdc547479 lint: fix 3 issues 2024-01-18 17:24:36 +01:00
missytake
c0b8ba816d rspamd: Significantly lower ratelimit; without read receipts this should be more than fine 2024-01-18 17:24:36 +01:00
missytake
118ae49674 rspamd: add redis-server for caching 2024-01-18 17:24:36 +01:00
missytake
a47df20e22 rspamd: disable RBL checks 2024-01-18 17:24:36 +01:00
missytake
a1d8881887 rspamd: add rate limiting 2024-01-18 17:24:36 +01:00
missytake
cd7416a0dd disable some unnecessary rspamd modules 2024-01-18 17:24:36 +01:00
missytake
173e3f6390 do DKIM signing with rspamd instead of openDKIM 2024-01-18 17:24:36 +01:00
missytake
b8d53242cf DNS: added www subdomain to zonefile 2024-01-18 17:24:36 +01:00
link2xt
c65f618fb1 nginx: redirect www. to non-www 2024-01-18 17:24:36 +01:00
link2xt
42afad0852 Fix indentation in nginx.conf.j2 2024-01-18 17:24:36 +01:00
link2xt
8bc19439a9 dns: require www. subdomain and request TLS certificate for it 2024-01-18 17:24:36 +01:00
link2xt
cdaddb9b0f dns: check mta-sts CNAME directly without resolving to IP 2024-01-18 17:24:36 +01:00
missytake
768bf2b22c greeterbot: better comparison method
Co-authored-by: holger krekel  <holger@merlinux.eu>
2024-01-18 17:09:23 +01:00
missytake
185e6f7d2a greeterbot: address hpk's comments 2024-01-13 17:37:22 +01:00
missytake
90e7169eef lint: fix issues 2024-01-12 16:24:45 +01:00
missytake
3db7933d8b greeterbot: port to chatmail 2024-01-12 16:20:39 +01: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
missytake
2cf950e901 echo: fail if configure doesn't work 2023-12-21 01:06:23 +01:00
missytake
46d5dbb07d DNS: nicer output for reverse DNS/PTR records. fixes #143 2023-12-20 19:26:50 +01:00
missytake
d2e0d1fecc DNS: flush_zone before validating DNS entries. fixes #140 2023-12-20 19:26:50 +01:00
missytake
d333cfdd5a lint: fix 1 issue 2023-12-20 19:26:50 +01:00
missytake
32238e99ab tests: testing cmdeploy init only makes sense with a staging server as well now 2023-12-20 19:26:50 +01:00
missytake
40a3a2cc86 tests: make test init work with reachable chatmail_domain 2023-12-20 19:26:50 +01:00
missytake
fe978a1971 DNS: increase SSH command's timeout to 10 seconds (the default) 2023-12-20 19:26:50 +01:00
missytake
b426c2e7ff DNS: error if can't connect with SSH. fixes #144 2023-12-20 19:26:50 +01:00
missytake
b626464453 cmdeploy: fail init and run if SSH doesn't connect 2023-12-20 19:26:50 +01:00
missytake
76c3316f02 cmdeploy init: make output green if DNS is correct 2023-12-19 19:39:49 +01:00
missytake
a6a9406228 DNS: making CLI output slightly prettier 2023-12-19 19:39:49 +01:00
missytake
7921f5dd0b DNS: fix some crashes in cmdeploy dns 2023-12-19 19:39:49 +01:00
missytake
39fc9d628f cmdeploy: only run cmdeploy dns after cmdeploy run 2023-12-19 19:39:49 +01:00
link2xt
85a9183b61 Do not call show_dns with run args 2023-12-19 19:39:49 +01:00
missytake
36a4381484 DNS: use local dig if ssh fails 2023-12-19 19:39:49 +01:00
missytake
5ff98a571c DNS: commit hpk's suggestion 2023-12-19 19:39:49 +01:00
missytake
0a91aeb4a3 cmdeploy: simplify check_necessary_dns output 2023-12-19 19:39:49 +01:00
missytake
c38f1d7e54 DNS: fix reverse DNS checking 2023-12-19 19:39:49 +01:00
missytake
42bba52f66 README: move cmdeploy dns to additional commands 2023-12-19 19:39:49 +01:00
missytake
03aab4043c DNS: fix CNAME resolving, don't print ssh commands for DNS requests 2023-12-19 19:39:49 +01:00
missytake
146def2f06 cmdeploy: show DNS info at begin and end of cmdeploy run 2023-12-19 19:39:49 +01:00
missytake
d642224a73 DNS: flush cache in the beginning 2023-12-19 19:39:49 +01:00
missytake
0238437ce7 DNS: get DNS records with server-side dig 2023-12-19 19:39:49 +01:00
missytake
7ed59ea8bc DNS: move getting IPs to dns.py 2023-12-19 19:39:49 +01:00
missytake
49d0a0bbb0 DNS: fix parsing 2023-12-19 19:39:49 +01:00
missytake
330a034329 DNS: ignore DNS resolvers which don't give us JSON 2023-12-19 19:39:49 +01:00
missytake
aee18215fc DNS: Also check A and CNAME entries 2023-12-19 19:39:49 +01:00
missytake
336f87770d cmdeploy: write --zonefile to file 2023-12-19 19:39:49 +01:00
missytake
4199e04ab3 cmdeploy: fixing DNS CLI output 2023-12-19 19:39:49 +01:00
missytake
50922fb1d2 docs: dns doesn't just output a zone file anymore 2023-12-19 19:39:49 +01:00
missytake
d2fe417715 DNS: try other resolvers if the first doesn't have it 2023-12-19 19:39:49 +01:00
missytake
2b731bf909 DNS: also add IPv4 entry to zonefile 2023-12-19 19:39:49 +01:00
missytake
2669babb53 DNS: added checks for PTR records 2023-12-19 19:39:49 +01:00
missytake
fe675a9a72 cmdeploy: dns --zonefile subcommand to just print the zonefile 2023-12-19 19:39:49 +01:00
missytake
79f766b28e tests: mark test as xfail until we can test for CLI output 2023-12-19 19:39:49 +01:00
missytake
0eeb692c4b DNS: re-use HTTP session to reduce query time by 7 seconds 2023-12-19 19:39:49 +01:00
missytake
6c401173db DNS: also generate AAAA entry 2023-12-19 19:39:49 +01:00
missytake
b474b86e7b cmdeploy: only output DNS entries which are not correct yet 2023-12-19 19:39:49 +01:00
missytake
6a9beb8ff7 DNS: ensure mta-sts.@ is also pointing to @ 2023-12-19 19:39:49 +01:00
missytake
d0f5d08443 cmdeploy run: don't run if crucial DNS entries are missing 2023-12-19 19:39:49 +01:00
missytake
49848ec01e cmdeploy init: show DNS entries required for deployment if not set 2023-12-19 19:39:49 +01:00
missytake
0ffe4d4996 Revert "pyinfra: only install unbound-anchor on Debian systems"
This reverts commit c1d3de926e.
2023-12-19 17:45:00 +01:00
missytake
7a2a889585 pyinfra: only install unbound-anchor on Debian systems 2023-12-19 17:45:00 +01:00
missytake
1e4b776de5 unbound: generate root.key manually if it doesn't exist 2023-12-19 17:45:00 +01:00
link2xt
3d00ca1672 doveauth: add support for Dovecot 2.3.16 2023-12-18 19:44:11 +00:00
link2xt
485bbb9cbd Let acmetool manage port 80
This avoids circular dependency with nginx.
nginx needs a certificate to start
and getting a certificate requires someone
listening on port 80.
2023-12-18 16:36:36 +01:00
holger krekel
359c195419 count ci accounts correctly 2023-12-16 17:06:13 +01:00
holger krekel
1b9e822ff6 strike this weird CHATMAIL_DOMAIN variable 2023-12-16 16:36:56 +01:00
holger krekel
9f6c00d62c strike last mentins of "instance" in readme 2023-12-16 16:36:56 +01:00
35 changed files with 785 additions and 197 deletions

View File

@@ -33,8 +33,5 @@ jobs:
- name: run deploy-chatmail offline tests - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
- name: initialize with chatmail domain
run: cmdeploy init chat.example.org
# all other cmdeploy commands require a staging server # all other cmdeploy commands require a staging server
# see https://github.com/deltachat/chatmail/issues/100 # see https://github.com/deltachat/chatmail/issues/100

View File

@@ -15,8 +15,8 @@ after which the initially specified password is required for using them.
## Deploying your own chatmail server ## Deploying your own chatmail server
We subsequently use `CHATMAIL_DOMAIN` as a placeholder for your fully qualified We use `chat.example.org` as the chatmail domain in the following steps.
DNS domain name (FQDN), for example `chat.example.org`. Please substitute it with your own domain.
1. Install the `cmdeploy` command in a virtualenv 1. Install the `cmdeploy` command in a virtualenv
@@ -25,19 +25,19 @@ DNS domain name (FQDN), for example `chat.example.org`.
cd chatmail cd chatmail
scripts/initenv.sh scripts/initenv.sh
``` ```
2. Create chatmail configuration file `chatmail.ini`: 2. Create chatmail configuration file `chatmail.ini`:
``` ```
scripts/cmdeploy init CHATMAIL_DOMAIN scripts/cmdeploy init chat.example.org # <-- use your domain
``` ```
3. Setup first DNS records for your `CHATMAIL_DOMAIN`, 3. Setup first DNS records for your chatmail domain,
according to the hints provided by `cmdeploy init`. according to the hints provided by `cmdeploy init`.
Verify that SSH root login works: Verify that SSH root login works:
``` ```
ssh root@CHATMAIL_DOMAIN ssh root@chat.example.org # <-- use your domain
``` ```
4. Deploy to the remote chatmail server: 4. Deploy to the remote chatmail server:
@@ -45,13 +45,9 @@ DNS domain name (FQDN), for example `chat.example.org`.
``` ```
scripts/cmdeploy run scripts/cmdeploy run
``` ```
This script will also show you additional DNS records
5. To output a DNS zone file from which you can transfer DNS records which you should configure at your DNS provider
to your DNS provider: (it can take some time until they are public).
```
scripts/cmdeploy dns
```
### Other helpful commands: ### Other helpful commands:
@@ -61,6 +57,12 @@ To check the status of your remotely running chatmail service:
scripts/cmdeploy status scripts/cmdeploy status
``` ```
To check whether your DNS records are correct:
```
scripts/cmdeploy dns
```
To test whether your chatmail service is working correctly: To test whether your chatmail service is working correctly:
``` ```
@@ -75,7 +77,7 @@ scripts/cmdeploy bench
## Overview of this repository ## Overview of this repository
This repository drives the development of "chatmail instances", This repository drives the development of chatmail services,
comprised of minimal setups of comprised of minimal setups of
- [postfix smtp server](https://www.postfix.org) - [postfix smtp server](https://www.postfix.org)
@@ -91,7 +93,7 @@ as well as custom services that are integrated with these two:
to send mails for them. to send mails for them.
- `chatmaild/src/chatmaild/filtermail.py` prevents - `chatmaild/src/chatmaild/filtermail.py` prevents
unencrypted e-mail from leaving the chatmail instance unencrypted e-mail from leaving the chatmail service
and is integrated into postfix's outbound mail pipelines. and is integrated into postfix's outbound mail pipelines.
There is also the `cmdeploy/src/cmdeploy/cmdeploy.py` command line tool There is also the `cmdeploy/src/cmdeploy/cmdeploy.py` command line tool
@@ -104,7 +106,7 @@ to automatically install all chatmail components on a server.
### Home page and getting started for users ### Home page and getting started for users
`cmdeploy run` also creates default static Web pages and deploys them `cmdeploy run` also creates default static Web pages and deploys them
to an nginx web server under `https://CHATMAIL_DOMAIN`. to a nginx web server with:
- a default `index.html` along with a QR code that users can click to - a default `index.html` along with a QR code that users can click to
create accounts on your chatmail provider, create accounts on your chatmail provider,
@@ -149,10 +151,12 @@ While this file is present, account creation will be blocked.
### Ports ### Ports
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions). [Postfix](http://www.postfix.org/) listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
Dovecot listens on ports 143(imap) and 993 (imaps). [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 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

@@ -10,6 +10,10 @@ dependencies = [
"iniconfig", "iniconfig",
"deltachat-rpc-server", "deltachat-rpc-server",
"deltachat-rpc-client", "deltachat-rpc-client",
"ConfigArgParse",
"deltachat",
"setuptools>=60",
"setuptools-scm>=8",
] ]
[tool.setuptools] [tool.setuptools]
@@ -22,6 +26,7 @@ where = ['src']
doveauth = "chatmaild.doveauth:main" doveauth = "chatmaild.doveauth:main"
filtermail = "chatmaild.filtermail:main" filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main" echobot = "chatmaild.echo:main"
greeterbot = "chatmaild.greeterbot:main"
chatmail-metrics = "chatmaild.metrics:main" chatmail-metrics = "chatmaild.metrics:main"
[project.entry-points.pytest11] [project.entry-points.pytest11]

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -46,11 +46,17 @@ class Connection:
) )
return result return result
def get_user_list(self) -> set[str]:
"""Get a set of all users."""
q = "SELECT addr from users"
return set([tup[0] for tup in self._sqlconn.execute(q).fetchall()])
class Database: class Database:
def __init__(self, path: str): def __init__(self, path: str, read_only=False):
self.path = Path(path) self.path = Path(path)
self.ensure_tables() if not read_only:
self.ensure_tables()
def _get_connection( def _get_connection(
self, write=False, transaction=False, closing=False self, write=False, transaction=False, closing=False

View File

@@ -46,7 +46,7 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
len(localpart) > config.username_max_length len(localpart) > config.username_max_length
or len(localpart) < config.username_min_length or len(localpart) < config.username_min_length
): ):
if localpart != "echo": if localpart not in ("echo", "hello"):
logging.warning( logging.warning(
"localpart %s has to be between %s and %s chars long", "localpart %s has to be between %s and %s chars long",
localpart, localpart,
@@ -91,23 +91,56 @@ def lookup_passdb(db, config: Config, user, cleartext_password):
VALUES (?, ?, ?)""" VALUES (?, ?, ?)"""
conn.execute(q, (user, encrypted_password, int(time.time()))) conn.execute(q, (user, encrypted_password, int(time.time())))
return dict( return dict(
home=f"/home/vmail/{user}", home=f"/home/vmail/mail/{config.mail_domain}/{user}",
uid="vmail", uid="vmail",
gid="vmail", gid="vmail",
password=encrypted_password, 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): def handle_dovecot_request(msg, db, config: Config):
short_command = msg[0] short_command = msg[0]
if short_command == "L": # LOOKUP if short_command == "L": # LOOKUP
parts = msg[1:].split("\t") parts = msg[1:].split("\t")
keyname, user = parts[:2]
namespace, type, *args = keyname.split("/") # Dovecot <2.3.17 has only one part,
# do not attempt to read any other parts for compatibility.
keyname = parts[0]
namespace, type, args = keyname.split("/", 2)
args = list(split_and_unescape(args))
reply_command = "F" reply_command = "F"
res = "" res = ""
if namespace == "shared": if namespace == "shared":
if type == "userdb": if type == "userdb":
user = args[0]
if user.endswith(f"@{config.mail_domain}"): if user.endswith(f"@{config.mail_domain}"):
res = lookup_userdb(db, user) res = lookup_userdb(db, user)
if res: if res:
@@ -115,6 +148,7 @@ def handle_dovecot_request(msg, db, config: Config):
else: else:
reply_command = "N" reply_command = "N"
elif type == "passdb": elif type == "passdb":
user = args[1]
if user.endswith(f"@{config.mail_domain}"): if user.endswith(f"@{config.mail_domain}"):
res = lookup_passdb(db, config, user, cleartext_password=args[0]) res = lookup_passdb(db, config, user, cleartext_password=args[0])
if res: if res:

View File

@@ -6,7 +6,6 @@ it will echo back any message that has non-empty text and also supports the /hel
import logging import logging
import os import os
import sys import sys
from threading import Thread
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
@@ -76,10 +75,7 @@ def main():
config = read_config(sys.argv[1]) config = read_config(sys.argv[1])
password = create_newemail_dict(config).get("password") password = create_newemail_dict(config).get("password")
email = "echo@" + config.mail_domain email = "echo@" + config.mail_domain
configure_thread = Thread( bot.configure(email, password)
target=bot.configure, kwargs={"email": email, "password": password}
)
configure_thread.start()
bot.run_forever() bot.run_forever()

Binary file not shown.

View File

@@ -0,0 +1,132 @@
import time
import deltachat
from deltachat.tracker import ConfigureFailed
from time import sleep
import tempfile
import os
import configargparse
import pkg_resources
import secrets
from chatmaild.database import Database
from chatmaild.config import read_config
from chatmaild.newemail import ALPHANUMERIC_PUNCT, CONFIG_PATH
PASSDB_PATH = "/home/vmail/passdb.sqlite"
def setup_account(data_dir: str, debug: bool) -> deltachat.Account:
"""Create a deltachat account with a given addr/password combination.
:param data_dir: the directory where the data(base) is stored.
:param debug: whether to show log messages for the account.
:return: the deltachat account object.
"""
chatmail_config = read_config(CONFIG_PATH)
addr = "hello@" + chatmail_config.mail_domain
try:
os.mkdir(os.path.join(data_dir, addr))
except FileExistsError:
pass
db_path = os.path.join(data_dir, addr, "db.sqlite")
ac = deltachat.Account(db_path)
if debug:
ac.add_account_plugin(deltachat.events.FFIEventLogger(ac))
ac.set_config("mvbox_move", "0")
ac.set_config("sentbox_watch", "0")
ac.set_config("bot", "1")
ac.set_config("mdns_enabled", "0")
if not ac.is_configured():
cleartext_password = "".join(
secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(chatmail_config.password_min_length + 3)
)
ac.set_config("mail_pw", cleartext_password)
ac.set_config("addr", addr)
configtracker = ac.configure()
try:
configtracker.wait_finish()
except ConfigureFailed:
print(
"configuration setup failed for %s with password:\n%s"
% (ac.get_config("addr"), ac.get_config("mail_pw"))
)
raise
ac.start_io()
avatar = pkg_resources.resource_filename(__name__, "avatar.jpg")
ac.set_avatar(avatar)
ac.set_config("displayname", f"Hello at {chatmail_config.mail_domain}!")
return ac
class GreetBot:
def __init__(self, passdb, account):
self.db = Database(passdb, read_only=True)
self.account = account
self.domain = account.get_config("addr").split("@")[1]
with self.db.read_connection() as conn:
self.existing_users = conn.get_user_list()
def greet_users(self):
with self.db.read_connection() as conn:
users = conn.get_user_list()
new_users = users.difference(self.existing_users)
self.existing_users = users
time.sleep(20) # wait until Delta is configured on the user side
for user in new_users:
for ci_prefix in ["ac1_", "ac2_", "ac3_", "ac4_", "ac5_", "ci-"]:
if user.startswith(ci_prefix):
continue
if user not in [c.addr for c in self.account.get_contacts()]:
print("Inviting", user)
contact = self.account.create_contact(user)
chat = contact.create_chat()
chat.send_text(
"Welcome to %s! Here you can try out Delta Chat." % (self.domain,)
)
chat.send_text(
"I prepared some webxdc apps for you, if you are interested:"
)
chat.send_file(pkg_resources.resource_filename(__name__, "editor.xdc"))
chat.send_file(
pkg_resources.resource_filename(__name__, "tower-builder.xdc")
)
chat.send_text(
"You can visit https://webxdc.org/apps to discover more apps! "
"Some of these games you can also play with friends, directly in the chat."
)
def main():
args = configargparse.ArgumentParser()
args.add_argument("--db_path", help="location of the Delta Chat database")
args.add_argument(
"--passdb", default=PASSDB_PATH, help="location of the chatmail passdb"
)
args.add_argument("--show-ffi", action="store_true", help="print Delta Chat log")
ops = args.parse_args()
# ensuring account data directory
if ops.db_path is None:
tempdir = tempfile.TemporaryDirectory(prefix="hellobot")
ops.db_path = tempdir.name
elif not os.path.exists(ops.db_path):
os.mkdir(ops.db_path)
ac = setup_account(ops.db_path, ops.show_ffi)
greeter = GreetBot(ops.passdb, ac)
print("waiting for new chatmail users...")
while 1:
greeter.greet_users()
sleep(5)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,11 @@
[Unit]
Description=Chatmail greeterbot, a Delta Chat bot to greet new users
[Service]
ExecStart={execpath} --passdb {passdb_path} --db_path /home/vmail/greeterbot/ --show-ffi
User=vmail
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -4,14 +4,16 @@ import time
import sys import sys
def main(): def main(vmail_dir=None):
vmail_dir = sys.argv[1] if vmail_dir is None:
vmail_dir = sys.argv[1]
accounts = 0 accounts = 0
ci_accounts = 0 ci_accounts = 0
for path in Path(vmail_dir).iterdir(): for path in Path(vmail_dir).iterdir():
accounts += 1 accounts += 1
if path.name.startswith("ci-"): if path.name[:3] in ("ci-", "ac_"):
ci_accounts += 1 ci_accounts += 1
timestamp = int(time.time() * 1000) timestamp = int(time.time() * 1000)

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
from chatmaild.metrics import main
def test_main(tmp_path, capsys):
for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"):
tmp_path.joinpath(x).mkdir()
main(tmp_path)
out, _ = capsys.readouterr()
d = {}
for line in out.split("\n"):
if line.strip():
name, num, _ = line.split()
d[name] = int(num)
assert d["accounts"] == 4
assert d["ci_accounts"] == 3

Binary file not shown.

View File

@@ -101,11 +101,13 @@ def _install_remote_venv_with_chatmaild(config) -> None:
"doveauth", "doveauth",
"filtermail", "filtermail",
"echobot", "echobot",
"greeterbot",
): ):
params = dict( params = dict(
execpath=f"{remote_venv_dir}/bin/{fn}", execpath=f"{remote_venv_dir}/bin/{fn}",
config_path=remote_chatmail_inipath, config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir, remote_venv_dir=remote_venv_dir,
passdb_path="/home/vmail/passdb.sqlite",
) )
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f") source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
content = source_path.read_text().format(**params).encode() content = source_path.read_text().format(**params).encode()
@@ -126,71 +128,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: def _install_mta_sts_daemon() -> bool:
need_restart = False need_restart = False
@@ -254,6 +191,17 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
) )
need_restart |= master_config.changed 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 return need_restart
@@ -359,6 +307,107 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
return need_restart 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): def check_config(config):
mail_domain = config.mail_domain mail_domain = config.mail_domain
if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"): if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"):
@@ -386,20 +435,16 @@ def deploy_chatmail(config_path: Path) -> None:
server.group(name="Create vmail group", group="vmail", system=True) server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", 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`. # 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.
apt.packages( apt.packages(
name="Install unbound", name="Install unbound",
packages="unbound", packages=["unbound", "unbound-anchor", "dnsutils"],
)
server.shell(
name="Generate root keys for validating DNSSEC",
commands=["unbound-anchor -a /var/lib/unbound/root.key || true"],
) )
systemd.service( systemd.service(
name="Start and enable unbound", name="Start and enable unbound",
@@ -409,7 +454,10 @@ def deploy_chatmail(config_path: Path) -> None:
) )
# Deploy acmetool to have TLS certificates. # 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( apt.packages(
name="Install Postfix", name="Install Postfix",
@@ -421,14 +469,6 @@ def deploy_chatmail(config_path: Path) -> None:
packages=["dovecot-imapd", "dovecot-lmtpd"], packages=["dovecot-imapd", "dovecot-lmtpd"],
) )
apt.packages(
name="Install OpenDKIM",
packages=[
"opendkim",
"opendkim-tools",
],
)
apt.packages( apt.packages(
name="Install nginx", name="Install nginx",
packages=["nginx"], packages=["nginx"],
@@ -450,16 +490,18 @@ def deploy_chatmail(config_path: Path) -> None:
debug = False debug = False
dovecot_need_restart = _configure_dovecot(config, debug=debug) dovecot_need_restart = _configure_dovecot(config, debug=debug)
postfix_need_restart = _configure_postfix(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() mta_sts_need_restart = _install_mta_sts_daemon()
nginx_need_restart = _configure_nginx(mail_domain) nginx_need_restart = _configure_nginx(mail_domain)
remove_opendkim()
rspamd_need_restart = _configure_rspamd("dkim", mail_domain)
systemd.service( systemd.service(
name="Start and enable OpenDKIM", name="Start and enable rspamd",
service="opendkim.service", service="rspamd.service",
running=True, running=True,
enabled=True, enabled=True,
restarted=opendkim_need_restart, restarted=rspamd_need_restart,
) )
systemd.service( systemd.service(

View File

@@ -1,6 +1,8 @@
import importlib.resources import importlib.resources
from pyinfra.operations import apt, files, server 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=[]): def deploy_acmetool(nginx_hook=False, email="", domains=[]):
@@ -46,6 +48,30 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
mode="644", mode="644",
) )
service_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-redirector.service"
),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
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",
running=True,
enabled=True,
restarted=service_file.changed,
)
server.shell( server.shell(
name=f"Request certificate for: { ', '.join(domains) }", name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want { ' '.join(domains)}"], commands=[f"acmetool want { ' '.join(domains)}"],

View File

@@ -0,0 +1,11 @@
[Unit]
Description=acmetool HTTP redirector
[Service]
Type=notify
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -1,12 +1,15 @@
{chatmail_domain}. A {ipv4}
{chatmail_domain}. AAAA {ipv6}
{chatmail_domain}. MX 10 {chatmail_domain}. {chatmail_domain}. MX 10 {chatmail_domain}.
_submission._tcp.{chatmail_domain}. SRV 0 1 587 {chatmail_domain}. _submission._tcp.{chatmail_domain}. SRV 0 1 587 {chatmail_domain}.
_submissions._tcp.{chatmail_domain}. SRV 0 1 465 {chatmail_domain}. _submissions._tcp.{chatmail_domain}. SRV 0 1 465 {chatmail_domain}.
_imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}. _imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}.
_imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}. _imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}.
{chatmail_domain}. IN CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}" {chatmail_domain}. CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}"
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all" {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;rua=mailto:{email};ruf=mailto:{email};fo=1;adkim=s;aspf=s"
_mta-sts.{chatmail_domain}. IN TXT "v=STSv1; id={sts_id}" _mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}"
mta-sts.{chatmail_domain}. IN CNAME {chatmail_domain}. mta-sts.{chatmail_domain}. CNAME {chatmail_domain}.
_smtp._tls.{chatmail_domain}. IN TXT "v=TLSRPTv1;rua=mailto:{email}" www.{chatmail_domain}. CNAME {chatmail_domain}.
_smtp._tls.{chatmail_domain}. TXT "v=TLSRPTv1;rua=mailto:{email}"
{dkim_entry} {dkim_entry}

View File

@@ -3,7 +3,6 @@ Provides the `cmdeploy` entry point function,
along with command line option and subcommand parsing. along with command line option and subcommand parsing.
""" """
import argparse import argparse
import datetime
import shutil import shutil
import subprocess import subprocess
import importlib.resources import importlib.resources
@@ -15,6 +14,7 @@ from pathlib import Path
from termcolor import colored from termcolor import colored
from chatmaild.config import read_config, write_initial_config from chatmaild.config import read_config, write_initial_config
from cmdeploy.dns import show_dns, check_necessary_dns
# #
@@ -32,11 +32,16 @@ def init_cmd_options(parser):
def init_cmd(args, out): def init_cmd(args, out):
"""Initialize chatmail config file.""" """Initialize chatmail config file."""
mail_domain = args.chatmail_domain
if args.inipath.exists(): if args.inipath.exists():
out.red(f"Path exists, not modifying: {args.inipath}") print(f"Path exists, not modifying: {args.inipath}")
raise SystemExit(1) else:
write_initial_config(args.inipath, args.chatmail_domain) write_initial_config(args.inipath, mail_domain)
out.green(f"created config file for {args.chatmail_domain} in {args.inipath}") out.green(f"created config file for {mail_domain} in {args.inipath}")
check_necessary_dns(
out,
mail_domain,
)
def run_cmd_options(parser): def run_cmd_options(parser):
@@ -50,47 +55,34 @@ def run_cmd_options(parser):
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
mail_domain = args.config.mail_domain
if not check_necessary_dns(
out,
mail_domain,
):
sys.exit(1)
env = os.environ.copy() env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath env["CHATMAIL_INI"] = args.inipath
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve() deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {args.config.mail_domain} {deploy_path}" cmd = f"{pyinf} --ssh-user root {args.config.mail_domain} {deploy_path}"
out.check_call(cmd, env=env) out.check_call(cmd, env=env)
print("Deploy completed, call `cmdeploy dns` next.")
def dns_cmd_options(parser):
parser.add_argument(
"--zonefile",
dest="zonefile",
help="print the whole zonefile for deploying directly",
)
def dns_cmd(args, out): def dns_cmd(args, out):
"""Generate dns zone file.""" """Generate dns zone file."""
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f") show_dns(args, out)
ssh = f"ssh root@{args.config.mail_domain}"
def read_dkim_entries(entry):
lines = []
for line in entry.split("\n"):
if line.startswith(";") or not line.strip():
continue
line = line.replace("\t", " ")
lines.append(line)
return "\n".join(lines)
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
dkim_entry = read_dkim_entries(out.shell_output(f"{ssh} -- opendkim-genzone -F"))
out(
f"[writing {args.config.mail_domain} zone data (using space as separator) to stdout output]",
green=True,
)
print(
template.read_text()
.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
)
.strip()
)
def status_cmd(args, out): def status_cmd(args, out):
@@ -219,9 +211,15 @@ class Out:
color = "red" if red else ("green" if green else None) color = "red" if red else ("green" if green else None)
print(colored(msg, color), file=file) print(colored(msg, color), file=file)
def shell_output(self, arg): def shell_output(self, arg, no_print=False, timeout=10):
self(f"[$ {arg}]", file=sys.stderr) if not no_print:
return subprocess.check_output(arg, shell=True).decode() self(f"[$ {arg}]", file=sys.stderr)
output = subprocess.STDOUT
else:
output = subprocess.DEVNULL
return subprocess.check_output(
arg, shell=True, timeout=timeout, stderr=output
).decode()
def check_call(self, arg, env=None, quiet=False): def check_call(self, arg, env=None, quiet=False):
if not quiet: if not quiet:

View File

@@ -0,0 +1,209 @@
import sys
import requests
import importlib
import subprocess
import datetime
class DNS:
def __init__(self, out, mail_domain):
self.session = requests.Session()
self.out = out
self.ssh = f"ssh root@{mail_domain} -- "
try:
self.shell(f"unbound-control flush_zone {mail_domain}")
except subprocess.CalledProcessError:
pass
def shell(self, cmd):
try:
return self.out.shell_output(f"{self.ssh}{cmd}", no_print=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
if "exit status 255" in str(e) or "timed out" in str(e):
self.out.red(f"Error: can't reach the server with: {self.ssh[:-4]}")
sys.exit(1)
else:
raise
def get_ipv4(self):
cmd = "ip a | grep 'inet ' | grep 'scope global' | grep -oE '[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}' | head -1"
return self.shell(cmd).strip()
def get_ipv6(self):
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
return self.shell(cmd).strip()
def get(self, typ: str, domain: str) -> str | None:
"""Get a DNS entry"""
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) -> bool:
"""Check the PTR record for an IPv4 or IPv6 address."""
result = self.shell(f"dig -r -x {ip} +short").rstrip()
return result == f"{mail_domain}."
def show_dns(args, out):
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
mail_domain = args.config.mail_domain
ssh = f"ssh root@{mail_domain}"
dns = DNS(out, mail_domain)
def read_dkim_entries(entry):
lines = []
for line in entry.split("\n"):
if line.startswith(";") or not line.strip():
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...")
try:
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} -- cat /var/lib/rspamd/dkim/{mail_domain}.dkim.zone")
)
ipv6 = dns.get_ipv6()
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
ipv4 = dns.get_ipv4()
reverse_ipv4 = dns.check_ptr_record(ipv4, mail_domain)
to_print = []
with open(template, "r") as f:
zonefile = (
f.read()
.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
ipv6=ipv6,
ipv4=ipv4,
)
.strip()
)
try:
with open(args.zonefile, "w+") as zf:
zf.write(zonefile)
print(f"DNS records successfully written to: {args.zonefile}")
return
except TypeError:
pass
started_dkim_parsing = False
for line in zonefile.splitlines():
line = line.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
ipv6=ipv6,
).strip()
for typ in ["A", "AAAA", "CNAME", "CAA"]:
if f" {typ} " in line:
domain, value = line.split(f" {typ} ")
current = dns.get(typ, domain.strip()[:-1])
if current != value.strip():
to_print.append(line)
if " MX " in line:
domain, typ, prio, value = line.split()
current = dns.get(typ, domain[:-1])
if not current:
to_print.append(line)
elif current.split()[1] != value:
print(line.replace(prio, str(int(current[0]) + 1)))
if " SRV " in line:
domain, typ, prio, weight, port, value = line.split()
current = dns.get("SRV", domain[:-1])
if current != f"{prio} {weight} {port} {value}":
to_print.append(line)
if " TXT " in line:
domain, value = line.split(" TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if domain.startswith("_mta-sts."):
if current:
if current.split("id=")[0] == value.split("id=")[0]:
continue
if current != value:
to_print.append(line)
if " IN TXT ( " in line:
started_dkim_parsing = True
dkim_lines = [line]
if started_dkim_parsing and line.startswith('"'):
dkim_lines.append(" " + line)
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 != data:
to_print.append(dkim_entry)
else:
to_print.append(dkim_entry)
if to_print:
to_print.insert(
0, "You should configure the following DNS entries at your provider:\n"
)
to_print.append(
"\nIf you already configured the DNS entries, wait a bit until the DNS entries propagate to the Internet."
)
print("\n".join(to_print))
else:
out.green("Great! All your DNS entries are correct.")
to_print = []
if not reverse_ipv4:
to_print.append(f"\tIPv4:\t{ipv4}\t{args.config.mail_domain}")
if not reverse_ipv6:
to_print.append(f"\tIPv6:\t{ipv6}\t{args.config.mail_domain}")
if len(to_print) > 0:
if len(to_print) == 1:
warning = "You should add the following PTR/reverse DNS entry:"
else:
warning = "You should add the following PTR/reverse DNS entries:"
out.red(warning)
for entry in to_print:
print(entry)
print(
"You can do so at your hosting provider (maybe this isn't your DNS provider)."
)
def check_necessary_dns(out, mail_domain):
"""Check whether $mail_domain and mta-sts.$mail_domain resolve."""
dns = 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)
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 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,
"\nFor chatmail to work, you need to configure this at your DNS provider:\n",
)
for line in to_print:
print(line)
print()
else:
dns.out.green("\nAll necessary DNS entries seem to be set.")
return True

View File

@@ -1,5 +1,10 @@
uri = proxy:/run/dovecot/doveauth.socket:auth uri = proxy:/run/dovecot/doveauth.socket:auth
iterate_disable = yes iterate_disable = yes
default_pass_scheme = plain default_pass_scheme = plain
password_key = passdb/%w/%u # %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
user_key = userdb/%u # 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

@@ -41,19 +41,19 @@ http {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
} }
location /metrics { location /metrics {
default_type text/plain; default_type text/plain;
} }
# add cgi-bin support # add cgi-bin support
include /usr/share/doc/fcgiwrap/examples/nginx.conf; include /usr/share/doc/fcgiwrap/examples/nginx.conf;
} }
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 301 https://$host$request_uri; # 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 readme_directory = no
# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 2 on # See http://www.postfix.org/COMPATIBILITY_README.html
# fresh installs. compatibility_level = 3.6
compatibility_level = 2
# TLS parameters # TLS parameters
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain 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_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }} 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 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,30 @@
rules {
REJECT_DKIM_SPF {
action = "reject";
# Reject if
# - R_DKIM_RJECT: DKIM reject inserted by `dkim` module.
# - R_DKIM_PERMFAIL: permanent failure inserted by `dkim` module e.g. no DKIM DNS record found.
# - No DKIM signing (R_DKIM_NA symbol inserted by `dkim` module)
#
# - SPF failure (R_SPF_FAIL)
# - SPF permanent failure, e.g. failed to resolve DNS record referenced from SPF (R_SPF_PERMFAIL)
#
# - DMARC policy failure (DMARC_POLICY_REJECT)
#
# 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
expression = "R_DKIM_REJECT | R_DKIM_PERMFAIL | R_DKIM_NA | R_SPF_FAIL | R_SPF_PERMFAIL | DMARC_POLICY_REJECT";
}
}

View File

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

View File

@@ -2,6 +2,16 @@ import pytest
import threading import threading
import queue import queue
from chatmaild.config import read_config
from cmdeploy.cmdeploy import main
def test_init(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini")
main(["init", "--config", str(inipath), maildomain])
config = read_config(inipath)
assert config.mail_domain == maildomain
def test_login_basic_functioning(imap_or_smtp, gencreds, lp): def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
"""Test a) that an initial login creates a user automatically """Test a) that an initial login creates a user automatically

View File

@@ -14,3 +14,12 @@ def test_fastcgi_working(maildomain, chatmail_config):
res = requests.post(url) res = requests.post(url)
assert maildomain in res.json().get("email") assert maildomain in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length 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}/cgi-bin/newemail.py"
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) 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="Spam message rejected"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
@pytest.mark.slow @pytest.mark.slow
def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config): def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
"""Test that the per-account send-mail limit is exceeded.""" """Test that the per-account send-mail limit is exceeded."""

View File

@@ -1,7 +1,10 @@
import time import time
import re import re
import random import random
import pytest import pytest
import requests
import ipaddress
class TestEndToEndDeltaChat: class TestEndToEndDeltaChat:
@@ -119,3 +122,17 @@ class TestEndToEndDeltaChat:
for msg in msgs: for msg in msgs:
assert "error" not in m.get_message_info() assert "error" not in m.get_message_info()
time.sleep(1) 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

@@ -2,7 +2,6 @@ import os
import pytest import pytest
from cmdeploy.cmdeploy import get_parser, main from cmdeploy.cmdeploy import get_parser, main
from chatmaild.config import read_config
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -21,12 +20,7 @@ class TestCmdline:
run = parser.parse_args(["run"]) run = parser.parse_args(["run"])
assert init and run assert init and run
def test_init(self, tmp_path): @pytest.mark.xfail(reason="init doesn't exit anymore, check for CLI output instead")
main(["init", "chat.example.org"])
inipath = tmp_path.joinpath("chatmail.ini")
config = read_config(inipath)
assert config.mail_domain == "chat.example.org"
def test_init_not_overwrite(self): def test_init_not_overwrite(self):
main(["init", "chat.example.org"]) main(["init", "chat.example.org"])
with pytest.raises(SystemExit): with pytest.raises(SystemExit):

View File

@@ -3,7 +3,7 @@
## More information ## 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 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.