mirror of
https://github.com/chatmail/relay.git
synced 2026-05-14 01:44:38 +00:00
Compare commits
63 Commits
version-pe
...
hpk/cliff-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a77f5c5f42 | ||
|
|
84b970f1ea | ||
|
|
1f4ba7bf43 | ||
|
|
5de5ba46ad | ||
|
|
337e045284 | ||
|
|
b60bc1862a | ||
|
|
579e7c767d | ||
|
|
a735543c80 | ||
|
|
948efff70d | ||
|
|
9945402d4c | ||
|
|
b39c27c5cc | ||
|
|
38ba28be8a | ||
|
|
1aa4896260 | ||
|
|
823bf88c74 | ||
|
|
78b326dbd1 | ||
|
|
e708027edb | ||
|
|
27b72039c8 | ||
|
|
32534251bf | ||
|
|
1a03d56c80 | ||
|
|
e043c1baff | ||
|
|
a9d3709663 | ||
|
|
741948682f | ||
|
|
68b78bf6d2 | ||
|
|
0e756c653d | ||
|
|
69de709b0f | ||
|
|
d11163629b | ||
|
|
e92c8766f6 | ||
|
|
334e468889 | ||
|
|
33e6807ca4 | ||
|
|
2029acc5a9 | ||
|
|
d1788b7c65 | ||
|
|
84ab4bb6b8 | ||
|
|
04451ad537 | ||
|
|
d09a118e54 | ||
|
|
5b982a3b0f | ||
|
|
71636b8250 | ||
|
|
e7df1a43a3 | ||
|
|
cfc94a37b3 | ||
|
|
22b77168ed | ||
|
|
ffcd657a88 | ||
|
|
39fd04473c | ||
|
|
49613f7e71 | ||
|
|
ded9dd470d | ||
|
|
b94ad729fd | ||
|
|
b60267f37f | ||
|
|
a0aa2912dd | ||
|
|
76108c1c03 | ||
|
|
61b8dc4637 | ||
|
|
d42f579291 | ||
|
|
dd3cf4d449 | ||
|
|
7361cc9350 | ||
|
|
00f199816d | ||
|
|
8d7e1dad0e | ||
|
|
c0da7bb3bf | ||
|
|
863ded6480 | ||
|
|
d75321b355 | ||
|
|
9148b16d81 | ||
|
|
fa9aa5b015 | ||
|
|
0155f32df6 | ||
|
|
9ddd5d8b2b | ||
|
|
4cfe228a1f | ||
|
|
741a20450c | ||
|
|
b7fadcd4be |
4
.github/ISSUE_TEMPLATE/config.yml
vendored
4
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: true
|
||||||
contact_links:
|
|
||||||
- name: Mutual Help Chat Group
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
This diagram shows components of the chatmail server; this is a draft
|
## Chatmail Relay
|
||||||
|
|
||||||
|
This diagram shows components of the chatmail relay; this is a draft
|
||||||
overview as of mid-August 2025:
|
overview as of mid-August 2025:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -19,7 +21,6 @@ graph LR;
|
|||||||
/var/lib/acme`")] --> nginx-internal;
|
/var/lib/acme`")] --> nginx-internal;
|
||||||
cron --- chatmail-metrics;
|
cron --- chatmail-metrics;
|
||||||
cron --- acmetool;
|
cron --- acmetool;
|
||||||
cron --- expunge;
|
|
||||||
chatmail-metrics --- website;
|
chatmail-metrics --- website;
|
||||||
acmetool --> certs[("`TLS certs
|
acmetool --> certs[("`TLS certs
|
||||||
/var/lib/acme`")];
|
/var/lib/acme`")];
|
||||||
@@ -35,7 +36,8 @@ graph LR;
|
|||||||
dovecot --- users;
|
dovecot --- users;
|
||||||
dovecot --- |metadata.socket|chatmail-metadata;
|
dovecot --- |metadata.socket|chatmail-metadata;
|
||||||
doveauth --- users;
|
doveauth --- users;
|
||||||
expunge --- users;
|
chatmail-expire-daily --- users;
|
||||||
|
chatmail-fsreport-daily --- users;
|
||||||
chatmail-metadata --- iroh-relay;
|
chatmail-metadata --- iroh-relay;
|
||||||
certs-nginx --> postfix;
|
certs-nginx --> postfix;
|
||||||
certs-nginx --> dovecot;
|
certs-nginx --> dovecot;
|
||||||
@@ -48,3 +50,66 @@ graph LR;
|
|||||||
The edges in this graph should not be taken too literally; they
|
The edges in this graph should not be taken too literally; they
|
||||||
reflect some sort of communication path or dependency relationship
|
reflect some sort of communication path or dependency relationship
|
||||||
between components of the chatmail server.
|
between components of the chatmail server.
|
||||||
|
|
||||||
|
## cmdeploy
|
||||||
|
|
||||||
|
cmdeploy is a Python program that uses the pyinfra library to deploy
|
||||||
|
chatmail servers, with all the necessary software, configuration, and
|
||||||
|
services. The deployment process performs three primary types of operation:
|
||||||
|
|
||||||
|
1. Installation of software, universal across all deployments.
|
||||||
|
2. Configuration of software, with deploy-specific variations.
|
||||||
|
3. Activation of services.
|
||||||
|
|
||||||
|
The process is implemented through a family of "deployer" objects
|
||||||
|
which all derive from a common `Deployer` base class, defined in
|
||||||
|
[deployer.py](cmdeploy/src/cmdeploy/deployer.py). Each object
|
||||||
|
provides implementation methods for the three stages -- install,
|
||||||
|
configure, and activate. The top-level procedure in
|
||||||
|
`deploy_chatmail()` calls these methods for all the deployer objects,
|
||||||
|
first calling all the install methods, then the configure methods,
|
||||||
|
then the activate methods.
|
||||||
|
|
||||||
|
The base class also implements support for a CMDEPLOY_STAGES
|
||||||
|
environment variable, which allows limiting the process to specific
|
||||||
|
stages. Note that some deployers are stateful between the stages
|
||||||
|
(this is one reason why they are implemented as objects), and that
|
||||||
|
state will not get propagated between stages when run in separate
|
||||||
|
invocations of cmdeploy. This environment variable is intended for
|
||||||
|
use in future revisions to support building Docker images with
|
||||||
|
software pre-installed, and configuration of containers at run time
|
||||||
|
from environmnet variables.
|
||||||
|
|
||||||
|
The `install_impl()` method for the deployer classes is static, to
|
||||||
|
ensure that it does not rely on any object state, in particular, the
|
||||||
|
configuration details of the deployment. This helps ensure that all
|
||||||
|
install methods are suitable for running as part of a container image
|
||||||
|
build.
|
||||||
|
|
||||||
|
Operations that start services for systemd-based deployments should
|
||||||
|
only be called from the `activate_impl()` methods. These methods will
|
||||||
|
not be called in non-systemd container environments.
|
||||||
|
|
||||||
|
### Deployer objects
|
||||||
|
|
||||||
|
One might ask why the deployers are implemented as object classes, as
|
||||||
|
opposed to callable functions or the like. There are various reasons
|
||||||
|
why objects are a good fit for the deployment process.
|
||||||
|
|
||||||
|
1. Objects provide a way to organize the install, configure, and
|
||||||
|
deploy operations for each component that is installed, supporting a
|
||||||
|
"driver" type of pattern. This could be implemented in other ways
|
||||||
|
without objects, such as function jump tables, but objects provide a
|
||||||
|
clean and formalized way to do essentially the same thing.
|
||||||
|
|
||||||
|
2. Class inheritance provides a natural way to define
|
||||||
|
component-specific operations for the various stages of deployment, by
|
||||||
|
overriding the no-op stub methods in the base class. The base class
|
||||||
|
handles policy decisions about which stages are to be executed,
|
||||||
|
ensuring consistent handling of the stages in a central location.
|
||||||
|
|
||||||
|
3. Some of the components track state between stages, basing decisions
|
||||||
|
like whether to restart a service on whether the software or
|
||||||
|
configuration of that service was changed in an earlier stage.
|
||||||
|
Keeping track of state between method calls is an ideal use case for
|
||||||
|
objects.
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
## untagged
|
## untagged
|
||||||
|
|
||||||
|
- Organized cmdeploy into install, configure, and activate stages
|
||||||
|
([#695](https://github.com/chatmail/relay/pull/695))
|
||||||
|
|
||||||
|
- don't deploy the website if there are merge conflicts in the www folder
|
||||||
|
([#714](https://github.com/chatmail/relay/pull/714))
|
||||||
|
|
||||||
|
- acmetool: use ECDSA keys instead of RSA
|
||||||
|
([#689](https://github.com/chatmail/relay/pull/689))
|
||||||
|
|
||||||
|
- Require TLS 1.2 for outgoing SMTP connections
|
||||||
|
([#685](https://github.com/chatmail/relay/pull/685))
|
||||||
|
|
||||||
|
- require STARTTLS for incoming port 25 connections
|
||||||
|
([#684](https://github.com/chatmail/relay/pull/684))
|
||||||
|
|
||||||
- filtermail: run CPU-intensive handle_DATA in a thread pool executor
|
- filtermail: run CPU-intensive handle_DATA in a thread pool executor
|
||||||
([#676](https://github.com/chatmail/relay/pull/676))
|
([#676](https://github.com/chatmail/relay/pull/676))
|
||||||
|
|
||||||
@@ -21,7 +36,7 @@
|
|||||||
([#650](https://github.com/chatmail/relay/pull/650))
|
([#650](https://github.com/chatmail/relay/pull/650))
|
||||||
|
|
||||||
- filtermail: accept mails from Protonmail
|
- filtermail: accept mails from Protonmail
|
||||||
([#616](https://github.com/chatmail/relay/pull/655))
|
([#616](https://github.com/chatmail/relay/pull/616))
|
||||||
|
|
||||||
- Ignore all RCPT TO: parameters
|
- Ignore all RCPT TO: parameters
|
||||||
([#651](https://github.com/chatmail/relay/pull/651))
|
([#651](https://github.com/chatmail/relay/pull/651))
|
||||||
@@ -50,6 +65,13 @@
|
|||||||
- Add `--skip-dns-check` argument to `cmdeploy run` command, which disables DNS record checking before installation.
|
- Add `--skip-dns-check` argument to `cmdeploy run` command, which disables DNS record checking before installation.
|
||||||
([#661](https://github.com/chatmail/relay/pull/661))
|
([#661](https://github.com/chatmail/relay/pull/661))
|
||||||
|
|
||||||
|
- Rework expiry of message files and mailboxes in Python
|
||||||
|
to only do a single iteration over sometimes millions of messages
|
||||||
|
instead of doing "find" commands that iterate 9 times over the messages.
|
||||||
|
Provide an "fsreport" CLI for more fine grained analysis of message files.
|
||||||
|
([#637](https://github.com/chatmail/relay/pull/637))
|
||||||
|
|
||||||
|
|
||||||
## 1.7.0 2025-09-11
|
## 1.7.0 2025-09-11
|
||||||
|
|
||||||
- Make www upload path configurable
|
- Make www upload path configurable
|
||||||
|
|||||||
@@ -180,6 +180,10 @@ The components of chatmail are:
|
|||||||
- [Iroh relay](https://www.iroh.computer/docs/concepts/relay)
|
- [Iroh relay](https://www.iroh.computer/docs/concepts/relay)
|
||||||
which helps client devices to establish Peer-to-Peer connections
|
which helps client devices to establish Peer-to-Peer connections
|
||||||
|
|
||||||
|
- [TURN](https://github.com/chatmail/chatmail-turn)
|
||||||
|
to enable relay users to start webRTC calls
|
||||||
|
even if a p2p connection can't be established
|
||||||
|
|
||||||
- and the chatmaild services, explained in the next section:
|
- and the chatmaild services, explained in the next section:
|
||||||
|
|
||||||
### chatmaild
|
### chatmaild
|
||||||
@@ -304,6 +308,8 @@ Chatmail address creation will be denied while this file is present.
|
|||||||
[Nginx](https://www.nginx.com/) listens on port 8443 (HTTPS-ALT) and 443 (HTTPS).
|
[Nginx](https://www.nginx.com/) listens on port 8443 (HTTPS-ALT) and 443 (HTTPS).
|
||||||
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
|
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
|
||||||
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (HTTP).
|
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (HTTP).
|
||||||
|
[chatmail-turn](https://github.com/chatmail/chatmail-turn) listens on UDP port 3478 (STUN/TURN),
|
||||||
|
and temporarily opens UDP ports when users request them. UDP port range is not restricted, any free port may be allocated.
|
||||||
|
|
||||||
chatmail-core based apps will, however, discover all ports and configurations
|
chatmail-core based apps will, however, discover all ports and configurations
|
||||||
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail relay server.
|
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail relay server.
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ chatmail-metadata = "chatmaild.metadata:main"
|
|||||||
filtermail = "chatmaild.filtermail:main"
|
filtermail = "chatmaild.filtermail:main"
|
||||||
echobot = "chatmaild.echo:main"
|
echobot = "chatmaild.echo:main"
|
||||||
chatmail-metrics = "chatmaild.metrics:main"
|
chatmail-metrics = "chatmaild.metrics:main"
|
||||||
delete_inactive_users = "chatmaild.delete_inactive_users:main"
|
chatmail-expire = "chatmaild.expire:main"
|
||||||
|
chatmail-fsreport = "chatmaild.fsreport:main"
|
||||||
lastlogin = "chatmaild.lastlogin:main"
|
lastlogin = "chatmaild.lastlogin:main"
|
||||||
turnserver = "chatmaild.turnserver:main"
|
turnserver = "chatmaild.turnserver:main"
|
||||||
|
|
||||||
@@ -71,5 +72,6 @@ commands =
|
|||||||
[testenv]
|
[testenv]
|
||||||
deps = pytest
|
deps = pytest
|
||||||
pdbpp
|
pdbpp
|
||||||
|
pytest-localserver
|
||||||
commands = pytest -v -rsXx {posargs}
|
commands = pytest -v -rsXx {posargs}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
"""
|
|
||||||
Remove inactive users
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
from .config import read_config
|
|
||||||
|
|
||||||
|
|
||||||
def delete_inactive_users(config):
|
|
||||||
cutoff_date = time.time() - config.delete_inactive_users_after * 86400
|
|
||||||
for addr in os.listdir(config.mailboxes_dir):
|
|
||||||
try:
|
|
||||||
user = config.get_user(addr)
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
read_timestamp = user.get_last_login_timestamp()
|
|
||||||
if read_timestamp and read_timestamp < cutoff_date:
|
|
||||||
path = config.mailboxes_dir.joinpath(addr)
|
|
||||||
assert path == user.maildir
|
|
||||||
shutil.rmtree(path, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
(cfgpath,) = sys.argv[1:]
|
|
||||||
config = read_config(cfgpath)
|
|
||||||
delete_inactive_users(config)
|
|
||||||
218
chatmaild/src/chatmaild/expire.py
Normal file
218
chatmaild/src/chatmaild/expire.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Expire old messages and addresses.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from collections import namedtuple
|
||||||
|
from datetime import datetime
|
||||||
|
from stat import S_ISREG
|
||||||
|
|
||||||
|
from chatmaild.config import read_config
|
||||||
|
|
||||||
|
FileEntry = namedtuple("FileEntry", ("relpath", "mtime", "size"))
|
||||||
|
|
||||||
|
|
||||||
|
def iter_mailboxes(basedir, maxnum):
|
||||||
|
if not os.path.exists(basedir):
|
||||||
|
print_info(f"no mailboxes found at: {basedir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for name in os_listdir_if_exists(basedir)[:maxnum]:
|
||||||
|
if "@" in name:
|
||||||
|
yield MailboxStat(basedir + "/" + name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_entry(path):
|
||||||
|
"""return a FileEntry or None if the path does not exist or is not a regular file."""
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
if not S_ISREG(st.st_mode):
|
||||||
|
return None
|
||||||
|
return FileEntry(path, st.st_mtime, st.st_size)
|
||||||
|
|
||||||
|
|
||||||
|
def os_listdir_if_exists(path):
|
||||||
|
"""return a list of names obtained from os.listdir or an empty list if the path does not exist."""
|
||||||
|
try:
|
||||||
|
return os.listdir(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class MailboxStat:
|
||||||
|
last_login = None
|
||||||
|
|
||||||
|
def __init__(self, basedir):
|
||||||
|
self.basedir = str(basedir)
|
||||||
|
# all detected messages in cur/new/tmp folders
|
||||||
|
self.messages = []
|
||||||
|
|
||||||
|
# all detected files in mailbox top dir
|
||||||
|
self.extrafiles = []
|
||||||
|
|
||||||
|
# scan all relevant files (without recursion)
|
||||||
|
old_cwd = os.getcwd()
|
||||||
|
try:
|
||||||
|
os.chdir(self.basedir)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
for name in os_listdir_if_exists("."):
|
||||||
|
if name in ("cur", "new", "tmp"):
|
||||||
|
for msg_name in os_listdir_if_exists(name):
|
||||||
|
entry = get_file_entry(f"{name}/{msg_name}")
|
||||||
|
if entry is not None:
|
||||||
|
self.messages.append(entry)
|
||||||
|
|
||||||
|
else:
|
||||||
|
entry = get_file_entry(name)
|
||||||
|
if entry is not None:
|
||||||
|
self.extrafiles.append(entry)
|
||||||
|
if name == "password":
|
||||||
|
self.last_login = entry.mtime
|
||||||
|
self.extrafiles.sort(key=lambda x: -x.size)
|
||||||
|
os.chdir(old_cwd)
|
||||||
|
|
||||||
|
|
||||||
|
def print_info(msg):
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
class Expiry:
|
||||||
|
def __init__(self, config, dry, now, verbose):
|
||||||
|
self.config = config
|
||||||
|
self.dry = dry
|
||||||
|
self.now = now
|
||||||
|
self.verbose = verbose
|
||||||
|
self.del_mboxes = 0
|
||||||
|
self.all_mboxes = 0
|
||||||
|
self.del_files = 0
|
||||||
|
self.all_files = 0
|
||||||
|
self.start = time.time()
|
||||||
|
|
||||||
|
def remove_mailbox(self, mboxdir):
|
||||||
|
if self.verbose:
|
||||||
|
print_info(f"removing {mboxdir}")
|
||||||
|
if not self.dry:
|
||||||
|
shutil.rmtree(mboxdir)
|
||||||
|
self.del_mboxes += 1
|
||||||
|
|
||||||
|
def remove_file(self, path, mtime=None):
|
||||||
|
if self.verbose:
|
||||||
|
if mtime is not None:
|
||||||
|
date = datetime.fromtimestamp(mtime).strftime("%b %d")
|
||||||
|
print_info(f"removing {date} {path}")
|
||||||
|
else:
|
||||||
|
print_info(f"removing {path}")
|
||||||
|
if not self.dry:
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print_info(f"file not found/vanished {path}")
|
||||||
|
self.del_files += 1
|
||||||
|
|
||||||
|
def process_mailbox_stat(self, mbox):
|
||||||
|
cutoff_without_login = (
|
||||||
|
self.now - int(self.config.delete_inactive_users_after) * 86400
|
||||||
|
)
|
||||||
|
cutoff_mails = self.now - int(self.config.delete_mails_after) * 86400
|
||||||
|
cutoff_large_mails = self.now - int(self.config.delete_large_after) * 86400
|
||||||
|
|
||||||
|
self.all_mboxes += 1
|
||||||
|
changed = False
|
||||||
|
if mbox.last_login and mbox.last_login < cutoff_without_login:
|
||||||
|
self.remove_mailbox(mbox.basedir)
|
||||||
|
return
|
||||||
|
|
||||||
|
# all to-be-removed files are relative to the mailbox basedir
|
||||||
|
try:
|
||||||
|
os.chdir(mbox.basedir)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print_info(f"mailbox not found/vanished {mbox.basedir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
mboxname = os.path.basename(mbox.basedir)
|
||||||
|
if self.verbose:
|
||||||
|
date = datetime.fromtimestamp(mbox.last_login) if mbox.last_login else None
|
||||||
|
if date:
|
||||||
|
print_info(f"checking mailbox {date.strftime('%b %d')} {mboxname}")
|
||||||
|
else:
|
||||||
|
print_info(f"checking mailbox (no last_login) {mboxname}")
|
||||||
|
self.all_files += len(mbox.messages)
|
||||||
|
for message in mbox.messages:
|
||||||
|
if message.mtime < cutoff_mails:
|
||||||
|
self.remove_file(message.relpath, mtime=message.mtime)
|
||||||
|
elif message.size > 200000 and message.mtime < cutoff_large_mails:
|
||||||
|
# we only remove noticed large files (not unnoticed ones in new/)
|
||||||
|
if message.relpath.startswith("cur/"):
|
||||||
|
self.remove_file(message.relpath, mtime=message.mtime)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self.remove_file("maildirsize")
|
||||||
|
|
||||||
|
def get_summary(self):
|
||||||
|
return (
|
||||||
|
f"Removed {self.del_mboxes} out of {self.all_mboxes} mailboxes "
|
||||||
|
f"and {self.del_files} out of {self.all_files} files in existing mailboxes "
|
||||||
|
f"in {time.time() - self.start:2.2f} seconds"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
"""Expire mailboxes and messages according to chatmail config"""
|
||||||
|
parser = ArgumentParser(description=main.__doc__)
|
||||||
|
ini = "/usr/local/lib/chatmaild/chatmail.ini"
|
||||||
|
parser.add_argument(
|
||||||
|
"chatmail_ini",
|
||||||
|
action="store",
|
||||||
|
nargs="?",
|
||||||
|
help=f"path pointing to chatmail.ini file, default: {ini}",
|
||||||
|
default=ini,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--days", action="store", help="assume date to be days older than now"
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--maxnum",
|
||||||
|
default=None,
|
||||||
|
action="store",
|
||||||
|
help="maximum number of mailboxes to iterate on",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
dest="verbose",
|
||||||
|
action="store_true",
|
||||||
|
help="print out removed files and mailboxes",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--remove",
|
||||||
|
dest="remove",
|
||||||
|
action="store_true",
|
||||||
|
help="actually remove all expired files and dirs",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
|
config = read_config(args.chatmail_ini)
|
||||||
|
now = datetime.utcnow().timestamp()
|
||||||
|
if args.days:
|
||||||
|
now = now - 86400 * int(args.days)
|
||||||
|
|
||||||
|
maxnum = int(args.maxnum) if args.maxnum else None
|
||||||
|
exp = Expiry(config, dry=not args.remove, now=now, verbose=args.verbose)
|
||||||
|
for mailbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
|
||||||
|
exp.process_mailbox_stat(mailbox)
|
||||||
|
print(exp.get_summary())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
||||||
@@ -101,13 +101,12 @@ def check_armored_payload(payload: str, outgoing: bool):
|
|||||||
return False
|
return False
|
||||||
payload = payload.removesuffix(suffix)
|
payload = payload.removesuffix(suffix)
|
||||||
|
|
||||||
# Disallow comments in outgoing messages
|
|
||||||
version_comment = "Version: "
|
version_comment = "Version: "
|
||||||
if payload.startswith(version_comment):
|
if payload.startswith(version_comment):
|
||||||
splitindex = payload.find("\r\n") + 2
|
if outgoing: # Disallow comments in outgoing messages
|
||||||
payload = payload[splitindex:]
|
|
||||||
if outgoing:
|
|
||||||
return False
|
return False
|
||||||
|
# Remove comments from incoming messages
|
||||||
|
payload = payload.partition("\r\n")[2]
|
||||||
|
|
||||||
while payload.startswith("\r\n"):
|
while payload.startswith("\r\n"):
|
||||||
payload = payload.removeprefix("\r\n")
|
payload = payload.removeprefix("\r\n")
|
||||||
|
|||||||
168
chatmaild/src/chatmaild/fsreport.py
Normal file
168
chatmaild/src/chatmaild/fsreport.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
"""
|
||||||
|
command line tool to analyze mailbox message storage
|
||||||
|
|
||||||
|
example invocation:
|
||||||
|
|
||||||
|
python -m chatmaild.fsreport /path/to/chatmail.ini
|
||||||
|
|
||||||
|
to show storage summaries for all "cur" folders
|
||||||
|
|
||||||
|
python -m chatmaild.fsreport /path/to/chatmail.ini --mdir cur
|
||||||
|
|
||||||
|
to show storage summaries only for first 1000 mailboxes
|
||||||
|
|
||||||
|
python -m chatmaild.fsreport /path/to/chatmail.ini --maxnum 1000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from chatmaild.config import read_config
|
||||||
|
from chatmaild.expire import iter_mailboxes
|
||||||
|
|
||||||
|
DAYSECONDS = 24 * 60 * 60
|
||||||
|
MONTHSECONDS = DAYSECONDS * 30
|
||||||
|
|
||||||
|
|
||||||
|
def HSize(size: int):
|
||||||
|
"""Format a size integer as a Human-readable string Kilobyte, Megabyte or Gigabyte"""
|
||||||
|
if size < 10000:
|
||||||
|
return f"{size / 1000:5.2f}K"
|
||||||
|
if size < 1000 * 1000:
|
||||||
|
return f"{size / 1000:5.0f}K"
|
||||||
|
if size < 1000 * 1000 * 1000:
|
||||||
|
return f"{int(size / 1000000):5.0f}M"
|
||||||
|
return f"{size / 1000000000:5.2f}G"
|
||||||
|
|
||||||
|
|
||||||
|
class Report:
|
||||||
|
def __init__(self, now, min_login_age, mdir):
|
||||||
|
self.size_extra = 0
|
||||||
|
self.size_messages = 0
|
||||||
|
self.now = now
|
||||||
|
self.min_login_age = min_login_age
|
||||||
|
self.mdir = mdir
|
||||||
|
|
||||||
|
self.num_ci_logins = self.num_all_logins = 0
|
||||||
|
self.login_buckets = {x: 0 for x in (1, 10, 30, 40, 80, 100, 150)}
|
||||||
|
|
||||||
|
self.message_buckets = {x: 0 for x in (0, 160000, 500000, 2000000)}
|
||||||
|
|
||||||
|
def process_mailbox_stat(self, mailbox):
|
||||||
|
# categorize login times
|
||||||
|
last_login = mailbox.last_login
|
||||||
|
if last_login:
|
||||||
|
self.num_all_logins += 1
|
||||||
|
if os.path.basename(mailbox.basedir)[:3] == "ci-":
|
||||||
|
self.num_ci_logins += 1
|
||||||
|
else:
|
||||||
|
for days in self.login_buckets:
|
||||||
|
if last_login >= self.now - days * DAYSECONDS:
|
||||||
|
self.login_buckets[days] += 1
|
||||||
|
|
||||||
|
cutoff_login_date = self.now - self.min_login_age * DAYSECONDS
|
||||||
|
if last_login and last_login <= cutoff_login_date:
|
||||||
|
# categorize message sizes
|
||||||
|
for size in self.message_buckets:
|
||||||
|
for msg in mailbox.messages:
|
||||||
|
if msg.size >= size:
|
||||||
|
if self.mdir and not msg.relpath.startswith(self.mdir):
|
||||||
|
continue
|
||||||
|
self.message_buckets[size] += msg.size
|
||||||
|
|
||||||
|
self.size_messages += sum(entry.size for entry in mailbox.messages)
|
||||||
|
self.size_extra += sum(entry.size for entry in mailbox.extrafiles)
|
||||||
|
|
||||||
|
def dump_summary(self):
|
||||||
|
all_messages = self.size_messages
|
||||||
|
print()
|
||||||
|
print("## Mailbox storage use analysis")
|
||||||
|
print(f"Mailbox data total size: {HSize(self.size_extra + all_messages)}")
|
||||||
|
print(f"Messages total size : {HSize(all_messages)}")
|
||||||
|
try:
|
||||||
|
percent = self.size_extra / (self.size_extra + all_messages) * 100
|
||||||
|
except ZeroDivisionError:
|
||||||
|
percent = 100
|
||||||
|
print(f"Extra files : {HSize(self.size_extra)} ({percent:.2f}%)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if self.min_login_age:
|
||||||
|
print(f"### Message storage for {self.min_login_age} days old logins")
|
||||||
|
|
||||||
|
pref = f"[{self.mdir}] " if self.mdir else ""
|
||||||
|
for minsize, sumsize in self.message_buckets.items():
|
||||||
|
percent = (sumsize / all_messages * 100) if all_messages else 0
|
||||||
|
print(
|
||||||
|
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%)"
|
||||||
|
)
|
||||||
|
|
||||||
|
user_logins = self.num_all_logins - self.num_ci_logins
|
||||||
|
|
||||||
|
def p(num):
|
||||||
|
return f"({num / user_logins * 100:2.2f}%)" if user_logins else "100%"
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"## Login stats, from date reference {datetime.fromtimestamp(self.now)}")
|
||||||
|
print(f"all: {HSize(self.num_all_logins)}")
|
||||||
|
print(f"non-ci: {HSize(user_logins)}")
|
||||||
|
print(f"ci: {HSize(self.num_ci_logins)}")
|
||||||
|
for days, active in self.login_buckets.items():
|
||||||
|
print(f"last {days:3} days: {HSize(active)} {p(active)}")
|
||||||
|
|
||||||
|
|
||||||
|
def main(args=None):
|
||||||
|
"""Report about filesystem storage usage of all mailboxes and messages"""
|
||||||
|
parser = ArgumentParser(description=main.__doc__)
|
||||||
|
ini = "/usr/local/lib/chatmaild/chatmail.ini"
|
||||||
|
parser.add_argument(
|
||||||
|
"chatmail_ini",
|
||||||
|
action="store",
|
||||||
|
nargs="?",
|
||||||
|
help=f"path pointing to chatmail.ini file, default: {ini}",
|
||||||
|
default=ini,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--days",
|
||||||
|
default=0,
|
||||||
|
action="store",
|
||||||
|
help="assume date to be days older than now",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--min-login-age",
|
||||||
|
default=0,
|
||||||
|
dest="min_login_age",
|
||||||
|
action="store",
|
||||||
|
help="only sum up message size if last login is at least min-login-age days old",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--mdir",
|
||||||
|
action="store",
|
||||||
|
help="only consider 'cur' or 'new' or 'tmp' messages for summary",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--maxnum",
|
||||||
|
default=None,
|
||||||
|
action="store",
|
||||||
|
help="maximum number of mailboxes to iterate on",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
|
config = read_config(args.chatmail_ini)
|
||||||
|
|
||||||
|
now = datetime.utcnow().timestamp()
|
||||||
|
if args.days:
|
||||||
|
now = now - 86400 * int(args.days)
|
||||||
|
|
||||||
|
maxnum = int(args.maxnum) if args.maxnum else None
|
||||||
|
rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir)
|
||||||
|
for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
|
||||||
|
rep.process_mailbox_stat(mbox)
|
||||||
|
rep.dump_summary()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from chatmaild.delete_inactive_users import delete_inactive_users
|
|
||||||
from chatmaild.doveauth import AuthDictProxy
|
from chatmaild.doveauth import AuthDictProxy
|
||||||
|
from chatmaild.expire import main as main_expire
|
||||||
|
|
||||||
|
|
||||||
def test_login_timestamps(example_config):
|
def test_login_timestamps(example_config):
|
||||||
@@ -45,7 +45,12 @@ def test_delete_inactive_users(example_config):
|
|||||||
for addr in to_remove:
|
for addr in to_remove:
|
||||||
assert example_config.get_user(addr).maildir.exists()
|
assert example_config.get_user(addr).maildir.exists()
|
||||||
|
|
||||||
delete_inactive_users(example_config)
|
main_expire(
|
||||||
|
args=[
|
||||||
|
"--remove",
|
||||||
|
str(example_config._inipath),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
for p in example_config.mailboxes_dir.iterdir():
|
for p in example_config.mailboxes_dir.iterdir():
|
||||||
assert not p.name.startswith("old")
|
assert not p.name.startswith("old")
|
||||||
|
|||||||
150
chatmaild/src/chatmaild/tests/test_expire.py
Normal file
150
chatmaild/src/chatmaild/tests/test_expire.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import os
|
||||||
|
import random
|
||||||
|
from datetime import datetime
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chatmaild.expire import (
|
||||||
|
FileEntry,
|
||||||
|
MailboxStat,
|
||||||
|
get_file_entry,
|
||||||
|
iter_mailboxes,
|
||||||
|
os_listdir_if_exists,
|
||||||
|
)
|
||||||
|
from chatmaild.expire import main as expiry_main
|
||||||
|
from chatmaild.fsreport import main as report_main
|
||||||
|
|
||||||
|
|
||||||
|
def fill_mbox(basedir):
|
||||||
|
basedir1 = basedir.joinpath("mailbox1@example.org")
|
||||||
|
basedir1.mkdir()
|
||||||
|
password = basedir1.joinpath("password")
|
||||||
|
password.write_text("xxx")
|
||||||
|
basedir1.joinpath("maildirsize").write_text("xxx")
|
||||||
|
|
||||||
|
garbagedir = basedir1.joinpath("garbagedir")
|
||||||
|
garbagedir.mkdir()
|
||||||
|
|
||||||
|
create_new_messages(basedir1, ["cur/msg1"], size=500)
|
||||||
|
create_new_messages(basedir1, ["new/msg2"], size=600)
|
||||||
|
return basedir1
|
||||||
|
|
||||||
|
|
||||||
|
def create_new_messages(basedir, relpaths, size=1000, days=0):
|
||||||
|
now = datetime.utcnow().timestamp()
|
||||||
|
|
||||||
|
for relpath in relpaths:
|
||||||
|
msg_path = Path(basedir).joinpath(relpath)
|
||||||
|
msg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
msg_path.write_text("x" * size)
|
||||||
|
# accessed now, modified N days ago
|
||||||
|
os.utime(msg_path, (now, now - days * 86400))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mbox1(example_config):
|
||||||
|
basedir1 = fill_mbox(example_config.mailboxes_dir)
|
||||||
|
return MailboxStat(basedir1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_filentry_ordering(tmp_path):
|
||||||
|
l = [FileEntry(f"x{i}", size=i + 10, mtime=1000 - i) for i in range(10)]
|
||||||
|
sorted = list(l)
|
||||||
|
random.shuffle(l)
|
||||||
|
l.sort(key=lambda x: x.size)
|
||||||
|
assert l == sorted
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_mailbxoes(tmp_path, capsys):
|
||||||
|
assert [] == list(iter_mailboxes(str(tmp_path.joinpath("notexists")), maxnum=10))
|
||||||
|
out, err = capsys.readouterr()
|
||||||
|
assert "no mailboxes" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats_mailbox(mbox1):
|
||||||
|
password = Path(mbox1.basedir).joinpath("password")
|
||||||
|
assert mbox1.last_login == password.stat().st_mtime
|
||||||
|
assert len(mbox1.messages) == 2
|
||||||
|
|
||||||
|
msgs = list(sorted(mbox1.messages, key=lambda x: x.size))
|
||||||
|
assert len(msgs) == 2
|
||||||
|
assert msgs[0].size == 500 # cur
|
||||||
|
assert msgs[1].size == 600 # new
|
||||||
|
|
||||||
|
create_new_messages(mbox1.basedir, ["large-extra"], size=1000)
|
||||||
|
create_new_messages(mbox1.basedir, ["index-something"], size=3)
|
||||||
|
mbox2 = MailboxStat(mbox1.basedir)
|
||||||
|
assert len(mbox2.extrafiles) == 4
|
||||||
|
assert mbox2.extrafiles[0].size == 1000
|
||||||
|
|
||||||
|
# cope well with mailbox dirs that have no password (for whatever reason)
|
||||||
|
Path(mbox1.basedir).joinpath("password").unlink()
|
||||||
|
mbox3 = MailboxStat(mbox1.basedir)
|
||||||
|
assert mbox3.last_login is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_report_no_mailboxes(example_config):
|
||||||
|
args = (str(example_config._inipath),)
|
||||||
|
report_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_report(mbox1, example_config):
|
||||||
|
args = (str(example_config._inipath),)
|
||||||
|
report_main(args)
|
||||||
|
args = list(args) + "--days 1".split()
|
||||||
|
report_main(args)
|
||||||
|
args = list(args) + "--min-login-age 1".split()
|
||||||
|
report_main(args)
|
||||||
|
args = list(args) + "--mdir cur".split()
|
||||||
|
report_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expiry_cli_basic(example_config, mbox1):
|
||||||
|
args = (str(example_config._inipath),)
|
||||||
|
expiry_main(args)
|
||||||
|
|
||||||
|
|
||||||
|
def test_expiry_cli_old_files(capsys, example_config, mbox1):
|
||||||
|
relpaths_old = ["cur/msg_old1", "cur/msg_old1"]
|
||||||
|
cutoff_days = int(example_config.delete_mails_after) + 1
|
||||||
|
create_new_messages(mbox1.basedir, relpaths_old, size=1000, days=cutoff_days)
|
||||||
|
|
||||||
|
relpaths_large = ["cur/msg_old_large1", "new/msg_old_large2"]
|
||||||
|
cutoff_days = int(example_config.delete_large_after) + 1
|
||||||
|
create_new_messages(
|
||||||
|
mbox1.basedir, relpaths_large, size=1000 * 300, days=cutoff_days
|
||||||
|
)
|
||||||
|
|
||||||
|
create_new_messages(mbox1.basedir, ["cur/shouldstay"], size=1000 * 300, days=1)
|
||||||
|
|
||||||
|
args = str(example_config._inipath), "--remove", "-v"
|
||||||
|
expiry_main(args)
|
||||||
|
out, err = capsys.readouterr()
|
||||||
|
|
||||||
|
allpaths = relpaths_old + relpaths_large + ["maildirsize"]
|
||||||
|
for path in allpaths:
|
||||||
|
for line in err.split("\n"):
|
||||||
|
if fnmatch(line, f"removing*{path}"):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if path != "new/msg_old_large2":
|
||||||
|
pytest.fail(f"failed to remove {path}\n{err}")
|
||||||
|
|
||||||
|
assert "shouldstay" not in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_file_entry(tmp_path):
|
||||||
|
assert get_file_entry(str(tmp_path.joinpath("123123"))) is None
|
||||||
|
p = tmp_path.joinpath("x")
|
||||||
|
p.write_text("hello")
|
||||||
|
entry = get_file_entry(str(p))
|
||||||
|
assert entry.size == 5
|
||||||
|
assert entry.mtime
|
||||||
|
|
||||||
|
|
||||||
|
def test_os_listdir_if_exists(tmp_path):
|
||||||
|
tmp_path.joinpath("x").write_text("hello")
|
||||||
|
assert len(os_listdir_if_exists(str(tmp_path))) == 1
|
||||||
|
assert len(os_listdir_if_exists(str(tmp_path.joinpath("123123")))) == 0
|
||||||
78
chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py
Normal file
78
chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import smtplib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def smtpserver():
|
||||||
|
from pytest_localserver import smtp
|
||||||
|
|
||||||
|
server = smtp.Server("127.0.0.1")
|
||||||
|
server.start()
|
||||||
|
yield server
|
||||||
|
server.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def make_popen(request):
|
||||||
|
def popen(cmdargs, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw):
|
||||||
|
p = subprocess.Popen(
|
||||||
|
cmdargs,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def fin():
|
||||||
|
p.terminate()
|
||||||
|
out, err = p.communicate()
|
||||||
|
print(out.decode("ascii"))
|
||||||
|
print(err.decode("ascii"), file=sys.stderr)
|
||||||
|
|
||||||
|
request.addfinalizer(fin)
|
||||||
|
return p
|
||||||
|
|
||||||
|
return popen
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("filtermail_mode", ["outgoing", "incoming"])
|
||||||
|
def test_one_mail(
|
||||||
|
make_config, make_popen, smtpserver, maildata, filtermail_mode, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("PYTHONUNBUFFERED", "1")
|
||||||
|
smtp_inject_port = 20025
|
||||||
|
if filtermail_mode == "outgoing":
|
||||||
|
settings = dict(
|
||||||
|
postfix_reinject_port=smtpserver.port,
|
||||||
|
filtermail_smtp_port=smtp_inject_port,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
settings = dict(
|
||||||
|
postfix_reinject_port_incoming=smtpserver.port,
|
||||||
|
filtermail_smtp_port_incoming=smtp_inject_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = make_config("example.org", settings=settings)
|
||||||
|
path = str(config._inipath)
|
||||||
|
|
||||||
|
popen = make_popen(["filtermail", path, filtermail_mode])
|
||||||
|
line = popen.stderr.readline().strip()
|
||||||
|
if b"loop" not in line:
|
||||||
|
print(line.decode("ascii"), file=sys.stderr)
|
||||||
|
pytest.fail("starting filtermail failed")
|
||||||
|
|
||||||
|
addr = f"user1@{config.mail_domain}"
|
||||||
|
config.get_user(addr).set_password("l1k2j3l1k2j3l")
|
||||||
|
|
||||||
|
# send encrypted mail
|
||||||
|
data = str(maildata("encrypted.eml", from_addr=addr, to_addr=addr))
|
||||||
|
client = smtplib.SMTP("localhost", smtp_inject_port)
|
||||||
|
client.sendmail(addr, [addr], data)
|
||||||
|
assert len(smtpserver.outbox) == 1
|
||||||
|
|
||||||
|
# send un-encrypted mail that errors
|
||||||
|
data = str(maildata("fake-encrypted.eml", from_addr=addr, to_addr=addr))
|
||||||
|
with pytest.raises(smtplib.SMTPDataError) as e:
|
||||||
|
client.sendmail(addr, [addr], data)
|
||||||
|
assert e.value.smtp_code == 523
|
||||||
@@ -6,4 +6,4 @@ def turn_credentials() -> str:
|
|||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
|
||||||
client_socket.connect("/run/chatmail-turn/turn.socket")
|
client_socket.connect("/run/chatmail-turn/turn.socket")
|
||||||
with client_socket.makefile("rb") as file:
|
with client_socket.makefile("rb") as file:
|
||||||
return file.readline().decode("utf-8")
|
return file.readline().decode("utf-8").strip()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,66 +2,79 @@ import importlib.resources
|
|||||||
|
|
||||||
from pyinfra.operations import apt, files, server, systemd
|
from pyinfra.operations import apt, files, server, systemd
|
||||||
|
|
||||||
|
from ..deployer import Deployer
|
||||||
|
|
||||||
def deploy_acmetool(email="", domains=[]):
|
|
||||||
"""Deploy acmetool."""
|
|
||||||
apt.packages(
|
|
||||||
name="Install acmetool",
|
|
||||||
packages=["acmetool"],
|
|
||||||
)
|
|
||||||
|
|
||||||
files.put(
|
class AcmetoolDeployer(Deployer):
|
||||||
src=importlib.resources.files(__package__).joinpath("acmetool.cron").open("rb"),
|
def __init__(self, *, email, domains, **kwargs):
|
||||||
dest="/etc/cron.d/acmetool",
|
super().__init__(**kwargs)
|
||||||
user="root",
|
self.domains = domains
|
||||||
group="root",
|
self.email = email
|
||||||
mode="644",
|
self.need_restart = False
|
||||||
)
|
|
||||||
|
|
||||||
files.put(
|
@staticmethod
|
||||||
src=importlib.resources.files(__package__).joinpath("acmetool.hook").open("rb"),
|
def install_impl():
|
||||||
dest="/usr/lib/acme/hooks/nginx",
|
apt.packages(
|
||||||
user="root",
|
name="Install acmetool",
|
||||||
group="root",
|
packages=["acmetool"],
|
||||||
mode="744",
|
)
|
||||||
)
|
|
||||||
|
|
||||||
files.template(
|
def configure_impl(self):
|
||||||
src=importlib.resources.files(__package__).joinpath("response-file.yaml.j2"),
|
files.put(
|
||||||
dest="/var/lib/acme/conf/responses",
|
src=importlib.resources.files(__package__).joinpath("acmetool.cron").open("rb"),
|
||||||
user="root",
|
dest="/etc/cron.d/acmetool",
|
||||||
group="root",
|
user="root",
|
||||||
mode="644",
|
group="root",
|
||||||
email=email,
|
mode="644",
|
||||||
)
|
)
|
||||||
|
|
||||||
files.template(
|
files.put(
|
||||||
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
|
src=importlib.resources.files(__package__).joinpath("acmetool.hook").open("rb"),
|
||||||
dest="/var/lib/acme/conf/target",
|
dest="/usr/lib/acme/hooks/nginx",
|
||||||
user="root",
|
user="root",
|
||||||
group="root",
|
group="root",
|
||||||
mode="644",
|
mode="744",
|
||||||
)
|
)
|
||||||
|
|
||||||
service_file = files.put(
|
files.template(
|
||||||
src=importlib.resources.files(__package__).joinpath(
|
src=importlib.resources.files(__package__).joinpath("response-file.yaml.j2"),
|
||||||
"acmetool-redirector.service"
|
dest="/var/lib/acme/conf/responses",
|
||||||
),
|
user="root",
|
||||||
dest="/etc/systemd/system/acmetool-redirector.service",
|
group="root",
|
||||||
user="root",
|
mode="644",
|
||||||
group="root",
|
email=self.email,
|
||||||
mode="644",
|
)
|
||||||
)
|
|
||||||
|
|
||||||
systemd.service(
|
files.template(
|
||||||
name="Setup acmetool-redirector service",
|
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
|
||||||
service="acmetool-redirector.service",
|
dest="/var/lib/acme/conf/target",
|
||||||
running=True,
|
user="root",
|
||||||
enabled=True,
|
group="root",
|
||||||
restarted=service_file.changed,
|
mode="644",
|
||||||
)
|
)
|
||||||
|
|
||||||
server.shell(
|
service_file = files.put(
|
||||||
name=f"Request certificate for: {', '.join(domains)}",
|
src=importlib.resources.files(__package__).joinpath(
|
||||||
commands=[f"acmetool want --xlog.severity=debug {' '.join(domains)}"],
|
"acmetool-redirector.service"
|
||||||
)
|
),
|
||||||
|
dest="/etc/systemd/system/acmetool-redirector.service",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="644",
|
||||||
|
)
|
||||||
|
self.need_restart = service_file.changed
|
||||||
|
|
||||||
|
def activate_impl(self):
|
||||||
|
systemd.service(
|
||||||
|
name="Setup acmetool-redirector service",
|
||||||
|
service="acmetool-redirector.service",
|
||||||
|
running=True,
|
||||||
|
enabled=True,
|
||||||
|
restarted=self.need_restart,
|
||||||
|
)
|
||||||
|
self.need_restart = False
|
||||||
|
|
||||||
|
server.shell(
|
||||||
|
name=f"Request certificate for: {', '.join(self.domains)}",
|
||||||
|
commands=[f"acmetool want --xlog.severity=debug {' '.join(self.domains)}"],
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
request:
|
request:
|
||||||
provider: https://acme-v02.api.letsencrypt.org/directory
|
provider: https://acme-v02.api.letsencrypt.org/directory
|
||||||
key:
|
key:
|
||||||
type: rsa
|
type: ecdsa
|
||||||
|
ecdsa-curve: nistp256
|
||||||
challenge:
|
challenge:
|
||||||
webroot-paths:
|
webroot-paths:
|
||||||
- /var/www/html/.well-known/acme-challenge
|
- /var/www/html/.well-known/acme-challenge
|
||||||
|
|||||||
59
cmdeploy/src/cmdeploy/deployer.py
Normal file
59
cmdeploy/src/cmdeploy/deployer.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from pyinfra.operations import server
|
||||||
|
|
||||||
|
|
||||||
|
class Deployment:
|
||||||
|
def install(self, deployer):
|
||||||
|
# optional 'required_users' contains a list of (user, group, secondary-group-list) tuples.
|
||||||
|
# If the group is None, no group is created corresponding to that user.
|
||||||
|
# If the secondary group list is not None, all listed groups are created as well.
|
||||||
|
required_users = getattr(deployer, "required_users", [])
|
||||||
|
for user, group, groups in required_users:
|
||||||
|
if group is not None:
|
||||||
|
server.group(
|
||||||
|
name="Create {} group".format(group), group=group, system=True
|
||||||
|
)
|
||||||
|
if groups is not None:
|
||||||
|
for group2 in groups:
|
||||||
|
server.group(
|
||||||
|
name="Create {} group".format(group2), group=group2, system=True
|
||||||
|
)
|
||||||
|
server.user(
|
||||||
|
name="Create {} user".format(user),
|
||||||
|
user=user,
|
||||||
|
group=group,
|
||||||
|
groups=groups,
|
||||||
|
system=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
ret = bool(deployer.install())
|
||||||
|
if ret:
|
||||||
|
deployer.need_restart = True
|
||||||
|
|
||||||
|
def configure(self, deployer):
|
||||||
|
deployer.configure()
|
||||||
|
|
||||||
|
def activate(self, deployer):
|
||||||
|
deployer.activate()
|
||||||
|
|
||||||
|
def perform_stages(self, deployers):
|
||||||
|
default_stages = "install,configure,activate"
|
||||||
|
stages = os.getenv("CMDEPLOY_STAGES", default_stages).split(",")
|
||||||
|
|
||||||
|
for stage in stages:
|
||||||
|
for deployer in deployers:
|
||||||
|
getattr(self, stage)(deployer)
|
||||||
|
|
||||||
|
|
||||||
|
class Deployer:
|
||||||
|
need_restart = False
|
||||||
|
|
||||||
|
def install(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def configure(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def activate(self):
|
||||||
|
pass
|
||||||
@@ -70,6 +70,12 @@ userdb {
|
|||||||
# Mailboxes are stored in the "mail" directory of the vmail user home.
|
# Mailboxes are stored in the "mail" directory of the vmail user home.
|
||||||
mail_location = maildir:{{ config.mailboxes_dir }}/%u
|
mail_location = maildir:{{ config.mailboxes_dir }}/%u
|
||||||
|
|
||||||
|
# index/cache files are not very useful for chatmail relay operations
|
||||||
|
# but it's not clear how to disable them completely.
|
||||||
|
# According to https://doc.dovecot.org/2.3/settings/advanced/#core_setting-mail_cache_max_size
|
||||||
|
# if the cache file becomes larger than the specified size, it is truncated by dovecot
|
||||||
|
mail_cache_max_size = 500K
|
||||||
|
|
||||||
namespace inbox {
|
namespace inbox {
|
||||||
inbox = yes
|
inbox = yes
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
# delete already seen big mails after 7 days, in the INBOX
|
|
||||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_large_after }} -size +200k -type f -delete
|
|
||||||
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox
|
|
||||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
|
||||||
# or in any IMAP subfolder
|
|
||||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
|
||||||
# even if they are unseen
|
|
||||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
|
||||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
|
||||||
# or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway).
|
|
||||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
|
||||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
|
||||||
3 0 * * * vmail find {{ config.mailboxes_dir }} -name 'maildirsize' -type f -delete
|
|
||||||
4 0 * * * vmail /usr/local/lib/chatmaild/venv/bin/delete_inactive_users /usr/local/lib/chatmaild/chatmail.ini
|
|
||||||
Binary file not shown.
3
cmdeploy/src/cmdeploy/policy-rc.d
Executable file
3
cmdeploy/src/cmdeploy/policy-rc.d
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
echo "All runlevel operations denied by policy" >&2
|
||||||
|
exit 101
|
||||||
@@ -26,6 +26,7 @@ smtp_tls_security_level=verify
|
|||||||
smtp_tls_servername = hostname
|
smtp_tls_servername = hostname
|
||||||
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
|
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
|
||||||
smtp_tls_policy_maps = inline:{nauta.cu=may}
|
smtp_tls_policy_maps = inline:{nauta.cu=may}
|
||||||
|
smtp_tls_protocols = >=TLSv1.2
|
||||||
smtpd_tls_protocols = >=TLSv1.2
|
smtpd_tls_protocols = >=TLSv1.2
|
||||||
|
|
||||||
# Disable anonymous cipher suites
|
# Disable anonymous cipher suites
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ smtp inet n - y - - smtpd -v
|
|||||||
{%- else %}
|
{%- else %}
|
||||||
smtp inet n - y - - smtpd
|
smtp inet n - y - - smtpd
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
-o smtpd_tls_security_level=encrypt
|
||||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
|
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
|
||||||
submission inet n - y - 5000 smtpd
|
submission inet n - y - 5000 smtpd
|
||||||
-o syslog_name=postfix/submission
|
-o syslog_name=postfix/submission
|
||||||
|
|||||||
@@ -73,9 +73,7 @@ def query_dns(typ, domain):
|
|||||||
|
|
||||||
# Query authoritative nameserver directly to bypass DNS cache.
|
# Query authoritative nameserver directly to bypass DNS cache.
|
||||||
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress)
|
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress)
|
||||||
if res:
|
return next((line for line in res.split("\n") if not line.startswith(';')), '')
|
||||||
return res.split("\n")[0]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def check_zonefile(zonefile, verbose=True):
|
def check_zonefile(zonefile, verbose=True):
|
||||||
|
|||||||
9
cmdeploy/src/cmdeploy/service/chatmail-expire.service.f
Normal file
9
cmdeploy/src/cmdeploy/service/chatmail-expire.service.f
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=chatmail mail storage expiration job
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=vmail
|
||||||
|
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-expire /usr/local/lib/chatmaild/chatmail.ini -v --remove
|
||||||
|
|
||||||
8
cmdeploy/src/cmdeploy/service/chatmail-expire.timer.f
Normal file
8
cmdeploy/src/cmdeploy/service/chatmail-expire.timer.f
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run Daily chatmail-expire job
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 00:02:00
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=chatmail file system storage reporting job
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=vmail
|
||||||
|
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini
|
||||||
|
|
||||||
9
cmdeploy/src/cmdeploy/service/chatmail-fsreport.timer.f
Normal file
9
cmdeploy/src/cmdeploy/service/chatmail-fsreport.timer.f
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run Daily Chatmail fsreport Job
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 08:02:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -37,7 +37,7 @@ class TestDC:
|
|||||||
|
|
||||||
def test_ping_pong(self, benchmark, cmfactory):
|
def test_ping_pong(self, benchmark, cmfactory):
|
||||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||||
chat = cmfactory.get_protected_chat(ac1, ac2)
|
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||||
|
|
||||||
def dc_ping_pong():
|
def dc_ping_pong():
|
||||||
chat.send_text("ping")
|
chat.send_text("ping")
|
||||||
@@ -49,7 +49,7 @@ class TestDC:
|
|||||||
|
|
||||||
def test_send_10_receive_10(self, benchmark, cmfactory, lp):
|
def test_send_10_receive_10(self, benchmark, cmfactory, lp):
|
||||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||||
chat = cmfactory.get_protected_chat(ac1, ac2)
|
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||||
|
|
||||||
def dc_send_10_receive_10():
|
def dc_send_10_receive_10():
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import queue
|
import queue
|
||||||
import socket
|
import smtplib
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -91,25 +91,23 @@ def test_concurrent_logins_same_account(
|
|||||||
|
|
||||||
def test_no_vrfy(chatmail_config):
|
def test_no_vrfy(chatmail_config):
|
||||||
domain = chatmail_config.mail_domain
|
domain = chatmail_config.mail_domain
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
sock.settimeout(10)
|
s = smtplib.SMTP(domain)
|
||||||
try:
|
s.starttls()
|
||||||
sock.connect((domain, 25))
|
|
||||||
except socket.timeout:
|
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
|
||||||
pytest.skip(f"port 25 not reachable for {domain}")
|
result = s.getreply()
|
||||||
banner = sock.recv(1024)
|
|
||||||
print(banner)
|
|
||||||
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
|
||||||
result = sock.recv(1024)
|
|
||||||
print(result)
|
print(result)
|
||||||
sock.send(b"VRFY echo@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
s.putcmd("vrfy", f"echo@{chatmail_config.mail_domain}")
|
||||||
result2 = sock.recv(1024)
|
result2 = s.getreply()
|
||||||
print(result2)
|
print(result2)
|
||||||
assert result[0:10] == result2[0:10]
|
assert result[0] == result2[0] == 252
|
||||||
sock.send(b"VRFY wrongaddress\r\n")
|
assert result[1][0:6] == result2[1][0:6] == b"2.0.0 "
|
||||||
result = sock.recv(1024)
|
s.putcmd("vrfy", "wrongaddress")
|
||||||
|
result = s.getreply()
|
||||||
print(result)
|
print(result)
|
||||||
sock.send(b"VRFY echo\r\n")
|
s.putcmd("vrfy", "echo")
|
||||||
result2 = sock.recv(1024)
|
result2 = s.getreply()
|
||||||
print(result2)
|
print(result2)
|
||||||
assert result[0:10] == result2[0:10] == b"252 2.0.0 "
|
assert result[0] == result2[0] == 252
|
||||||
|
assert result[1][0:6] == result2[1][0:6] == b"2.0.0 "
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
|||||||
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr
|
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr
|
||||||
).as_string()
|
).as_string()
|
||||||
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
|
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
|
||||||
|
conn.starttls()
|
||||||
|
|
||||||
with conn as s:
|
with conn as s:
|
||||||
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
|
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TestEndToEndDeltaChat:
|
|||||||
"""Test that a DC account can send a message to a second DC account
|
"""Test that a DC account can send a message to a second DC account
|
||||||
on the same chat-mail instance."""
|
on the same chat-mail instance."""
|
||||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||||
chat = cmfactory.get_protected_chat(ac1, ac2)
|
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||||
chat.send_text("message0")
|
chat.send_text("message0")
|
||||||
|
|
||||||
lp.sec("wait for ac2 to receive message")
|
lp.sec("wait for ac2 to receive message")
|
||||||
@@ -70,7 +70,7 @@ class TestEndToEndDeltaChat:
|
|||||||
before quota is exceeded, and thus depends on the speed of the upload.
|
before quota is exceeded, and thus depends on the speed of the upload.
|
||||||
"""
|
"""
|
||||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||||
chat = cmfactory.get_protected_chat(ac1, ac2)
|
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||||
|
|
||||||
user = ac2.get_config("configured_addr")
|
user = ac2.get_config("configured_addr")
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ def test_hide_senders_ip_address(cmfactory):
|
|||||||
assert ipaddress.ip_address(public_ip)
|
assert ipaddress.ip_address(public_ip)
|
||||||
|
|
||||||
user1, user2 = cmfactory.get_online_accounts(2)
|
user1, user2 = cmfactory.get_online_accounts(2)
|
||||||
chat = cmfactory.get_protected_chat(user1, user2)
|
chat = cmfactory.get_accepted_chat(user1, user2)
|
||||||
|
|
||||||
chat.send_text("testing submission header cleanup")
|
chat.send_text("testing submission header cleanup")
|
||||||
user2._evtracker.wait_next_incoming_message()
|
user2._evtracker.wait_next_incoming_message()
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cmdeploy import remote
|
from cmdeploy import remote
|
||||||
@@ -8,38 +10,63 @@ from cmdeploy.dns import check_full_zone, check_initial_remote_data
|
|||||||
def mockdns_base(monkeypatch):
|
def mockdns_base(monkeypatch):
|
||||||
qdict = {}
|
qdict = {}
|
||||||
|
|
||||||
def query_dns(typ, domain):
|
def shell(command, fail_ok=False, print=print):
|
||||||
try:
|
if command.startswith("dig"):
|
||||||
return qdict[typ][domain]
|
if command == "dig":
|
||||||
except KeyError:
|
return "."
|
||||||
return ""
|
if "SOA" in command:
|
||||||
|
return (
|
||||||
|
"delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com."
|
||||||
|
" 2025102800 14400 1800 604800 3600"
|
||||||
|
)
|
||||||
|
command_chunks = command.split()
|
||||||
|
domain, typ = command_chunks[4], command_chunks[6]
|
||||||
|
try:
|
||||||
|
return qdict[typ][domain]
|
||||||
|
except KeyError:
|
||||||
|
return ""
|
||||||
|
return remote.rshell.shell(command=command, fail_ok=fail_ok, print=print)
|
||||||
|
|
||||||
monkeypatch.setattr(remote.rdns, query_dns.__name__, query_dns)
|
monkeypatch.setattr(remote.rdns, shell.__name__, shell)
|
||||||
return qdict
|
return qdict
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mockdns(mockdns_base):
|
def mockdns_expected():
|
||||||
mockdns_base.update(
|
return {
|
||||||
{
|
"A": {"some.domain": "1.1.1.1"},
|
||||||
"A": {"some.domain": "1.1.1.1"},
|
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
|
||||||
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
|
"CNAME": {
|
||||||
"CNAME": {
|
"mta-sts.some.domain": "some.domain.",
|
||||||
"mta-sts.some.domain": "some.domain.",
|
"www.some.domain": "some.domain.",
|
||||||
"www.some.domain": "some.domain.",
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
|
@pytest.fixture(params=["plain", "with-dns-comments"])
|
||||||
|
def mockdns(request, mockdns_base, mockdns_expected):
|
||||||
|
mockdns_base.update(deepcopy(mockdns_expected))
|
||||||
|
match request.param:
|
||||||
|
case "plain":
|
||||||
|
pass
|
||||||
|
case "with-dns-comments":
|
||||||
|
for typ, data in mockdns_base.items():
|
||||||
|
for host, result in data.items():
|
||||||
|
mockdns_base[typ][host] = (
|
||||||
|
";; some unsuccessful attempt result\n"
|
||||||
|
"; and another with a single semicolon\n"
|
||||||
|
f"{result}"
|
||||||
|
)
|
||||||
return mockdns_base
|
return mockdns_base
|
||||||
|
|
||||||
|
|
||||||
class TestPerformInitialChecks:
|
class TestPerformInitialChecks:
|
||||||
def test_perform_initial_checks_ok1(self, mockdns):
|
def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected):
|
||||||
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
||||||
assert remote_data["A"] == mockdns["A"]["some.domain"]
|
assert remote_data["A"] == mockdns_expected["A"]["some.domain"]
|
||||||
assert remote_data["AAAA"] == mockdns["AAAA"]["some.domain"]
|
assert remote_data["AAAA"] == mockdns_expected["AAAA"]["some.domain"]
|
||||||
assert remote_data["MTA_STS"] == mockdns["CNAME"]["mta-sts.some.domain"]
|
assert remote_data["MTA_STS"] == mockdns_expected["CNAME"]["mta-sts.some.domain"]
|
||||||
assert remote_data["WWW"] == mockdns["CNAME"]["www.some.domain"]
|
assert remote_data["WWW"] == mockdns_expected["CNAME"]["www.some.domain"]
|
||||||
|
|
||||||
@pytest.mark.parametrize("drop", ["A", "AAAA"])
|
@pytest.mark.parametrize("drop", ["A", "AAAA"])
|
||||||
def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop):
|
def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop):
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import time
|
|||||||
import traceback
|
import traceback
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
import markdown
|
import markdown
|
||||||
from chatmaild.config import read_config
|
from chatmaild.config import read_config
|
||||||
@@ -12,6 +13,9 @@ from jinja2 import Template
|
|||||||
from .genqr import gen_qr_png_data
|
from .genqr import gen_qr_png_data
|
||||||
|
|
||||||
|
|
||||||
|
_MERGE_CONFLICT_RE = re.compile(r"^<<<<<<<.+^=======.+^>>>>>>>", re.DOTALL | re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
def snapshot_dir_stats(somedir):
|
def snapshot_dir_stats(somedir):
|
||||||
d = {}
|
d = {}
|
||||||
for path in somedir.iterdir():
|
for path in somedir.iterdir():
|
||||||
@@ -116,6 +120,17 @@ def _build_webpages(src_dir, build_dir, config):
|
|||||||
return build_dir
|
return build_dir
|
||||||
|
|
||||||
|
|
||||||
|
def find_merge_conflict(src_dir) -> Path:
|
||||||
|
assert src_dir.exists(), src_dir
|
||||||
|
result = None
|
||||||
|
for path in src_dir.iterdir():
|
||||||
|
if path.suffix in [".css", ".html", ".md"]:
|
||||||
|
if _MERGE_CONFLICT_RE.search(path.read_text()):
|
||||||
|
result = path
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
path = importlib.resources.files(__package__)
|
path = importlib.resources.files(__package__)
|
||||||
reporoot = path.joinpath("../../../").resolve()
|
reporoot = path.joinpath("../../../").resolve()
|
||||||
|
|||||||
Reference in New Issue
Block a user