mirror of
https://github.com/chatmail/relay.git
synced 2026-05-11 08:24:37 +00:00
Compare commits
11 Commits
cmdeploy-p
...
invite-onl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a7b425958 | ||
|
|
cb87c85c03 | ||
|
|
5b4eb1701e | ||
|
|
f578704069 | ||
|
|
7319977527 | ||
|
|
7dcd109bec | ||
|
|
6940175b06 | ||
|
|
de139bde18 | ||
|
|
a92c9ff275 | ||
|
|
1afdab7b20 | ||
|
|
56cbd6f35b |
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Mutual Help Chat Group
|
- 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
|
url: https://i.delta.chat/#C2846EB4C1CB8DF84B1818F5E3A638FC3FBDC981&a=stalebot1%40nine.testrun.org&g=Chatmail%20Mutual%20Help&x=7sFF7Ik50pWv6J1z7RVC5527&i=d7s1HvOsk5UrSf9AoqRZggg4&s=XmX_9BAW6-g5Ao5E8PyaeKNB
|
||||||
about: If you have troubles setting up the relay server, feel free to ask here.
|
about: If you have troubles setting up the relay server, feel free to ask here.
|
||||||
|
|||||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -2,23 +2,12 @@
|
|||||||
|
|
||||||
## untagged
|
## untagged
|
||||||
|
|
||||||
- cmdeploy: make --ssh-host work with localhost
|
- Enable invite-only chatmail relays with invite tokens
|
||||||
([#659](https://github.com/chatmail/relay/pull/659))
|
that can override disabled account creation
|
||||||
|
([#600](https://github.com/chatmail/relay/pull/600))
|
||||||
|
|
||||||
- Update iroh-relay to 0.35.0
|
- dovecot: keep mailbox index only in memory to avoid unnecessary disc usage
|
||||||
([#650](https://github.com/chatmail/relay/pull/650))
|
([#632](https://github.com/chatmail/relay/pull/632))
|
||||||
|
|
||||||
- Ignore all RCPT TO: parameters
|
|
||||||
([#651](https://github.com/chatmail/relay/pull/651))
|
|
||||||
|
|
||||||
- Use max username length in newemail.py, not min
|
|
||||||
([#648](https://github.com/chatmail/relay/pull/648))
|
|
||||||
|
|
||||||
- Increase maxproc for reinjecting ports from 10 to 100
|
|
||||||
([#646](https://github.com/chatmail/relay/pull/646))
|
|
||||||
|
|
||||||
- Allow ports 143 and 993 to be used by `dovecot` process
|
|
||||||
([#639](https://github.com/chatmail/relay/pull/639))
|
|
||||||
|
|
||||||
## 1.7.0 2025-09-11
|
## 1.7.0 2025-09-11
|
||||||
|
|
||||||
|
|||||||
104
README.md
104
README.md
@@ -284,8 +284,23 @@ Fresh chatmail addresses have a mailbox directory that contains:
|
|||||||
will typically be empty unless the user of that address hasn't been online
|
will typically be empty unless the user of that address hasn't been online
|
||||||
for a while.
|
for a while.
|
||||||
|
|
||||||
|
## Restrict address creation
|
||||||
|
|
||||||
## Emergency Commands to disable automatic address creation
|
### Only allow new addresses with an invite token
|
||||||
|
|
||||||
|
To restrict address creation for anyone who doesn't have the invite link/QR code:
|
||||||
|
|
||||||
|
1. Use the `invite_token` option to add
|
||||||
|
one or more tokens of your choice to `chatmail.ini`:
|
||||||
|
`invite_token = s3cr3t privil3g3`
|
||||||
|
- (recommendation: choose 9 or more letters, or it will be easily bruteforced)
|
||||||
|
2. Run `scripts/cmdeploy run`
|
||||||
|
3. Distribute a `dcaccount` invite link/QR code
|
||||||
|
(like the one on your web page)
|
||||||
|
with one of your invite tokens added at the end,
|
||||||
|
for example: `dcaccount:https://example.org/new?s3cr3t`
|
||||||
|
|
||||||
|
### Emergency Command to disable automatic address creation
|
||||||
|
|
||||||
If you need to stop address creation,
|
If you need to stop address creation,
|
||||||
e.g. because some script is wildly creating addresses,
|
e.g. because some script is wildly creating addresses,
|
||||||
@@ -456,15 +471,94 @@ to send messages outside.
|
|||||||
|
|
||||||
To setup a reverse proxy
|
To setup a reverse proxy
|
||||||
(or rather Destination NAT, DNAT)
|
(or rather Destination NAT, DNAT)
|
||||||
for your chatmail relay, run:
|
for your chatmail relay,
|
||||||
|
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 relay reboots.
|
||||||
|
|
||||||
|
Uncomment in `/etc/sysctl.conf` the following two lines:
|
||||||
|
|
||||||
```
|
```
|
||||||
scripts/cmdeploy proxy <proxy_ip_address> --relay-ipv4 <relay_ipv4_address> --relay-ipv6 <relay_ipv6_address>
|
net.ipv4.ip_forward=1
|
||||||
|
net.ipv6.conf.all.forwarding=1
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Then reboot the relay or do `sysctl -p` and `nft -f /etc/nftables.conf`.
|
||||||
|
|
||||||
Once proxy relay is set up,
|
Once proxy relay is set up,
|
||||||
you can add its IP address to the DNS,
|
you can add its IP address to the DNS.
|
||||||
or distribute it as you wish.
|
|
||||||
|
|
||||||
## Neighbors and Acquaintances
|
## Neighbors and Acquaintances
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Config:
|
|||||||
self.username_min_length = int(params["username_min_length"])
|
self.username_min_length = int(params["username_min_length"])
|
||||||
self.username_max_length = int(params["username_max_length"])
|
self.username_max_length = int(params["username_max_length"])
|
||||||
self.password_min_length = int(params["password_min_length"])
|
self.password_min_length = int(params["password_min_length"])
|
||||||
|
self.invite_token = params.get("invite_token", "")
|
||||||
self.passthrough_senders = params["passthrough_senders"].split()
|
self.passthrough_senders = params["passthrough_senders"].split()
|
||||||
self.passthrough_recipients = params["passthrough_recipients"].split()
|
self.passthrough_recipients = params["passthrough_recipients"].split()
|
||||||
self.www_folder = params.get("www_folder", "")
|
self.www_folder = params.get("www_folder", "")
|
||||||
|
|||||||
@@ -26,8 +26,19 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
|||||||
if os.path.exists(NOCREATE_FILE):
|
if os.path.exists(NOCREATE_FILE):
|
||||||
logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.")
|
logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.")
|
||||||
return False
|
return False
|
||||||
|
password_length = len(cleartext_password)
|
||||||
|
if config.invite_token:
|
||||||
|
for inv_token in config.invite_token.split():
|
||||||
|
if cleartext_password.startswith(inv_token):
|
||||||
|
password_length = len(cleartext_password) - len(inv_token)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
logging.warning(
|
||||||
|
"blocked account creation because password didn't contain invite token(s)."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
if len(cleartext_password) < config.password_min_length:
|
if password_length < config.password_min_length:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Password needs to be at least %s characters long",
|
"Password needs to be at least %s characters long",
|
||||||
config.password_min_length,
|
config.password_min_length,
|
||||||
|
|||||||
@@ -197,13 +197,11 @@ class HackedController(Controller):
|
|||||||
|
|
||||||
class SMTPDiscardRCPTO_options(SMTP):
|
class SMTPDiscardRCPTO_options(SMTP):
|
||||||
def _getparams(self, params):
|
def _getparams(self, params):
|
||||||
# Ignore RCPT TO parameters.
|
# aiosmtpd's SMTP daemon fails to handle a request if there are RCPT TO options
|
||||||
#
|
# We just ignore them for our incoming filtermail purposes
|
||||||
# Otherwise parameters such as `ORCPT=...`
|
if len(params) == 1 and params[0].startswith("ORCPT"):
|
||||||
# or `NOTIFY=DELAY,FAILURE` (generated by Stalwart)
|
return {}
|
||||||
# make aiosmtpd reject the message here:
|
return super()._getparams(params)
|
||||||
# <https://github.com/aio-libs/aiosmtpd/blob/98f578389ae86e5345cc343fa4e5a17b21d9c96d/aiosmtpd/smtp.py#L1379-L1384>
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class OutgoingBeforeQueueHandler:
|
class OutgoingBeforeQueueHandler:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"""CGI script for creating new accounts."""
|
"""CGI script for creating new accounts."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
@@ -15,12 +16,16 @@ ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
|||||||
|
|
||||||
|
|
||||||
def create_newemail_dict(config: Config):
|
def create_newemail_dict(config: Config):
|
||||||
user = "".join(random.choices(ALPHANUMERIC, k=config.username_max_length))
|
user = "".join(random.choices(ALPHANUMERIC, k=config.username_min_length))
|
||||||
password = "".join(
|
password = "".join(
|
||||||
secrets.choice(ALPHANUMERIC_PUNCT)
|
secrets.choice(ALPHANUMERIC_PUNCT)
|
||||||
for _ in range(config.password_min_length + 3)
|
for _ in range(config.password_min_length + 3)
|
||||||
)
|
)
|
||||||
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
|
redirect_uri = os.getenv("REQUEST_URI", "/new")
|
||||||
|
invite_token = "" if redirect_uri == "/new" else redirect_uri[5:]
|
||||||
|
return dict(
|
||||||
|
email=f"{user}@{config.mail_domain}", password=f"{invite_token}{password}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def print_new_account():
|
def print_new_account():
|
||||||
|
|||||||
@@ -64,12 +64,38 @@ def test_dont_overwrite_password_on_wrong_login(dictproxy):
|
|||||||
assert res["password"] == res2["password"]
|
assert res["password"] == res2["password"]
|
||||||
|
|
||||||
|
|
||||||
def test_nocreate_file(monkeypatch, tmpdir, dictproxy):
|
@pytest.mark.parametrize(
|
||||||
p = tmpdir.join("nocreate")
|
["nocreate_file", "account", "invite_token", "password"],
|
||||||
p.write("")
|
[
|
||||||
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
|
(False, True, "asdf", "asdfasdmaimfelsgwerw"),
|
||||||
dictproxy.lookup_passdb("newuser12@chat.example.org", "zequ0Aimuchoodaechik")
|
(False, False, "asdf", "z9873240187420913798"),
|
||||||
assert not dictproxy.lookup_userdb("newuser12@chat.example.org")
|
(False, True, "", "dsaiujfw9fjiwf9w"),
|
||||||
|
(False, False, "asdf", "z987324018742asdf0913798"),
|
||||||
|
(False, True, "as df", "asj0wiefkj0ofkeefok"),
|
||||||
|
(False, True, "as df", "dfj0wiefkj0ofkeefok"),
|
||||||
|
(False, False, "as df", "j0wiefkj0ofas dfkeefok"),
|
||||||
|
(True, False, "asdf", "asdfmosadkdkfwdofkw"),
|
||||||
|
(True, False, "asdf", "z9873240187420913798"),
|
||||||
|
(True, False, "", "dsaiujfw9fjiwf9w"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_nocreate_file(
|
||||||
|
monkeypatch,
|
||||||
|
tmpdir,
|
||||||
|
dictproxy,
|
||||||
|
example_config,
|
||||||
|
nocreate_file: bool,
|
||||||
|
account: bool,
|
||||||
|
invite_token: str,
|
||||||
|
password: str,
|
||||||
|
):
|
||||||
|
if nocreate_file:
|
||||||
|
p = tmpdir.join("nocreate")
|
||||||
|
p.write("")
|
||||||
|
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
|
||||||
|
example_config.invite_token = invite_token
|
||||||
|
dictproxy.lookup_passdb("newuser12@chat.example.org", password)
|
||||||
|
assert bool(dictproxy.lookup_userdb("newuser12@chat.example.org")) == account
|
||||||
|
|
||||||
|
|
||||||
def test_handle_dovecot_request(dictproxy):
|
def test_handle_dovecot_request(dictproxy):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
|||||||
from chatmaild.config import Config, read_config
|
from chatmaild.config import Config, read_config
|
||||||
from pyinfra import facts, host, logger
|
from pyinfra import facts, host, logger
|
||||||
from pyinfra.api import FactBase
|
from pyinfra.api import FactBase
|
||||||
from pyinfra.facts.files import File, Sha256File
|
from pyinfra.facts.files import File
|
||||||
from pyinfra.facts.server import Sysctl
|
from pyinfra.facts.server import Sysctl
|
||||||
from pyinfra.facts.systemd import SystemdEnabled
|
from pyinfra.facts.systemd import SystemdEnabled
|
||||||
from pyinfra.operations import apt, files, pip, server, systemd
|
from pyinfra.operations import apt, files, pip, server, systemd
|
||||||
@@ -555,12 +555,12 @@ def deploy_mtail(config):
|
|||||||
def deploy_iroh_relay(config) -> None:
|
def deploy_iroh_relay(config) -> None:
|
||||||
(url, sha256sum) = {
|
(url, sha256sum) = {
|
||||||
"x86_64": (
|
"x86_64": (
|
||||||
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-x86_64-unknown-linux-musl.tar.gz",
|
"https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-x86_64-unknown-linux-musl.tar.gz",
|
||||||
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
|
"2ffacf7c0622c26b67a5895ee8e07388769599f60e5f52a3bd40a3258db89b2c",
|
||||||
),
|
),
|
||||||
"aarch64": (
|
"aarch64": (
|
||||||
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-aarch64-unknown-linux-musl.tar.gz",
|
"https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-aarch64-unknown-linux-musl.tar.gz",
|
||||||
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
|
"b915037bcc1ff1110cc9fcb5de4a17c00ff576fd2f568cd339b3b2d54c420dc4",
|
||||||
),
|
),
|
||||||
}[host.get_fact(facts.server.Arch)]
|
}[host.get_fact(facts.server.Arch)]
|
||||||
|
|
||||||
@@ -569,18 +569,15 @@ def deploy_iroh_relay(config) -> None:
|
|||||||
packages=["curl"],
|
packages=["curl"],
|
||||||
)
|
)
|
||||||
|
|
||||||
need_restart = False
|
server.shell(
|
||||||
|
name="Download iroh-relay",
|
||||||
|
commands=[
|
||||||
|
f"(echo '{sha256sum} /usr/local/bin/iroh-relay' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
|
||||||
|
"chmod 755 /usr/local/bin/iroh-relay",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay")
|
need_restart = False
|
||||||
if existing_sha256sum != sha256sum:
|
|
||||||
server.shell(
|
|
||||||
name="Download iroh-relay",
|
|
||||||
commands=[
|
|
||||||
f"(curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && (echo '{sha256sum} /usr/local/bin/iroh-relay.new' | sha256sum -c) && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
|
|
||||||
"chmod 755 /usr/local/bin/iroh-relay",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
need_restart = True
|
|
||||||
|
|
||||||
systemd_unit = files.put(
|
systemd_unit = files.put(
|
||||||
name="Upload iroh-relay systemd unit",
|
name="Upload iroh-relay systemd unit",
|
||||||
@@ -682,11 +679,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
|
|||||||
(["master", "smtpd"], 25),
|
(["master", "smtpd"], 25),
|
||||||
("unbound", 53),
|
("unbound", 53),
|
||||||
("acmetool", 80),
|
("acmetool", 80),
|
||||||
(["imap-login", "dovecot"], 143),
|
("imap-login", 143),
|
||||||
("nginx", 443),
|
("nginx", 443),
|
||||||
(["master", "smtpd"], 465),
|
(["master", "smtpd"], 465),
|
||||||
(["master", "smtpd"], 587),
|
(["master", "smtpd"], 587),
|
||||||
(["imap-login", "dovecot"], 993),
|
("imap-login", 993),
|
||||||
("iroh-relay", 3340),
|
("iroh-relay", 3340),
|
||||||
("nginx", 8443),
|
("nginx", 8443),
|
||||||
(["master", "smtpd"], config.postfix_reinject_port),
|
(["master", "smtpd"], config.postfix_reinject_port),
|
||||||
@@ -699,9 +696,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
|
|||||||
running_service = host.get_fact(Port, port=port)
|
running_service = host.get_fact(Port, port=port)
|
||||||
if running_service:
|
if running_service:
|
||||||
if running_service not in service:
|
if running_service not in service:
|
||||||
Out().red(
|
Out().red(f"Deploy failed: port {port} is occupied by: {running_service}")
|
||||||
f"Deploy failed: port {port} is occupied by: {running_service}"
|
|
||||||
)
|
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
apt.packages(
|
apt.packages(
|
||||||
@@ -813,12 +808,6 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
|
|||||||
restarted=nginx_need_restart,
|
restarted=nginx_need_restart,
|
||||||
)
|
)
|
||||||
|
|
||||||
systemd.service(
|
|
||||||
name="Restart echobot if postfix and dovecot were just started",
|
|
||||||
service="echobot.service",
|
|
||||||
restarted=postfix_need_restart and dovecot_need_restart,
|
|
||||||
)
|
|
||||||
|
|
||||||
# This file is used by auth proxy.
|
# This file is used by auth proxy.
|
||||||
# https://wiki.debian.org/EtcMailName
|
# https://wiki.debian.org/EtcMailName
|
||||||
server.shell(
|
server.shell(
|
||||||
|
|||||||
@@ -61,15 +61,14 @@ def run_cmd_options(parser):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--ssh-host",
|
"--ssh-host",
|
||||||
dest="ssh_host",
|
dest="ssh_host",
|
||||||
help="Deploy to 'localhost' or to a specific SSH host",
|
help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args, out):
|
def run_cmd(args, out):
|
||||||
"""Deploy chatmail services on the remote server."""
|
"""Deploy chatmail services on the remote server."""
|
||||||
|
|
||||||
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
sshexec = args.get_sshexec()
|
||||||
sshexec = get_sshexec(ssh_host)
|
|
||||||
require_iroh = args.config.enable_iroh_relay
|
require_iroh = args.config.enable_iroh_relay
|
||||||
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
||||||
if not dns.check_initial_remote_data(remote_data, print=out.red):
|
if not dns.check_initial_remote_data(remote_data, print=out.red):
|
||||||
@@ -81,11 +80,8 @@ def run_cmd(args, out):
|
|||||||
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
|
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
|
||||||
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
|
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
|
||||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||||
|
ssh_host = args.config.mail_domain if not args.ssh_host else args.ssh_host
|
||||||
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
||||||
if ssh_host == "localhost":
|
|
||||||
cmd = f"{pyinf} @local {deploy_path} -y"
|
|
||||||
|
|
||||||
if version.parse(pyinfra.__version__) < version.parse("3"):
|
if version.parse(pyinfra.__version__) < version.parse("3"):
|
||||||
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
|
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
|
||||||
return 1
|
return 1
|
||||||
@@ -122,17 +118,11 @@ def dns_cmd_options(parser):
|
|||||||
default=None,
|
default=None,
|
||||||
help="write out a zonefile",
|
help="write out a zonefile",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--ssh-host",
|
|
||||||
dest="ssh_host",
|
|
||||||
help="Run the DNS queries on 'localhost' or on a specific SSH host",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def dns_cmd(args, out):
|
def dns_cmd(args, out):
|
||||||
"""Check DNS entries and optionally generate dns zone file."""
|
"""Check DNS entries and optionally generate dns zone file."""
|
||||||
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
sshexec = args.get_sshexec()
|
||||||
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
|
||||||
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
||||||
if not remote_data:
|
if not remote_data:
|
||||||
return 1
|
return 1
|
||||||
@@ -208,61 +198,6 @@ def test_cmd(args, out):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def proxy_cmd_options(parser: argparse.ArgumentParser):
|
|
||||||
parser.add_argument(
|
|
||||||
"ip_address",
|
|
||||||
help="specify a server to deploy to; can also be an inventory.py file",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--relay-ipv4",
|
|
||||||
dest="relay_ipv4",
|
|
||||||
help="The ipv4 address of the relay you want to forward traffic to",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--relay-ipv6",
|
|
||||||
dest="relay_ipv6",
|
|
||||||
help="The ipv6 address of the relay you want to forward traffic to",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--dry-run",
|
|
||||||
dest="dry_run",
|
|
||||||
action="store_true",
|
|
||||||
help="don't actually modify the server",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def proxy_cmd(args, out):
|
|
||||||
"""Deploy reverse proxy on a second server."""
|
|
||||||
env = os.environ.copy()
|
|
||||||
env["RELAY_IPV4"] = args.relay_ipv4
|
|
||||||
env["RELAY_IPV6"] = args.relay_ipv6
|
|
||||||
deploy_path = importlib.resources.files(__package__).joinpath("proxy-deploy.py").resolve()
|
|
||||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
|
||||||
|
|
||||||
sshexec = args.get_sshexec()
|
|
||||||
# :todo make sure relay is deployed to args.relay_ipv4 and args.relay_ipv6
|
|
||||||
|
|
||||||
# abort if IP address == the chatmail relay itself: if port 22 is open AND /etc/chatmail-version exists
|
|
||||||
if sshexec.logged(call=remote.rshell.get_port_service, args=[22]):
|
|
||||||
if sshexec.logged(call=remote.rshell.chatmail_version):
|
|
||||||
out.red("Can not deploy proxy on the chatmail relay itself, use another server")
|
|
||||||
return 1
|
|
||||||
cmd = f"{pyinf} --ssh-user root {args.ip_address} {deploy_path} -y"
|
|
||||||
out.check_call(cmd, env=env) # during first try, only set SSH port to 2222
|
|
||||||
|
|
||||||
cmd = f"{pyinf} --ssh-port 2222 --ssh-user root {args.ip_address} {deploy_path} -y"
|
|
||||||
try:
|
|
||||||
retcode = out.check_call(cmd, env=env)
|
|
||||||
if retcode == 0:
|
|
||||||
out.green("Reverse proxy deployed - you can distribute the IP address now.")
|
|
||||||
else:
|
|
||||||
out.red("Deploying reverse proxy failed")
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
out.red("Deploying reverse proxy failed")
|
|
||||||
retcode = 1
|
|
||||||
return retcode
|
|
||||||
|
|
||||||
|
|
||||||
def fmt_cmd_options(parser):
|
def fmt_cmd_options(parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--check",
|
"--check",
|
||||||
@@ -396,14 +331,6 @@ def get_parser():
|
|||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def get_sshexec(ssh_host: str, verbose=True):
|
|
||||||
if ssh_host in ["localhost", "@local"]:
|
|
||||||
return "localhost"
|
|
||||||
if verbose:
|
|
||||||
print(f"[ssh] login to {ssh_host}")
|
|
||||||
return SSHExec(ssh_host, verbose=verbose)
|
|
||||||
|
|
||||||
|
|
||||||
def main(args=None):
|
def main(args=None):
|
||||||
"""Provide main entry point for 'cmdeploy' CLI invocation."""
|
"""Provide main entry point for 'cmdeploy' CLI invocation."""
|
||||||
parser = get_parser()
|
parser = get_parser()
|
||||||
@@ -411,6 +338,12 @@ def main(args=None):
|
|||||||
if not hasattr(args, "func"):
|
if not hasattr(args, "func"):
|
||||||
return parser.parse_args(["-h"])
|
return parser.parse_args(["-h"])
|
||||||
|
|
||||||
|
def get_sshexec():
|
||||||
|
print(f"[ssh] login to {args.config.mail_domain}")
|
||||||
|
return SSHExec(args.config.mail_domain, verbose=args.verbose)
|
||||||
|
|
||||||
|
args.get_sshexec = get_sshexec
|
||||||
|
|
||||||
out = Out()
|
out = Out()
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
|
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
|
||||||
|
|||||||
@@ -7,13 +7,9 @@ from . import remote
|
|||||||
|
|
||||||
|
|
||||||
def get_initial_remote_data(sshexec, mail_domain):
|
def get_initial_remote_data(sshexec, mail_domain):
|
||||||
if sshexec == "localhost":
|
return sshexec.logged(
|
||||||
result = remote.rdns.perform_initial_checks(mail_domain)
|
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
||||||
else:
|
)
|
||||||
result = sshexec.logged(
|
|
||||||
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def check_initial_remote_data(remote_data, *, print=print):
|
def check_initial_remote_data(remote_data, *, print=print):
|
||||||
@@ -48,14 +44,10 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
|
|||||||
"""Check existing DNS records, optionally write them to zone file
|
"""Check existing DNS records, optionally write them to zone file
|
||||||
and return (exitcode, remote_data) tuple."""
|
and return (exitcode, remote_data) tuple."""
|
||||||
|
|
||||||
if sshexec == "localhost":
|
required_diff, recommended_diff = sshexec.logged(
|
||||||
required_diff, recommended_diff = remote.rdns.check_zonefile(
|
remote.rdns.check_zonefile,
|
||||||
zonefile=zonefile, verbose=False
|
kwargs=dict(zonefile=zonefile, mail_domain=remote_data["mail_domain"]),
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
required_diff, recommended_diff = sshexec.logged(
|
|
||||||
remote.rdns.check_zonefile, kwargs=dict(zonefile=zonefile, verbose=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
returncode = 0
|
returncode = 0
|
||||||
if required_diff:
|
if required_diff:
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ 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=MEMORY
|
||||||
|
|
||||||
namespace inbox {
|
namespace inbox {
|
||||||
inbox = yes
|
inbox = yes
|
||||||
|
|||||||
@@ -84,12 +84,13 @@ http {
|
|||||||
if ($request_method = GET) {
|
if ($request_method = GET) {
|
||||||
# Redirect to Delta Chat,
|
# Redirect to Delta Chat,
|
||||||
# which will in turn do a POST request.
|
# which will in turn do a POST request.
|
||||||
return 301 dcaccount:https://{{ config.domain_name }}/new;
|
return 301 dcaccount:https://{{ config.domain_name }}$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
fastcgi_pass unix:/run/fcgiwrap.socket;
|
fastcgi_pass unix:/run/fcgiwrap.socket;
|
||||||
include /etc/nginx/fastcgi_params;
|
include /etc/nginx/fastcgi_params;
|
||||||
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
|
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
|
||||||
|
fastcgi_param QUERY_STRING $query_string;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Old URL for compatibility with e.g. printed QR codes.
|
# Old URL for compatibility with e.g. printed QR codes.
|
||||||
@@ -100,7 +101,7 @@ http {
|
|||||||
# Redirects are only for browsers.
|
# Redirects are only for browsers.
|
||||||
location /cgi-bin/newemail.py {
|
location /cgi-bin/newemail.py {
|
||||||
if ($request_method = GET) {
|
if ($request_method = GET) {
|
||||||
return 301 dcaccount:https://{{ config.domain_name }}/new;
|
return 301 dcaccount:https://{{ config.domain_name }}$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
fastcgi_pass unix:/run/fcgiwrap.socket;
|
fastcgi_pass unix:/run/fcgiwrap.socket;
|
||||||
|
|||||||
@@ -77,13 +77,13 @@ scache unix - - y - 1 scache
|
|||||||
postlog unix-dgram n - n - 1 postlogd
|
postlog unix-dgram n - n - 1 postlogd
|
||||||
filter unix - n n - - lmtp
|
filter unix - n n - - lmtp
|
||||||
# Local SMTP server for reinjecting outgoing filtered mail.
|
# Local SMTP server for reinjecting outgoing filtered mail.
|
||||||
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
|
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
|
||||||
-o syslog_name=postfix/reinject
|
-o syslog_name=postfix/reinject
|
||||||
-o smtpd_milters=unix:opendkim/opendkim.sock
|
-o smtpd_milters=unix:opendkim/opendkim.sock
|
||||||
-o cleanup_service_name=authclean
|
-o cleanup_service_name=authclean
|
||||||
|
|
||||||
# Local SMTP server for reinjecting incoming filtered mail
|
# Local SMTP server for reinjecting incoming filtered mail
|
||||||
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
|
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 10 smtpd
|
||||||
-o syslog_name=postfix/reinject_incoming
|
-o syslog_name=postfix/reinject_incoming
|
||||||
-o smtpd_milters=unix:opendkim/opendkim.sock
|
-o smtpd_milters=unix:opendkim/opendkim.sock
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import os
|
|
||||||
|
|
||||||
import pyinfra
|
|
||||||
from pyinfra import host
|
|
||||||
|
|
||||||
from proxy import configure_ssh, configure_proxy
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ipv4_relay = os.getenv("IPV4_RELAY")
|
|
||||||
ipv6_relay = os.getenv("IPV6_RELAY")
|
|
||||||
|
|
||||||
configure_ssh()
|
|
||||||
if host.data.get("ssh_port") not in (None, 22):
|
|
||||||
configure_proxy(ipv4_relay, ipv6_relay)
|
|
||||||
|
|
||||||
|
|
||||||
if pyinfra.is_cli:
|
|
||||||
main()
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import importlib
|
|
||||||
|
|
||||||
from pyinfra import host
|
|
||||||
from pyinfra.operations import files, server, apt, systemd
|
|
||||||
|
|
||||||
def configure_ssh():
|
|
||||||
files.replace(
|
|
||||||
name="Configure sshd to use port 2222",
|
|
||||||
path="/etc/ssh/sshd_config",
|
|
||||||
text="Port 22\n",
|
|
||||||
replace="Port 2222\n",
|
|
||||||
)
|
|
||||||
systemd.service(
|
|
||||||
name="apply SSH config",
|
|
||||||
service="ssh",
|
|
||||||
reloaded=True,
|
|
||||||
)
|
|
||||||
apt.update()
|
|
||||||
|
|
||||||
|
|
||||||
def configure_proxy(ipv4_relay, ipv6_relay):
|
|
||||||
files.put(
|
|
||||||
name="Configure nftables",
|
|
||||||
src=importlib.resources.files(__package__).joinpath("proxy_files/nftables.conf.j2"),
|
|
||||||
dest="/etc/nftables.conf",
|
|
||||||
ipv4_address=ipv4_relay, # :todo what if only one of them is specified?
|
|
||||||
ipv6_address=ipv6_relay,
|
|
||||||
)
|
|
||||||
|
|
||||||
server.sysctl(name="enable IPv4 forwarding", key="net.ipv4.ip_forward", value=1, persist=True)
|
|
||||||
|
|
||||||
server.sysctl(
|
|
||||||
name="enable IPv6 forwarding",
|
|
||||||
key="net.ipv6.conf.all.forwarding",
|
|
||||||
value=1,
|
|
||||||
persist=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
server.shell(
|
|
||||||
name="apply forwarding configuration",
|
|
||||||
commands=[
|
|
||||||
"sysctl -p",
|
|
||||||
"nft -f /etc/nftables.conf",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
if host.data.get("floating_ips"):
|
|
||||||
i = 0
|
|
||||||
for floating_ip in host.data.get("floating_ips"):
|
|
||||||
i += 1
|
|
||||||
files.template(
|
|
||||||
name="Add floating IPs",
|
|
||||||
src="servers/proxy-nine/files/60-floating.ip.cfg.j2",
|
|
||||||
dest=f"/etc/network/interfaces.d/{59 + i}-floating.ip.cfg",
|
|
||||||
ip_address=floating_ip,
|
|
||||||
i=i,
|
|
||||||
)
|
|
||||||
|
|
||||||
systemd.service(
|
|
||||||
name="apply floating IPs",
|
|
||||||
service="networking",
|
|
||||||
restarted=True,
|
|
||||||
)
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
auto eth0:{{ i }}
|
|
||||||
iface eth0:{{ i }} inet static
|
|
||||||
address {{ ip_address }}
|
|
||||||
netmask 32
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/sbin/nft -f
|
|
||||||
|
|
||||||
flush ruleset
|
|
||||||
|
|
||||||
define wan = eth0
|
|
||||||
|
|
||||||
# which ports to proxy
|
|
||||||
define ports = { smtp, http, https, imap, imaps, submission, submissions }
|
|
||||||
|
|
||||||
# the host we want to proxy to
|
|
||||||
define ipv4_address = {{ ipv4_address }}
|
|
||||||
define ipv6_address = [{{ ipv6_address }}]
|
|
||||||
|
|
||||||
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 { 22, 2222 } accept
|
|
||||||
# Allow incoming shadowsocks connections.
|
|
||||||
tcp dport { 8388 } 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,23 +12,23 @@ All functions of this module
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from .rshell import CalledProcessError, shell, log_progress
|
from .rshell import CalledProcessError, shell
|
||||||
|
|
||||||
|
|
||||||
def perform_initial_checks(mail_domain, pre_command=""):
|
def perform_initial_checks(mail_domain):
|
||||||
"""Collecting initial DNS settings."""
|
"""Collecting initial DNS settings."""
|
||||||
assert mail_domain
|
assert mail_domain
|
||||||
if not shell("dig", fail_ok=True, print=log_progress):
|
if not shell("dig", fail_ok=True):
|
||||||
shell("apt-get update && apt-get install -y dnsutils", print=log_progress)
|
shell("apt-get update && apt-get install -y dnsutils")
|
||||||
A = query_dns("A", mail_domain)
|
A = query_dns("A", mail_domain)
|
||||||
AAAA = query_dns("AAAA", mail_domain)
|
AAAA = query_dns("AAAA", mail_domain)
|
||||||
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
|
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
|
||||||
WWW = query_dns("CNAME", f"www.{mail_domain}")
|
WWW = query_dns("CNAME", f"www.{mail_domain}")
|
||||||
|
|
||||||
res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS, WWW=WWW)
|
res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS, WWW=WWW)
|
||||||
res["acme_account_url"] = shell(pre_command + "acmetool account-url", fail_ok=True, print=log_progress)
|
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
|
||||||
res["dkim_entry"], res["web_dkim_entry"] = get_dkim_entry(
|
res["dkim_entry"], res["web_dkim_entry"] = get_dkim_entry(
|
||||||
mail_domain, pre_command, dkim_selector="opendkim"
|
mail_domain, dkim_selector="opendkim"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not MTA_STS or not WWW or (not A and not AAAA):
|
if not MTA_STS or not WWW or (not A and not AAAA):
|
||||||
@@ -40,12 +40,11 @@ def perform_initial_checks(mail_domain, pre_command=""):
|
|||||||
return res
|
return res
|
||||||
|
|
||||||
|
|
||||||
def get_dkim_entry(mail_domain, pre_command, dkim_selector):
|
def get_dkim_entry(mail_domain, dkim_selector):
|
||||||
try:
|
try:
|
||||||
dkim_pubkey = shell(
|
dkim_pubkey = shell(
|
||||||
f"{pre_command}openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
|
f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
|
||||||
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'",
|
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
|
||||||
print=log_progress
|
|
||||||
)
|
)
|
||||||
except CalledProcessError:
|
except CalledProcessError:
|
||||||
return
|
return
|
||||||
@@ -62,7 +61,7 @@ def query_dns(typ, domain):
|
|||||||
# Get autoritative nameserver from the SOA record.
|
# Get autoritative nameserver from the SOA record.
|
||||||
soa_answers = [
|
soa_answers = [
|
||||||
x.split()
|
x.split()
|
||||||
for x in shell(f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress).split(
|
for x in shell(f"dig -r -q {domain} -t SOA +noall +authority +answer").split(
|
||||||
"\n"
|
"\n"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -72,13 +71,13 @@ def query_dns(typ, domain):
|
|||||||
ns = soa[0][4]
|
ns = soa[0][4]
|
||||||
|
|
||||||
# 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")
|
||||||
if res:
|
if res:
|
||||||
return res.split("\n")[0]
|
return res.split("\n")[0]
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def check_zonefile(zonefile, verbose=True):
|
def check_zonefile(zonefile, mail_domain):
|
||||||
"""Check expected zone file entries."""
|
"""Check expected zone file entries."""
|
||||||
required = True
|
required = True
|
||||||
required_diff = []
|
required_diff = []
|
||||||
@@ -90,7 +89,7 @@ def check_zonefile(zonefile, verbose=True):
|
|||||||
continue
|
continue
|
||||||
if not zf_line.strip() or zf_line.startswith(";"):
|
if not zf_line.strip() or zf_line.startswith(";"):
|
||||||
continue
|
continue
|
||||||
print(f"dns-checking {zf_line!r}") if verbose else log_progress("")
|
print(f"dns-checking {zf_line!r}")
|
||||||
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
|
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
|
||||||
zf_domain = zf_domain.rstrip(".")
|
zf_domain = zf_domain.rstrip(".")
|
||||||
zf_value = zf_value.strip()
|
zf_value = zf_value.strip()
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import sys
|
|
||||||
|
|
||||||
from subprocess import DEVNULL, CalledProcessError, check_output
|
from subprocess import DEVNULL, CalledProcessError, check_output
|
||||||
|
|
||||||
|
|
||||||
def log_progress(data):
|
def shell(command, fail_ok=False):
|
||||||
sys.stderr.write(".")
|
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def shell(command, fail_ok=False, print=print):
|
|
||||||
print(f"$ {command}")
|
print(f"$ {command}")
|
||||||
args = dict(shell=True)
|
args = dict(shell=True)
|
||||||
if fail_ok:
|
if fail_ok:
|
||||||
@@ -21,20 +14,6 @@ def shell(command, fail_ok=False, print=print):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def get_port_service(port: int) -> str:
|
|
||||||
return shell(
|
|
||||||
"ss -lptn 'src :%d' | awk 'NR>1 {print $6,$7}' | sed 's/users:((\"//;s/\".*//'"
|
|
||||||
% (port,)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def chatmail_version():
|
|
||||||
version = shell("cat /etc/chatmail-version")
|
|
||||||
if "cat: /etc/chatmail-version:" in version:
|
|
||||||
version = None
|
|
||||||
return version
|
|
||||||
|
|
||||||
|
|
||||||
def get_systemd_running():
|
def get_systemd_running():
|
||||||
lines = shell("systemctl --type=service --state=running").split("\n")
|
lines = shell("systemctl --type=service --state=running").split("\n")
|
||||||
return [line for line in lines if line.startswith(" ")]
|
return [line for line in lines if line.startswith(" ")]
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ def bootstrap_remote(gateway, remote=remote):
|
|||||||
|
|
||||||
def print_stderr(item="", end="\n"):
|
def print_stderr(item="", end="\n"):
|
||||||
print(item, file=sys.stderr, end=end)
|
print(item, file=sys.stderr, end=end)
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
|
|
||||||
class SSHExec:
|
class SSHExec:
|
||||||
@@ -71,6 +70,10 @@ class SSHExec:
|
|||||||
raise self.FuncError(data)
|
raise self.FuncError(data)
|
||||||
|
|
||||||
def logged(self, call, kwargs):
|
def logged(self, call, kwargs):
|
||||||
|
def log_progress(data):
|
||||||
|
sys.stderr.write(".")
|
||||||
|
sys.stderr.flush()
|
||||||
|
|
||||||
title = call.__doc__
|
title = call.__doc__
|
||||||
if not title:
|
if not title:
|
||||||
title = call.__name__
|
title = call.__name__
|
||||||
@@ -79,6 +82,6 @@ class SSHExec:
|
|||||||
return self(call, kwargs, log_callback=print_stderr)
|
return self(call, kwargs, log_callback=print_stderr)
|
||||||
else:
|
else:
|
||||||
print_stderr(title, end="")
|
print_stderr(title, end="")
|
||||||
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
|
res = self(call, kwargs, log_callback=log_progress)
|
||||||
print_stderr()
|
print_stderr()
|
||||||
return res
|
return res
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class TestSSHExecutor:
|
|||||||
)
|
)
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert err.startswith("Collecting")
|
assert err.startswith("Collecting")
|
||||||
#assert err.endswith("....\n")
|
assert err.endswith("....\n")
|
||||||
assert err.count("\n") == 1
|
assert err.count("\n") == 1
|
||||||
|
|
||||||
sshexec.verbose = True
|
sshexec.verbose = True
|
||||||
@@ -40,7 +40,7 @@ class TestSSHExecutor:
|
|||||||
)
|
)
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
lines = err.split("\n")
|
lines = err.split("\n")
|
||||||
#assert len(lines) > 4
|
assert len(lines) > 4
|
||||||
assert remote.rdns.perform_initial_checks.__doc__ in lines[0]
|
assert remote.rdns.perform_initial_checks.__doc__ in lines[0]
|
||||||
|
|
||||||
def test_exception(self, sshexec, capsys):
|
def test_exception(self, sshexec, capsys):
|
||||||
|
|||||||
@@ -89,14 +89,18 @@ class TestZonefileChecks:
|
|||||||
def test_check_zonefile_all_ok(self, cm_data, mockdns_base):
|
def test_check_zonefile_all_ok(self, cm_data, mockdns_base):
|
||||||
zonefile = cm_data.get("zftest.zone")
|
zonefile = cm_data.get("zftest.zone")
|
||||||
parse_zonefile_into_dict(zonefile, mockdns_base)
|
parse_zonefile_into_dict(zonefile, mockdns_base)
|
||||||
required_diff, recommended_diff = remote.rdns.check_zonefile(zonefile)
|
required_diff, recommended_diff = remote.rdns.check_zonefile(
|
||||||
|
zonefile, "some.domain"
|
||||||
|
)
|
||||||
assert not required_diff and not recommended_diff
|
assert not required_diff and not recommended_diff
|
||||||
|
|
||||||
def test_check_zonefile_recommended_not_set(self, cm_data, mockdns_base):
|
def test_check_zonefile_recommended_not_set(self, cm_data, mockdns_base):
|
||||||
zonefile = cm_data.get("zftest.zone")
|
zonefile = cm_data.get("zftest.zone")
|
||||||
zonefile_mocked = zonefile.split("; Recommended")[0]
|
zonefile_mocked = zonefile.split("; Recommended")[0]
|
||||||
parse_zonefile_into_dict(zonefile_mocked, mockdns_base)
|
parse_zonefile_into_dict(zonefile_mocked, mockdns_base)
|
||||||
required_diff, recommended_diff = remote.rdns.check_zonefile(zonefile)
|
required_diff, recommended_diff = remote.rdns.check_zonefile(
|
||||||
|
zonefile, "some.domain"
|
||||||
|
)
|
||||||
assert not required_diff
|
assert not required_diff
|
||||||
assert len(recommended_diff) == 8
|
assert len(recommended_diff) == 8
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ for Delta Chat users. For details how it avoids storing personal information
|
|||||||
please see our [privacy policy](privacy.html).
|
please see our [privacy policy](privacy.html).
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not config.invite_token %}
|
||||||
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>
|
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>
|
||||||
|
|
||||||
If you are viewing this page on a different device
|
If you are viewing this page on a different device
|
||||||
@@ -23,6 +24,10 @@ you can also **scan this QR code** with Delta Chat:
|
|||||||
🐣 **Choose** your Avatar and Name
|
🐣 **Choose** your Avatar and Name
|
||||||
|
|
||||||
💬 **Start** chatting with any Delta Chat contacts using [QR invite codes](https://delta.chat/en/help#howtoe2ee)
|
💬 **Start** chatting with any Delta Chat contacts using [QR invite codes](https://delta.chat/en/help#howtoe2ee)
|
||||||
|
{% else %}
|
||||||
|
**To join this instance, you need an invite link or QR code -
|
||||||
|
ask the admin for an invite.**
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if config.mail_domain != "nine.testrun.org" %}
|
{% if config.mail_domain != "nine.testrun.org" %}
|
||||||
<div class="experimental">Note: this is only a temporary development chatmail service</div>
|
<div class="experimental">Note: this is only a temporary development chatmail service</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user