Compare commits

..

2 Commits

Author SHA1 Message Date
cliffmccarthy
614b955351 chore: Add changelog entry 2025-08-16 10:07:08 +02:00
cliffmccarthy
7b1ffc1410 feat: Automate file ownership setting from host migration process
- Added a step to deploy_chatmail() that sets ownership of paths that
  are copied over as part of the upgrade process.
- Removed manual chown step from README.md.
2025-08-16 10:07:08 +02:00
25 changed files with 202 additions and 518 deletions

View File

@@ -1,5 +1,5 @@
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
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.

View File

@@ -70,6 +70,9 @@ jobs:
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true
- name: run formatting checks
run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy

View File

@@ -70,6 +70,9 @@ jobs:
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
- name: run formatting checks
run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy

View File

@@ -1,50 +0,0 @@
This diagram shows components of the chatmail server; this is a draft
overview as of mid-August 2025:
```mermaid
graph LR;
cmdeploy --- sshd;
letsencrypt --- |80|acmetool-redirector;
acmetool-redirector --- |443|nginx-right(["`nginx
(external)`"]);
nginx-external --- |465|postfix;
nginx-external(["`nginx
(external)`"]) --- |8443|nginx-internal["`nginx
(internal)`"];
nginx-internal --- website["`Website
/var/www/html`"];
nginx-internal --- newemail.py;
nginx-internal --- autoconfig.xml;
certs-nginx[("`TLS certs
/var/lib/acme`")] --> nginx-internal;
cron --- chatmail-metrics;
cron --- acmetool;
cron --- expunge;
chatmail-metrics --- website;
acmetool --> certs[("`TLS certs
/var/lib/acme`")];
nginx-external --- |993|dovecot;
autoconfig.xml --- postfix;
autoconfig.xml --- dovecot;
postfix --- echobot;
postfix --- |10080,10081|filtermail;
postfix --- users["`User data
home/vmail/mail`"];
postfix --- |doveauth.socket|doveauth;
dovecot --- |doveauth.socket|doveauth;
dovecot --- users;
dovecot --- |metadata.socket|chatmail-metadata;
doveauth --- users;
expunge --- users;
chatmail-metadata --- iroh-relay;
certs-nginx --> postfix;
certs-nginx --> dovecot;
style certs fill:#ff6;
style certs-nginx fill:#ff6;
style nginx-external fill:#fc9;
style nginx-right fill:#fc9;
```
The edges in this graph should not be taken too literally; they
reflect some sort of communication path or dependency relationship
between components of the chatmail server.

View File

