mirror of
https://github.com/chatmail/relay.git
synced 2026-05-10 16:04:37 +00:00
Compare commits
12 Commits
link2xt/po
...
link2xt/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2f4394318 | ||
|
|
7573ef928f | ||
|
|
46297d4839 | ||
|
|
5515607b63 | ||
|
|
d0ed8830f7 | ||
|
|
a6bdbb748b | ||
|
|
ba811c2e1c | ||
|
|
3ef45c2ffd | ||
|
|
8d72d770a3 | ||
|
|
e32d81520a | ||
|
|
e973bc1f41 | ||
|
|
cdfce25494 |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -2,15 +2,40 @@
|
||||
|
||||
## untagged
|
||||
|
||||
- add mtail support (new optional `mail_address` ini value)
|
||||
This defines the address on which [`mtail`](https://google.github.io/mtail/)
|
||||
exposes its metrics collected from the logs.
|
||||
If you want to collect the metrics with Prometheus,
|
||||
setup a private network (e.g. WireGuard interface)
|
||||
and assign an IP address from this network to the host.
|
||||
If you do not plan to collect metrics,
|
||||
keep this setting unset.
|
||||
([#388](https://github.com/deltachat/chatmail/pull/388))
|
||||
|
||||
- fix checking for required DNS records
|
||||
([#412](https://github.com/deltachat/chatmail/pull/412))
|
||||
|
||||
- add a paragraph about "account deletion" to info page
|
||||
([#405](https://github.com/deltachat/chatmail/pull/405))
|
||||
|
||||
- avoid nginx listening on ipv6 if v6 is dsiabled
|
||||
([#402](https://github.com/deltachat/chatmail/pull/402))
|
||||
|
||||
- refactor ssh-based execution to allow organizing remote functions in
|
||||
modules.
|
||||
([#396](https://github.com/deltachat/chatmail/pull/396))
|
||||
|
||||
- trigger "apt upgrade" during "cmdeploy run"
|
||||
([#398](https://github.com/deltachat/chatmail/pull/398))
|
||||
|
||||
- drop hispanilandia passthrough address
|
||||
([#401](https://github.com/deltachat/chatmail/pull/401))
|
||||
|
||||
- set CAA record flags to 0
|
||||
|
||||
- add IMAP capabilities instead of overwriting them
|
||||
([#413](https://github.com/deltachat/chatmail/pull/413))
|
||||
|
||||
|
||||
## 1.4.1 2024-07-31
|
||||
|
||||
|
||||
127
README.md
127
README.md
@@ -34,8 +34,8 @@ Please substitute it with your own domain.
|
||||
scripts/cmdeploy init chat.example.org # <-- use your domain
|
||||
```
|
||||
|
||||
3. Setup first DNS records for your chatmail domain,
|
||||
according to the hints provided by `cmdeploy init`.
|
||||
3. Point your domain to the server's IP address,
|
||||
if you haven't done so already.
|
||||
Verify that SSH root login works:
|
||||
|
||||
```
|
||||
@@ -47,7 +47,8 @@ Please substitute it with your own domain.
|
||||
```
|
||||
scripts/cmdeploy run
|
||||
```
|
||||
This script will also show you additional DNS records
|
||||
This script will check that you have all necessary DNS records.
|
||||
If DNS records are missing, it will recommend
|
||||
which you should configure at your DNS provider
|
||||
(it can take some time until they are public).
|
||||
|
||||
@@ -59,7 +60,7 @@ To check the status of your remotely running chatmail service:
|
||||
scripts/cmdeploy status
|
||||
```
|
||||
|
||||
To check whether your DNS records are correct:
|
||||
To display and check all recommended DNS records:
|
||||
|
||||
```
|
||||
scripts/cmdeploy dns
|
||||
@@ -186,3 +187,121 @@ to MAIL FROM with
|
||||
and rejects incorrectly authenticated emails with [`reject_sender_login_mismatch`](reject_sender_login_mismatch) policy.
|
||||
`From:` header must correspond to envelope MAIL FROM,
|
||||
this is ensured by `filtermail` proxy.
|
||||
|
||||
## Setting up a reverse proxy
|
||||
|
||||
A chatmail server does not depend on the client IP address
|
||||
for its operation, so it can be run behind a reverse proxy.
|
||||
This will not even affect incoming mail authentication
|
||||
as DKIM only checks the cryptographic signature
|
||||
of the message and does not use the IP address as the input.
|
||||
|
||||
For example, you may want to self-host your chatmail server
|
||||
and only use hosted VPS to provide a public IP address
|
||||
for client connections and incoming mail.
|
||||
You can connect chatmail server to VPS
|
||||
using a tunnel protocol
|
||||
such as [WireGuard](https://www.wireguard.com/)
|
||||
and setup a reverse proxy on a VPS
|
||||
to forward connections to the chatmail server
|
||||
over the tunnel.
|
||||
You can also setup multiple reverse proxies
|
||||
for your chatmail server in different networks
|
||||
to ensure your server is reachable even when
|
||||
one of the IPs becomes inaccessible due to
|
||||
hosting or routing problems.
|
||||
|
||||
Note that your server still needs
|
||||
to be able to make outgoing connections on port 25
|
||||
to send messages outside.
|
||||
|
||||
To setup a reverse proxy
|
||||
(or rather Destination NAT, DNAT)
|
||||
for your chatmail server,
|
||||
put the following configuration in `/etc/nftables.conf`:
|
||||
```
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
|
||||
define wan = eth0
|
||||
|
||||
# Which ports to proxy.
|
||||
#
|
||||
# Note that SSH is not proxied
|
||||
# so it is possible to log into the proxy server
|
||||
# and not the original one.
|
||||
define ports = { smtp, http, https, imap, imaps, submission, submissions }
|
||||
|
||||
# The host we want to proxy to.
|
||||
define ipv4_address = AAA.BBB.CCC.DDD
|
||||
define ipv6_address = [XXX::1]
|
||||
|
||||
table ip nat {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iif $wan tcp dport $ports dnat to $ipv4_address
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority 0;
|
||||
|
||||
oifname $wan masquerade
|
||||
}
|
||||
}
|
||||
|
||||
table ip6 nat {
|
||||
chain prerouting {
|
||||
type nat hook prerouting priority dstnat; policy accept;
|
||||
iif $wan tcp dport $ports dnat to $ipv6_address
|
||||
}
|
||||
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority 0;
|
||||
|
||||
oifname $wan masquerade
|
||||
}
|
||||
}
|
||||
|
||||
table inet filter {
|
||||
chain input {
|
||||
type filter hook input priority filter; policy drop;
|
||||
|
||||
# Accept ICMP.
|
||||
# It is especially important to accept ICMPv6 ND messages,
|
||||
# otherwise IPv6 connectivity breaks.
|
||||
icmp type { echo-request } accept
|
||||
icmpv6 type { echo-request, nd-neighbor-solicit, nd-router-advert, nd-neighbor-advert } accept
|
||||
|
||||
# Allow incoming SSH connections.
|
||||
tcp dport { ssh } accept
|
||||
|
||||
ct state established accept
|
||||
}
|
||||
chain forward {
|
||||
type filter hook forward priority filter; policy drop;
|
||||
|
||||
ct state established accept
|
||||
ip daddr $ipv4_address counter accept
|
||||
ip6 daddr $ipv6_address counter accept
|
||||
}
|
||||
chain output {
|
||||
type filter hook output priority filter;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run `systemctl enable nftables.service`
|
||||
to ensure configuration is reloaded when the proxy server reboots.
|
||||
|
||||
Uncomment in `/etc/sysctl.conf` the following two lines:
|
||||
|
||||
```
|
||||
net.ipv4.ip_forward=1
|
||||
net.ipv6.conf.all.forwarding=1
|
||||
```
|
||||
|
||||
Then reboot the server or do `sysctl -p` and `nft -f /etc/nftables.conf`.
|
||||
|
||||
Once proxy server is set up,
|
||||
you can add its IP address to the DNS.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ class Config:
|
||||
self.passthrough_recipients = params["passthrough_recipients"].split()
|
||||
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
|
||||
self.postfix_reinject_port = int(params["postfix_reinject_port"])
|
||||
self.mtail_address = params.get("mtail_address")
|
||||
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
|
||||
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
|
||||
self.iroh_relay = params.get("iroh_relay")
|
||||
|
||||
@@ -183,15 +183,29 @@ class BeforeQueueHandler:
|
||||
mail_encrypted = check_encrypted(message)
|
||||
|
||||
_, from_addr = parseaddr(message.get("from").strip())
|
||||
envelope_from_domain = from_addr.split("@").pop()
|
||||
|
||||
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
|
||||
if envelope.mail_from.lower() != from_addr.lower():
|
||||
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
|
||||
|
||||
if mail_encrypted:
|
||||
print("Filtering encrypted mail.", file=sys.stderr)
|
||||
else:
|
||||
print("Filtering unencrypted mail.", file=sys.stderr)
|
||||
|
||||
if envelope.mail_from in self.config.passthrough_senders:
|
||||
return
|
||||
|
||||
passthrough_recipients = self.config.passthrough_recipients
|
||||
envelope_from_domain = from_addr.split("@").pop()
|
||||
|
||||
is_securejoin = message.get("secure-join") in [
|
||||
"vc-request",
|
||||
"vg-request",
|
||||
]
|
||||
if is_securejoin:
|
||||
return
|
||||
|
||||
for recipient in envelope.rcpt_tos:
|
||||
if envelope.mail_from == recipient:
|
||||
# Always allow sending emails to self.
|
||||
@@ -205,12 +219,8 @@ class BeforeQueueHandler:
|
||||
|
||||
is_outgoing = recipient_domain != envelope_from_domain
|
||||
if is_outgoing and not mail_encrypted:
|
||||
is_securejoin = message.get("secure-join") in [
|
||||
"vc-request",
|
||||
"vg-request",
|
||||
]
|
||||
if not is_securejoin:
|
||||
return f"500 Invalid unencrypted mail to <{recipient}>"
|
||||
print("Rejected unencrypted mail.", file=sys.stderr)
|
||||
return f"500 Invalid unencrypted mail to <{recipient}>"
|
||||
|
||||
|
||||
class SendRateLimiter:
|
||||
|
||||
@@ -23,8 +23,8 @@ max_message_size = 31457280
|
||||
# days after which mails are unconditionally deleted
|
||||
delete_mails_after = 20
|
||||
|
||||
# days after which users without a login are deleted (database and mails)
|
||||
delete_inactive_users_after = 100
|
||||
# days after which users without a successful login are deleted (database and mails)
|
||||
delete_inactive_users_after = 90
|
||||
|
||||
# minimum length a username must have
|
||||
username_min_length = 9
|
||||
@@ -55,6 +55,22 @@ postfix_reinject_port = 10025
|
||||
# if set to "True" IPv6 is disabled
|
||||
disable_ipv6 = False
|
||||
|
||||
# Address on which `mtail` listens,
|
||||
# e.g. 127.0.0.1 or some private network
|
||||
# address like 192.168.10.1.
|
||||
# You can point Prometheus
|
||||
# or some other OpenMetrics-compatible
|
||||
# collector to
|
||||
# http://{{mtail_address}}:3903/metrics
|
||||
# and display collected metrics with Grafana.
|
||||
#
|
||||
# WARNING: do not expose this service
|
||||
# to the public IP address.
|
||||
#
|
||||
# `mtail is not running if the setting is not set.
|
||||
|
||||
# mtail_address = 127.0.0.1
|
||||
|
||||
#
|
||||
# Debugging options
|
||||
#
|
||||
|
||||
@@ -441,6 +441,44 @@ def check_config(config):
|
||||
return config
|
||||
|
||||
|
||||
def deploy_mtail(config):
|
||||
apt.packages(
|
||||
name="Install mtail",
|
||||
packages=["mtail"],
|
||||
)
|
||||
|
||||
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
|
||||
# This allows to read from journalctl instead of log files.
|
||||
files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("mtail/mtail.service.j2"),
|
||||
dest="/etc/systemd/system/mtail.service",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
address=config.mtail_address or "127.0.0.1",
|
||||
port=3903,
|
||||
)
|
||||
|
||||
mtail_conf = files.put(
|
||||
name="Mtail configuration",
|
||||
src=importlib.resources.files(__package__).joinpath(
|
||||
"mtail/delivered_mail.mtail"
|
||||
),
|
||||
dest="/etc/mtail/delivered_mail.mtail",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable mtail",
|
||||
service="mtail.service",
|
||||
running=bool(config.mtail_address),
|
||||
enabled=bool(config.mtail_address),
|
||||
restarted=mtail_conf.changed,
|
||||
)
|
||||
|
||||
|
||||
def deploy_chatmail(config_path: Path) -> None:
|
||||
"""Deploy a chat-mail instance.
|
||||
|
||||
@@ -636,3 +674,5 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
name="Ensure cron is installed",
|
||||
packages=["cron"],
|
||||
)
|
||||
|
||||
deploy_mtail(config)
|
||||
|
||||
@@ -20,7 +20,7 @@ www.{{ mail_domain }}. CNAME {{ mail_domain }}.
|
||||
_dmarc.{{ mail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
||||
|
||||
{% if acme_account_url %}
|
||||
{{ mail_domain }}. CAA 128 issue "letsencrypt.org;accounturi={{ acme_account_url }}"
|
||||
{{ mail_domain }}. CAA 0 issue "letsencrypt.org;accounturi={{ acme_account_url }}"
|
||||
{% endif %}
|
||||
_adsp._domainkey.{{ mail_domain }}. TXT "dkim=discardable"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from chatmaild.config import read_config, write_initial_config
|
||||
from packaging import version
|
||||
from termcolor import colored
|
||||
|
||||
from . import dns, remote_funcs
|
||||
from . import dns, remote
|
||||
from .sshexec import SSHExec
|
||||
|
||||
#
|
||||
@@ -74,7 +74,7 @@ def run_cmd(args, out):
|
||||
retcode = out.check_call(cmd, env=env)
|
||||
if retcode == 0:
|
||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||
elif not remote_data["acme_account_url"]:
|
||||
elif not remote_data.get("acme_account_url"):
|
||||
out.red("Deploy completed but letsencrypt not configured")
|
||||
out.red("Run 'cmdeploy run' again")
|
||||
retcode = 0
|
||||
@@ -100,7 +100,7 @@ def dns_cmd(args, out):
|
||||
if not remote_data:
|
||||
return 1
|
||||
|
||||
if not remote_data["acme_account_url"]:
|
||||
if not remote_data.get("acme_account_url"):
|
||||
out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
|
||||
return 1
|
||||
|
||||
@@ -132,7 +132,7 @@ def status_cmd(args, out):
|
||||
else:
|
||||
out.red("no privacy settings")
|
||||
|
||||
for line in sshexec(remote_funcs.get_systemd_running):
|
||||
for line in sshexec(remote.rshell.get_systemd_running):
|
||||
print(line)
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ def main(args=None):
|
||||
|
||||
def get_sshexec():
|
||||
print(f"[ssh] login to {args.config.mail_domain}")
|
||||
return SSHExec(args.config.mail_domain, remote_funcs, verbose=args.verbose)
|
||||
return SSHExec(args.config.mail_domain, verbose=args.verbose)
|
||||
|
||||
args.get_sshexec = get_sshexec
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import importlib
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from . import remote_funcs
|
||||
from . import remote
|
||||
|
||||
|
||||
def get_initial_remote_data(sshexec, mail_domain):
|
||||
return sshexec.logged(
|
||||
call=remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
||||
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
||||
)
|
||||
|
||||
|
||||
@@ -16,9 +16,12 @@ def check_initial_remote_data(remote_data, print=print):
|
||||
mail_domain = remote_data["mail_domain"]
|
||||
if not remote_data["A"] and not remote_data["AAAA"]:
|
||||
print(f"Missing A and/or AAAA DNS records for {mail_domain}!")
|
||||
elif not remote_data["MTA_STS"]:
|
||||
elif remote_data["MTA_STS"] != f"{mail_domain}.":
|
||||
print("Missing MTA-STS CNAME record:")
|
||||
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}")
|
||||
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.")
|
||||
elif remote_data["WWW"] != f"{mail_domain}.":
|
||||
print("Missing www CNAME record:")
|
||||
print(f"www.{mail_domain}. CNAME {mail_domain}.")
|
||||
else:
|
||||
return remote_data
|
||||
|
||||
@@ -42,7 +45,8 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
|
||||
and return (exitcode, remote_data) tuple."""
|
||||
|
||||
required_diff, recommended_diff = sshexec.logged(
|
||||
remote_funcs.check_zonefile, kwargs=dict(zonefile=zonefile)
|
||||
remote.rdns.check_zonefile,
|
||||
kwargs=dict(zonefile=zonefile, mail_domain=remote_data["mail_domain"]),
|
||||
)
|
||||
|
||||
if required_diff:
|
||||
|
||||
@@ -51,10 +51,7 @@ mail_server_comment = Chatmail server
|
||||
# <https://doc.dovecot.org/configuration_manual/quota_plugin/>
|
||||
mail_plugins = zlib quota
|
||||
|
||||
# these are the capabilities Delta Chat cares about actually
|
||||
# so let's keep the network overhead per login small
|
||||
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
|
||||
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA XDELTAPUSH XCHATMAIL
|
||||
imap_capability = +XDELTAPUSH XCHATMAIL
|
||||
|
||||
|
||||
# Authentication for system users.
|
||||
|
||||
64
cmdeploy/src/cmdeploy/mtail/delivered_mail.mtail
Normal file
64
cmdeploy/src/cmdeploy/mtail/delivered_mail.mtail
Normal file
@@ -0,0 +1,64 @@
|
||||
counter delivered_mail
|
||||
/saved mail to INBOX$/ {
|
||||
delivered_mail++
|
||||
}
|
||||
|
||||
counter quota_exceeded
|
||||
/Quota exceeded \(mailbox for user is full\)$/ {
|
||||
quota_exceeded++
|
||||
}
|
||||
|
||||
# Essentially the number of outgoing messages.
|
||||
counter dkim_signed
|
||||
/DKIM-Signature field added/ {
|
||||
dkim_signed++
|
||||
}
|
||||
|
||||
counter created_accounts
|
||||
counter created_ci_accounts
|
||||
counter created_nonci_accounts
|
||||
|
||||
/: Created address: (?P<addr>.*)$/ {
|
||||
created_accounts++
|
||||
|
||||
$addr =~ /ci-/ {
|
||||
created_ci_accounts++
|
||||
} else {
|
||||
created_nonci_accounts++
|
||||
}
|
||||
}
|
||||
|
||||
counter postfix_timeouts
|
||||
/timeout after DATA/ {
|
||||
postfix_timeouts++
|
||||
}
|
||||
|
||||
counter postfix_noqueue
|
||||
/postfix\/.*NOQUEUE/ {
|
||||
postfix_noqueue++
|
||||
}
|
||||
|
||||
counter warning_count
|
||||
/warning/ {
|
||||
warning_count++
|
||||
}
|
||||
|
||||
|
||||
counter filtered_mail_count
|
||||
|
||||
counter encrypted_mail_count
|
||||
/Filtering encrypted mail\./ {
|
||||
encrypted_mail_count++
|
||||
filtered_mail_count++
|
||||
}
|
||||
|
||||
counter unencrypted_mail_count
|
||||
/Filtering unencrypted mail\./ {
|
||||
unencrypted_mail_count++
|
||||
filtered_mail_count++
|
||||
}
|
||||
|
||||
counter rejected_unencrypted_mail_count
|
||||
/Rejected unencrypted mail\./ {
|
||||
rejected_unencrypted_mail_count++
|
||||
}
|
||||
10
cmdeploy/src/cmdeploy/mtail/mtail.service.j2
Normal file
10
cmdeploy/src/cmdeploy/mtail/mtail.service.j2
Normal file
@@ -0,0 +1,10 @@
|
||||
[Unit]
|
||||
Description=mtail
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -"
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
12
cmdeploy/src/cmdeploy/remote/__init__.py
Normal file
12
cmdeploy/src/cmdeploy/remote/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
|
||||
The 'cmdeploy.remote' sub package contains modules with remotely executing functions.
|
||||
|
||||
Its "_sshexec_bootstrap" module is executed remotely through `SSHExec`
|
||||
and its main() loop there stays connected via a command channel,
|
||||
ready to receive function invocations ("command") and return results.
|
||||
"""
|
||||
|
||||
from . import rdns, rshell
|
||||
|
||||
__all__ = ["rdns", "rshell"]
|
||||
30
cmdeploy/src/cmdeploy/remote/_sshexec_bootstrap.py
Normal file
30
cmdeploy/src/cmdeploy/remote/_sshexec_bootstrap.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import builtins
|
||||
import importlib
|
||||
import traceback
|
||||
|
||||
## Function Execution server
|
||||
|
||||
|
||||
def _run_loop(cmd_channel):
|
||||
while cmd := cmd_channel.receive():
|
||||
cmd_channel.send(_handle_one_request(cmd))
|
||||
|
||||
|
||||
def _handle_one_request(cmd):
|
||||
pymod_path, func_name, kwargs = cmd
|
||||
try:
|
||||
mod = importlib.import_module(pymod_path)
|
||||
func = getattr(mod, func_name)
|
||||
res = func(**kwargs)
|
||||
return ("finish", res)
|
||||
except:
|
||||
data = traceback.format_exc()
|
||||
return ("error", data)
|
||||
|
||||
|
||||
def main(channel):
|
||||
# enable simple "print" logging
|
||||
|
||||
builtins.print = lambda x="": channel.send(("log", x))
|
||||
|
||||
_run_loop(channel)
|
||||
@@ -11,40 +11,26 @@ All functions of this module
|
||||
"""
|
||||
|
||||
import re
|
||||
import traceback
|
||||
from subprocess import CalledProcessError, check_output
|
||||
|
||||
|
||||
def shell(command, fail_ok=False):
|
||||
print(f"$ {command}")
|
||||
try:
|
||||
return check_output(command, shell=True).decode().rstrip()
|
||||
except CalledProcessError:
|
||||
if not fail_ok:
|
||||
raise
|
||||
return ""
|
||||
|
||||
|
||||
def get_systemd_running():
|
||||
lines = shell("systemctl --type=service --state=running").split("\n")
|
||||
return [line for line in lines if line.startswith(" ")]
|
||||
from .rshell import CalledProcessError, shell
|
||||
|
||||
|
||||
def perform_initial_checks(mail_domain):
|
||||
"""Collecting initial DNS settings."""
|
||||
assert mail_domain
|
||||
A = query_dns("A", mail_domain)
|
||||
AAAA = query_dns("AAAA", mail_domain)
|
||||
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
|
||||
|
||||
res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS)
|
||||
if not MTA_STS or (not A and not AAAA):
|
||||
return res
|
||||
|
||||
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
|
||||
if not shell("dig", fail_ok=True):
|
||||
shell("apt-get install -y dnsutils")
|
||||
shell(f"unbound-control flush_zone {mail_domain}", fail_ok=True)
|
||||
A = query_dns("A", mail_domain)
|
||||
AAAA = query_dns("AAAA", mail_domain)
|
||||
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
|
||||
WWW = query_dns("CNAME", f"www.{mail_domain}")
|
||||
|
||||
res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS, WWW=WWW)
|
||||
if not MTA_STS or not WWW or (not A and not AAAA):
|
||||
return res
|
||||
|
||||
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
|
||||
res["dkim_entry"] = get_dkim_entry(mail_domain, dkim_selector="opendkim")
|
||||
|
||||
# parse out sts-id if exists, example: "v=STSv1; id=2090123"
|
||||
@@ -74,8 +60,9 @@ def query_dns(typ, domain):
|
||||
return ""
|
||||
|
||||
|
||||
def check_zonefile(zonefile):
|
||||
def check_zonefile(zonefile, mail_domain):
|
||||
"""Check expected zone file entries."""
|
||||
shell(f"unbound-control flush_zone {mail_domain}", fail_ok=True)
|
||||
required = True
|
||||
required_diff = []
|
||||
recommended_diff = []
|
||||
@@ -99,37 +86,3 @@ def check_zonefile(zonefile):
|
||||
recommended_diff.append(zf_line)
|
||||
|
||||
return required_diff, recommended_diff
|
||||
|
||||
|
||||
## Function Execution server
|
||||
|
||||
|
||||
def _run_loop(cmd_channel):
|
||||
while 1:
|
||||
cmd = cmd_channel.receive()
|
||||
if cmd is None:
|
||||
break
|
||||
|
||||
cmd_channel.send(_handle_one_request(cmd))
|
||||
|
||||
|
||||
def _handle_one_request(cmd):
|
||||
func_name, kwargs = cmd
|
||||
try:
|
||||
res = globals()[func_name](**kwargs)
|
||||
return ("finish", res)
|
||||
except:
|
||||
data = traceback.format_exc()
|
||||
return ("error", data)
|
||||
|
||||
|
||||
# check if this module is executed remotely
|
||||
# and setup a simple serialized function-execution loop
|
||||
|
||||
if __name__ == "__channelexec__":
|
||||
channel = channel # noqa (channel object gets injected)
|
||||
|
||||
# enable simple "print" logging for anyone changing this module
|
||||
globals()["print"] = lambda x="": channel.send(("log", x))
|
||||
|
||||
_run_loop(channel)
|
||||
16
cmdeploy/src/cmdeploy/remote/rshell.py
Normal file
16
cmdeploy/src/cmdeploy/remote/rshell.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from subprocess import CalledProcessError, check_output
|
||||
|
||||
|
||||
def shell(command, fail_ok=False):
|
||||
print(f"$ {command}")
|
||||
try:
|
||||
return check_output(command, shell=True).decode().rstrip()
|
||||
except CalledProcessError:
|
||||
if not fail_ok:
|
||||
raise
|
||||
return ""
|
||||
|
||||
|
||||
def get_systemd_running():
|
||||
lines = shell("systemctl --type=service --state=running").split("\n")
|
||||
return [line for line in lines if line.startswith(" ")]
|
||||
@@ -1,12 +1,45 @@
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
from queue import Queue
|
||||
|
||||
import execnet
|
||||
|
||||
from . import remote
|
||||
|
||||
|
||||
class FuncError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def bootstrap_remote(gateway, remote=remote):
|
||||
"""Return a command channel which can execute remote functions."""
|
||||
source_init_path = inspect.getfile(remote)
|
||||
basedir = os.path.dirname(source_init_path)
|
||||
name = os.path.basename(basedir)
|
||||
|
||||
# rsync sourcedir to remote host
|
||||
remote_pkg_path = f"/root/from-cmdeploy/{name}"
|
||||
q = Queue()
|
||||
finish = lambda: q.put(None)
|
||||
rsync = execnet.RSync(sourcedir=basedir, verbose=False)
|
||||
rsync.add_target(gateway, remote_pkg_path, finishedcallback=finish, delete=True)
|
||||
rsync.send()
|
||||
q.get()
|
||||
|
||||
# start sshexec bootstrap and return its command channel
|
||||
remote_sys_path = os.path.dirname(remote_pkg_path)
|
||||
channel = gateway.remote_exec(
|
||||
f"""
|
||||
import sys
|
||||
sys.path.insert(0, {remote_sys_path!r})
|
||||
from remote._sshexec_bootstrap import main
|
||||
main(channel)
|
||||
"""
|
||||
)
|
||||
return channel
|
||||
|
||||
|
||||
def print_stderr(item="", end="\n"):
|
||||
print(item, file=sys.stderr, end=end)
|
||||
|
||||
@@ -15,16 +48,18 @@ class SSHExec:
|
||||
RemoteError = execnet.RemoteError
|
||||
FuncError = FuncError
|
||||
|
||||
def __init__(self, host, remote_funcs, verbose=False, python="python3", timeout=60):
|
||||
def __init__(self, host, verbose=False, python="python3", timeout=60):
|
||||
self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}")
|
||||
self._remote_cmdloop_channel = self.gateway.remote_exec(remote_funcs)
|
||||
self._remote_cmdloop_channel = bootstrap_remote(self.gateway, remote)
|
||||
self.timeout = timeout
|
||||
self.verbose = verbose
|
||||
|
||||
def __call__(self, call, kwargs=None, log_callback=None):
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
self._remote_cmdloop_channel.send((call.__name__, kwargs))
|
||||
assert call.__module__.startswith("cmdeploy.remote")
|
||||
modname = call.__module__.replace("cmdeploy.", "")
|
||||
self._remote_cmdloop_channel.send((modname, call.__name__, kwargs))
|
||||
while 1:
|
||||
code, data = self._remote_cmdloop_channel.receive(timeout=self.timeout)
|
||||
if log_callback is not None and code == "log":
|
||||
|
||||
@@ -11,7 +11,7 @@ _submission._tcp.zftest.testrun.org. SRV 0 1 587 zftest.testrun.org.
|
||||
_submissions._tcp.zftest.testrun.org. SRV 0 1 465 zftest.testrun.org.
|
||||
_imap._tcp.zftest.testrun.org. SRV 0 1 143 zftest.testrun.org.
|
||||
_imaps._tcp.zftest.testrun.org. SRV 0 1 993 zftest.testrun.org.
|
||||
zftest.testrun.org. CAA 128 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
|
||||
zftest.testrun.org. CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
|
||||
zftest.testrun.org. TXT "v=spf1 a:zftest.testrun.org ~all"
|
||||
_dmarc.zftest.testrun.org. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
||||
_adsp._domainkey.zftest.testrun.org. TXT "dkim=discardable"
|
||||
|
||||
@@ -2,29 +2,29 @@ import smtplib
|
||||
|
||||
import pytest
|
||||
|
||||
from cmdeploy import remote_funcs
|
||||
from cmdeploy import remote
|
||||
from cmdeploy.sshexec import SSHExec
|
||||
|
||||
|
||||
class TestSSHExecutor:
|
||||
@pytest.fixture(scope="class")
|
||||
def sshexec(self, sshdomain):
|
||||
return SSHExec(sshdomain, remote_funcs)
|
||||
return SSHExec(sshdomain)
|
||||
|
||||
def test_ls(self, sshexec):
|
||||
out = sshexec(call=remote_funcs.shell, kwargs=dict(command="ls"))
|
||||
out2 = sshexec(call=remote_funcs.shell, kwargs=dict(command="ls"))
|
||||
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
|
||||
out2 = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
|
||||
assert out == out2
|
||||
|
||||
def test_perform_initial(self, sshexec, maildomain):
|
||||
res = sshexec(
|
||||
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
)
|
||||
assert res["A"] or res["AAAA"]
|
||||
|
||||
def test_logged(self, sshexec, maildomain, capsys):
|
||||
sshexec.logged(
|
||||
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
)
|
||||
out, err = capsys.readouterr()
|
||||
assert err.startswith("Collecting")
|
||||
@@ -33,21 +33,21 @@ class TestSSHExecutor:
|
||||
|
||||
sshexec.verbose = True
|
||||
sshexec.logged(
|
||||
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
)
|
||||
out, err = capsys.readouterr()
|
||||
lines = err.split("\n")
|
||||
assert len(lines) > 4
|
||||
assert remote_funcs.perform_initial_checks.__doc__ in lines[0]
|
||||
assert remote.rdns.perform_initial_checks.__doc__ in lines[0]
|
||||
|
||||
def test_exception(self, sshexec, capsys):
|
||||
try:
|
||||
sshexec.logged(
|
||||
remote_funcs.perform_initial_checks,
|
||||
remote.rdns.perform_initial_checks,
|
||||
kwargs=dict(mail_domain=None),
|
||||
)
|
||||
except sshexec.FuncError as e:
|
||||
assert "remote_funcs.py" in str(e)
|
||||
assert "rdns.py" in str(e)
|
||||
assert "AssertionError" in str(e)
|
||||
else:
|
||||
pytest.fail("didn't raise exception")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from cmdeploy import remote_funcs
|
||||
from cmdeploy import remote
|
||||
from cmdeploy.dns import check_full_zone, check_initial_remote_data
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ def mockdns_base(monkeypatch):
|
||||
except KeyError:
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(remote_funcs, query_dns.__name__, query_dns)
|
||||
monkeypatch.setattr(remote.rdns, query_dns.__name__, query_dns)
|
||||
return qdict
|
||||
|
||||
|
||||
@@ -24,7 +24,10 @@ def mockdns(mockdns_base):
|
||||
{
|
||||
"A": {"some.domain": "1.1.1.1"},
|
||||
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
|
||||
"CNAME": {"mta-sts.some.domain": "some.domain"},
|
||||
"CNAME": {
|
||||
"mta-sts.some.domain": "some.domain.",
|
||||
"www.some.domain": "some.domain.",
|
||||
},
|
||||
}
|
||||
)
|
||||
return mockdns_base
|
||||
@@ -32,14 +35,16 @@ def mockdns(mockdns_base):
|
||||
|
||||
class TestPerformInitialChecks:
|
||||
def test_perform_initial_checks_ok1(self, mockdns):
|
||||
remote_data = remote_funcs.perform_initial_checks("some.domain")
|
||||
assert len(remote_data) == 7
|
||||
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
||||
assert remote_data["A"] == mockdns["A"]["some.domain"]
|
||||
assert remote_data["AAAA"] == mockdns["AAAA"]["some.domain"]
|
||||
assert remote_data["MTA_STS"] == mockdns["CNAME"]["mta-sts.some.domain"]
|
||||
assert remote_data["WWW"] == mockdns["CNAME"]["www.some.domain"]
|
||||
|
||||
@pytest.mark.parametrize("drop", ["A", "AAAA"])
|
||||
def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop):
|
||||
del mockdns[drop]
|
||||
remote_data = remote_funcs.perform_initial_checks("some.domain")
|
||||
assert len(remote_data) == 7
|
||||
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
||||
assert not remote_data[drop]
|
||||
|
||||
l = []
|
||||
@@ -48,9 +53,8 @@ class TestPerformInitialChecks:
|
||||
assert not l
|
||||
|
||||
def test_perform_initial_checks_no_mta_sts(self, mockdns):
|
||||
del mockdns["CNAME"]
|
||||
remote_data = remote_funcs.perform_initial_checks("some.domain")
|
||||
assert len(remote_data) == 4
|
||||
del mockdns["CNAME"]["mta-sts.some.domain"]
|
||||
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
||||
assert not remote_data["MTA_STS"]
|
||||
|
||||
l = []
|
||||
@@ -85,14 +89,18 @@ class TestZonefileChecks:
|
||||
def test_check_zonefile_all_ok(self, cm_data, mockdns_base):
|
||||
zonefile = cm_data.get("zftest.zone")
|
||||
parse_zonefile_into_dict(zonefile, mockdns_base)
|
||||
required_diff, recommended_diff = remote_funcs.check_zonefile(zonefile)
|
||||
required_diff, recommended_diff = remote.rdns.check_zonefile(
|
||||
zonefile, "some.domain"
|
||||
)
|
||||
assert not required_diff and not recommended_diff
|
||||
|
||||
def test_check_zonefile_recommended_not_set(self, cm_data, mockdns_base):
|
||||
zonefile = cm_data.get("zftest.zone")
|
||||
zonefile_mocked = zonefile.split("; Recommended")[0]
|
||||
parse_zonefile_into_dict(zonefile_mocked, mockdns_base)
|
||||
required_diff, recommended_diff = remote_funcs.check_zonefile(zonefile)
|
||||
required_diff, recommended_diff = remote.rdns.check_zonefile(
|
||||
zonefile, "some.domain"
|
||||
)
|
||||
assert not required_diff
|
||||
assert len(recommended_diff) == 8
|
||||
|
||||
@@ -101,6 +109,7 @@ class TestZonefileChecks:
|
||||
zonefile_mocked = zonefile.split("; Recommended")[0]
|
||||
parse_zonefile_into_dict(zonefile_mocked, mockdns_base, only_required=True)
|
||||
mssh = MockSSHExec()
|
||||
mockdns_base["mail_domain"] = "some.domain"
|
||||
res = check_full_zone(mssh, mockdns_base, out=mockout, zonefile=zonefile)
|
||||
assert res == 0
|
||||
assert "WARNING" in mockout.captured_plain[0]
|
||||
@@ -110,6 +119,7 @@ class TestZonefileChecks:
|
||||
zonefile = cm_data.get("zftest.zone")
|
||||
parse_zonefile_into_dict(zonefile, mockdns_base)
|
||||
mssh = MockSSHExec()
|
||||
mockdns_base["mail_domain"] = "some.domain"
|
||||
res = check_full_zone(mssh, mockdns_base, out=mockout, zonefile=zonefile)
|
||||
assert res == 0
|
||||
assert not mockout.captured_red
|
||||
|
||||
@@ -43,6 +43,20 @@ The first login sets your password.
|
||||
- You can store up to [{{ config.max_mailbox_size }} messages on the server](https://delta.chat/en/help#what-happens-if-i-turn-on-delete-old-messages-from-server).
|
||||
|
||||
|
||||
### <a name="account-deletion"></a> Account deletion
|
||||
|
||||
If you remove a {{ config.mail_domain }} profile from within the Delta Chat app,
|
||||
then the according account on the server, along with all associated data,
|
||||
is automatically deleted {{ config.delete_inactive_users_after }} days afterwards.
|
||||
|
||||
If you use multiple devices
|
||||
then you need to remove the according chat profile from each device
|
||||
in order for all account data to be removed on the server side.
|
||||
|
||||
If you have any further questions or requests regarding account deletion
|
||||
please send a message from your account to {{ config.privacy_mail }}.
|
||||
|
||||
|
||||
### Who are the operators? Which software is running?
|
||||
|
||||
This chatmail provider is run by a small voluntary group of devs and sysadmins,
|
||||
|
||||
Reference in New Issue
Block a user