@@ -2,32 +2,12 @@
## untagged
- cmdeploy: make --ssh-host work with localhost
([#659](https://github.com/chatmail/relay/pull/659))
- Update iroh-relay to 0.35.0
([#650](https://github.com/chatmail/relay/pull/650))
- 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
- Make www upload path configurable
([#618](https://github.com/chatmail/relay/pull/618))
- Check whether GCC is installed in initenv.sh
([#608](https://github.com/chatmail/relay/pull/608))
- Automate file ownership setting from host migration process
([#609](https://github.com/chatmail/relay/pull/609))
- Expire push notification tokens after 90 days
([#583](https://github.com/chatmail/relay/pull/583))
@@ -52,9 +32,6 @@
- filtermail: respect config message size limit
([#572](https://github.com/chatmail/relay/pull/572))
- Don't deploy if one of the ports used for chatmail relay services is occupied by an unexpected process
([#568](https://github.com/chatmail/relay/pull/568))
- Add config value after how many days large files are deleted
([#555](https://github.com/chatmail/relay/pull/555))

108
README.md
View File

@@ -255,18 +255,6 @@ This starts a local live development cycle for chatmail web pages:
- Starts a browser window automatically where you can "refresh" as needed.
#### Custom web pages
You can skip uploading a web page
by setting `www_folder=disabled` in `chatmail.ini`.
If you want to manage your web pages outside this git repository,
you can set `www_folder` in `chatmail.ini` to a custom directory on your computer.
`cmdeploy run` will upload it as the server's home page,
and if it contains a `src/index.md` file,
will build it with hugo.
## Mailbox directory layout
Fresh chatmail addresses have a mailbox directory that contains:
@@ -407,15 +395,6 @@ in this case, just run `ssh-keygen -R "mail.example.org"` as recommended.
Postfix and Dovecot are disabled for now; we will enable them later.
We first need to make the new site fully operational.
3. On the new site, run the following to ensure the ownership is correct in case UIDs/GIDs changed:
```
chown root: -R /var/lib/acme
chown opendkim: -R /etc/dkimkeys
chown vmail: -R /home/vmail/mail
chown echobot: -R /run/echobot
```
4. Now, update DNS entries.
If other MTAs try to deliver messages to your chatmail domain they may fail intermittently,
@@ -456,15 +435,94 @@ to send messages outside.
To setup a reverse proxy
(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,
you can add its IP address to the DNS,
or distribute it as you wish.
you can add its IP address to the DNS.
## Neighbors and Acquaintances

View File

@@ -33,7 +33,6 @@ class Config:
self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.filtermail_smtp_port_incoming = int(
params["filtermail_smtp_port_incoming"]

View File

@@ -197,13 +197,11 @@ class HackedController(Controller):
class SMTPDiscardRCPTO_options(SMTP):
def _getparams(self, params):
# Ignore RCPT TO parameters.
#
# Otherwise parameters such as `ORCPT=...`
# or `NOTIFY=DELAY,FAILURE` (generated by Stalwart)
# make aiosmtpd reject the message here:
# <https://github.com/aio-libs/aiosmtpd/blob/98f578389ae86e5345cc343fa4e5a17b21d9c96d/aiosmtpd/smtp.py#L1379-L1384>
return {}
# aiosmtpd's SMTP daemon fails to handle a request if there are RCPT TO options
# We just ignore them for our incoming filtermail purposes
if len(params) == 1 and params[0].startswith("ORCPT"):
return {}
return super()._getparams(params)
class OutgoingBeforeQueueHandler:

View File

@@ -15,7 +15,7 @@ ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
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(
secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3)

View File

@@ -11,9 +11,9 @@ from io import StringIO
from pathlib import Path
from chatmaild.config import Config, read_config
from pyinfra import facts, host, logger
from pyinfra import facts, host
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.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd
@@ -555,12 +555,12 @@ def deploy_mtail(config):
def deploy_iroh_relay(config) -> None:
(url, sha256sum) = {
"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",
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
"https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-x86_64-unknown-linux-musl.tar.gz",
"2ffacf7c0622c26b67a5895ee8e07388769599f60e5f52a3bd40a3258db89b2c",
),
"aarch64": (
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-aarch64-unknown-linux-musl.tar.gz",
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
"https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-aarch64-unknown-linux-musl.tar.gz",
"b915037bcc1ff1110cc9fcb5de4a17c00ff576fd2f568cd339b3b2d54c420dc4",
),
}[host.get_fact(facts.server.Arch)]
@@ -569,18 +569,15 @@ def deploy_iroh_relay(config) -> None:
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")
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
need_restart = False
systemd_unit = files.put(
name="Upload iroh-relay systemd unit",
@@ -621,7 +618,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
check_config(config)
mail_domain = config.mail_domain
from .www import build_webpages, get_paths
from .www import build_webpages
server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
@@ -678,32 +675,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
# to use 127.0.0.1 as the resolver.
from cmdeploy.cmdeploy import Out
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
("acmetool", 80),
(["imap-login", "dovecot"], 143),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
if running_service:
if running_service not in service:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
process_on_53 = host.get_fact(Port, port=53)
if process_on_53 not in (None, "unbound"):
Out().red(f"Can't install unbound: port 53 is occupied by: {process_on_53}")
exit(1)
apt.packages(
name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"],
@@ -756,16 +731,12 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
packages=["fcgiwrap"],
)
www_path, src_dir, build_dir = get_paths(config)
# if www_folder was set to a non-existing folder, skip upload
if not www_path.is_dir():
logger.warning("Building web pages is disabled in chatmail.ini, skipping")
else:
# if www_folder is a hugo page, build it
if build_dir:
www_path = build_webpages(src_dir, build_dir, config)
# if it is not a hugo page, upload it as is
files.rsync(f"{www_path}/", "/var/www/html", flags=["-avz"])
www_path = importlib.resources.files(__package__).joinpath("../../../www").resolve()
build_dir = www_path.joinpath("build")
src_dir = www_path.joinpath("src")
build_webpages(src_dir, build_dir, config)
files.rsync(f"{build_dir}/", "/var/www/html", flags=["-avz"])
_install_remote_venv_with_chatmaild(config)
debug = False
@@ -777,6 +748,20 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
_remove_rspamd()
opendkim_need_restart = _configure_opendkim(mail_domain, "opendkim")
#
# If this system is pre-populated with data from a previous instance,
# we might need to adjust ownership of files.
#
stateful_paths = {
"/etc/dkimkeys": "opendkim",
"/home/vmail/mail": "vmail",
"/run/echobot": "echobot",
"/var/lib/acme": "root",
}
for stateful_path, path_owner in stateful_paths.items():
files.directory(stateful_path) # In case it doesn't exist yet.
server.shell("chown {}: -R {}".format(path_owner, stateful_path))
systemd.service(
name="Start and enable OpenDKIM",
service="opendkim.service",
@@ -813,12 +798,6 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
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.
# https://wiki.debian.org/EtcMailName
server.shell(

View File

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

View File

@@ -61,15 +61,14 @@ def run_cmd_options(parser):
parser.add_argument(
"--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):
"""Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host)
sshexec = args.get_sshexec()
require_iroh = args.config.enable_iroh_relay
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
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 ""
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
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"
if ssh_host == "localhost":
cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"):
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
return 1
@@ -93,14 +89,6 @@ def run_cmd(args, out):
try:
retcode = out.check_call(cmd, env=env)
if retcode == 0:
print("\nYou can try out the relay by talking to this echo bot: ")
sshexec = SSHExec(args.config.mail_domain, verbose=args.verbose)
print(
sshexec(
call=remote.rshell.shell,
kwargs=dict(command="cat /var/lib/echobot/invite-link.txt"),
)
)
out.green("Deploy completed, call `cmdeploy dns` next.")
elif not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
@@ -122,17 +110,11 @@ def dns_cmd_options(parser):
default=None,
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):
"""Check DNS entries and optionally generate dns zone file."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
sshexec = args.get_sshexec()
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not remote_data:
return 1
@@ -208,61 +190,6 @@ def test_cmd(args, out):
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):
parser.add_argument(
"--check",
@@ -396,14 +323,6 @@ def get_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):
"""Provide main entry point for 'cmdeploy' CLI invocation."""
parser = get_parser()
@@ -411,6 +330,12 @@ def main(args=None):
if not hasattr(args, "func"):
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()
kwargs = {}
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):

View File

@@ -7,13 +7,9 @@ from . import remote
def get_initial_remote_data(sshexec, mail_domain):
if sshexec == "localhost":
result = remote.rdns.perform_initial_checks(mail_domain)
else:
result = sshexec.logged(
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
)
return result
return sshexec.logged(
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
)
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
and return (exitcode, remote_data) tuple."""
if sshexec == "localhost":
required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile=zonefile, verbose=False
)
else:
required_diff, recommended_diff = sshexec.logged(
remote.rdns.check_zonefile, kwargs=dict(zonefile=zonefile, verbose=False),
)
required_diff, recommended_diff = sshexec.logged(
remote.rdns.check_zonefile,
kwargs=dict(zonefile=zonefile, mail_domain=remote_data["mail_domain"]),
)
returncode = 0
if required_diff:

View File

@@ -77,13 +77,13 @@ scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp
# 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 smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean
# 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 smtpd_milters=unix:opendkim/opendkim.sock

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -1,4 +0,0 @@
auto eth0:{{ i }}
iface eth0:{{ i }} inet static
address {{ ip_address }}
netmask 32

View File

@@ -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;
}
}

View File

@@ -12,23 +12,23 @@ All functions of this module
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."""
assert mail_domain
if not shell("dig", fail_ok=True, print=log_progress):
shell("apt-get update && apt-get install -y dnsutils", print=log_progress)
if not shell("dig", fail_ok=True):
shell("apt-get update && apt-get install -y dnsutils")
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)
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(
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):
@@ -40,12 +40,11 @@ def perform_initial_checks(mail_domain, pre_command=""):
return res
def get_dkim_entry(mail_domain, pre_command, dkim_selector):
def get_dkim_entry(mail_domain, dkim_selector):
try:
dkim_pubkey = shell(
f"{pre_command}openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'",
print=log_progress
f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
)
except CalledProcessError:
return
@@ -62,7 +61,7 @@ def query_dns(typ, domain):
# Get autoritative nameserver from the SOA record.
soa_answers = [
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"
)
]
@@ -72,13 +71,13 @@ def query_dns(typ, domain):
ns = soa[0][4]
# 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:
return res.split("\n")[0]
return ""
def check_zonefile(zonefile, verbose=True):
def check_zonefile(zonefile, mail_domain):
"""Check expected zone file entries."""
required = True
required_diff = []
@@ -90,7 +89,7 @@ def check_zonefile(zonefile, verbose=True):
continue
if not zf_line.strip() or zf_line.startswith(";"):
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_domain.rstrip(".")
zf_value = zf_value.strip()

View File

@@ -1,14 +1,7 @@
import sys
from subprocess import DEVNULL, CalledProcessError, check_output
def log_progress(data):
sys.stderr.write(".")
sys.stderr.flush()
def shell(command, fail_ok=False, print=print):
def shell(command, fail_ok=False):
print(f"$ {command}")
args = dict(shell=True)
if fail_ok:
@@ -21,20 +14,6 @@ def shell(command, fail_ok=False, print=print):
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():
lines = shell("systemctl --type=service --state=running").split("\n")
return [line for line in lines if line.startswith(" ")]

View File

@@ -42,7 +42,6 @@ def bootstrap_remote(gateway, remote=remote):
def print_stderr(item="", end="\n"):
print(item, file=sys.stderr, end=end)
sys.stderr.flush()
class SSHExec:
@@ -71,6 +70,10 @@ class SSHExec:
raise self.FuncError(data)
def logged(self, call, kwargs):
def log_progress(data):
sys.stderr.write(".")
sys.stderr.flush()
title = call.__doc__
if not title:
title = call.__name__
@@ -79,6 +82,6 @@ class SSHExec:
return self(call, kwargs, log_callback=print_stderr)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
res = self(call, kwargs, log_callback=log_progress)
print_stderr()
return res

View File

@@ -31,7 +31,7 @@ class TestSSHExecutor:
)
out, err = capsys.readouterr()
assert err.startswith("Collecting")
#assert err.endswith("....\n")
assert err.endswith("....\n")
assert err.count("\n") == 1
sshexec.verbose = True
@@ -40,7 +40,7 @@ class TestSSHExecutor:
)
out, err = capsys.readouterr()
lines = err.split("\n")
#assert len(lines) > 4
assert len(lines) > 4
assert remote.rdns.perform_initial_checks.__doc__ in lines[0]
def test_exception(self, sshexec, capsys):

View File

@@ -1,10 +1,8 @@
import importlib
import os
import pytest
from cmdeploy.cmdeploy import get_parser, main
from cmdeploy.www import get_paths
@pytest.fixture(autouse=True)
@@ -29,28 +27,3 @@ class TestCmdline:
assert main(["init", "chat.example.org"]) == 1
out, err = capsys.readouterr()
assert "path exists" in out.lower()
def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()
assert not example_config.www_folder
www_path, src_dir, build_dir = get_paths(example_config)
assert www_path.absolute() == reporoot.joinpath("www").absolute()
assert src_dir == reporoot.joinpath("www").joinpath("src")
assert build_dir == reporoot.joinpath("www").joinpath("build")
example_config.www_folder = "disabled"
www_path, _, _ = get_paths(example_config)
assert not www_path.is_dir()
example_config.www_folder = str(tmp_path)
www_path, src_dir, build_dir = get_paths(example_config)
assert www_path == tmp_path
assert not src_dir.exists()
assert not build_dir
src_path = tmp_path.joinpath("src")
os.mkdir(src_path)
with open(src_path / "index.md", "w") as f:
f.write("# Test")
www_path, src_dir, build_dir = get_paths(example_config)
assert www_path == tmp_path
assert src_dir == src_path
assert build_dir == tmp_path.joinpath("build")

View File

@@ -89,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.rdns.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.rdns.check_zonefile(zonefile)
required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile, "some.domain"
)
assert not required_diff
assert len(recommended_diff) == 8

View File

@@ -3,7 +3,6 @@ import importlib.resources
import time
import traceback
import webbrowser
from pathlib import Path
import markdown
from chatmaild.config import read_config
@@ -31,25 +30,9 @@ def prepare_template(source):
return render_vars, page_layout
def get_paths(config) -> (Path, Path, Path):
reporoot = importlib.resources.files(__package__).joinpath("../../../").resolve()
www_path = Path(config.www_folder)
# if www_folder was not set, use default directory
if config.www_folder == "":
www_path = reporoot.joinpath("www")
src_dir = www_path.joinpath("src")
# if www_folder is a hugo page, build it
if src_dir.joinpath("index.md").is_file():
build_dir = www_path.joinpath("build")
# if it is not a hugo page, upload it as is
else:
build_dir = None
return www_path, src_dir, build_dir
def build_webpages(src_dir, build_dir, config) -> Path:
def build_webpages(src_dir, build_dir, config):
try:
return _build_webpages(src_dir, build_dir, config)
_build_webpages(src_dir, build_dir, config)
except Exception:
print(traceback.format_exc())
@@ -123,11 +106,15 @@ def main():
config = read_config(inipath)
config.webdev = True
assert config.mail_domain
www_path = reporoot.joinpath("www")
src_path = www_path.joinpath("src")
stats = None
build_dir = www_path.joinpath("build")
src_dir = www_path.joinpath("src")
index_path = build_dir.joinpath("index.html")
# start web page generation, open a browser and wait for changes
www_path, src_path, build_dir = get_paths(config)
build_dir = build_webpages(src_path, build_dir, config)
index_path = build_dir.joinpath("index.html")
build_webpages(src_dir, build_dir, config)
webbrowser.open(str(index_path))
stats = snapshot_dir_stats(src_path)
print(f"\nOpened URL: file://{index_path.resolve()}\n")
@@ -148,7 +135,7 @@ def main():
changenum += 1
stats = newstats
build_webpages(src_path, build_dir, config)
build_webpages(src_dir, build_dir, config)
print(f"[{changenum}] regenerated web pages at: {index_path}")
print(f"URL: file://{index_path.resolve()}\n\n")
count = 0