mirror of
https://github.com/chatmail/relay.git
synced 2026-06-13 23:21:08 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abd50e20ed | |||
| d6fb38750a | |||
| 3b73457de3 | |||
| ba06a4ff70 | |||
| 7fdaffe829 | |||
| 73831c74d9 | |||
| d8cbe9d6af | |||
| 180ddb8168 | |||
| a1eeea4632 | |||
| a49aa0e655 | |||
| 7e81495b51 | |||
| 6fde062613 | |||
| 84e0376762 | |||
| d690c22c06 | |||
| 5410c1bebc | |||
| 915bd39dd5 | |||
| 2de8b155c2 | |||
| c975aa3bd1 | |||
| 6b73f6933a |
@@ -70,9 +70,6 @@ jobs:
|
|||||||
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
|
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
|
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
|
- name: run deploy-chatmail offline tests
|
||||||
run: pytest --pyargs cmdeploy
|
run: pytest --pyargs cmdeploy
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,6 @@ jobs:
|
|||||||
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
|
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
|
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
|
- name: run deploy-chatmail offline tests
|
||||||
run: pytest --pyargs cmdeploy
|
run: pytest --pyargs cmdeploy
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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.
|
||||||
+5
-2
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
## untagged
|
## untagged
|
||||||
|
|
||||||
- Allow custom nginx config files
|
- Make www upload path configurable
|
||||||
([#617](https://github.com/chatmail/relay/pull/617))
|
([#618](https://github.com/chatmail/relay/pull/618))
|
||||||
|
|
||||||
- Check whether GCC is installed in initenv.sh
|
- Check whether GCC is installed in initenv.sh
|
||||||
([#608](https://github.com/chatmail/relay/pull/608))
|
([#608](https://github.com/chatmail/relay/pull/608))
|
||||||
@@ -32,6 +32,9 @@
|
|||||||
- filtermail: respect config message size limit
|
- filtermail: respect config message size limit
|
||||||
([#572](https://github.com/chatmail/relay/pull/572))
|
([#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
|
- Add config value after how many days large files are deleted
|
||||||
([#555](https://github.com/chatmail/relay/pull/555))
|
([#555](https://github.com/chatmail/relay/pull/555))
|
||||||
|
|
||||||
|
|||||||
@@ -257,15 +257,14 @@ This starts a local live development cycle for chatmail web pages:
|
|||||||
|
|
||||||
#### Custom web pages
|
#### Custom web pages
|
||||||
|
|
||||||
If you want to include other pages,
|
You can skip uploading a web page
|
||||||
they need their separate nginx config
|
by setting `www_folder=disabled` in `chatmail.ini`.
|
||||||
under `/etc/nginx/sites-enabled/`.
|
|
||||||
Note that they need to listen on port 8443 instead of 443.
|
|
||||||
|
|
||||||
To request TLS certificates for the corresponding domains,
|
If you want to manage your web pages outside this git repository,
|
||||||
point the DNS records to your Server and run `acmetool want <domain>`.
|
you can set `www_folder` in `chatmail.ini` to a custom directory on your computer.
|
||||||
You can find the TLS certificates under `/var/lib/acme/live`.
|
`cmdeploy run` will upload it as the server's home page,
|
||||||
They will be automatically renewed.
|
and if it contains a `src/index.md` file,
|
||||||
|
will build it with hugo.
|
||||||
|
|
||||||
|
|
||||||
## Mailbox directory layout
|
## Mailbox directory layout
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class Config:
|
|||||||
self.password_min_length = int(params["password_min_length"])
|
self.password_min_length = int(params["password_min_length"])
|
||||||
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.filtermail_smtp_port = int(params["filtermail_smtp_port"])
|
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
|
||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
params["filtermail_smtp_port_incoming"]
|
params["filtermail_smtp_port_incoming"]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from io import StringIO
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from chatmaild.config import Config, read_config
|
from chatmaild.config import Config, read_config
|
||||||
from pyinfra import facts, host
|
from pyinfra import facts, host, logger
|
||||||
from pyinfra.api import FactBase
|
from pyinfra.api import FactBase
|
||||||
from pyinfra.facts.files import File
|
from pyinfra.facts.files import File
|
||||||
from pyinfra.facts.server import Sysctl
|
from pyinfra.facts.server import Sysctl
|
||||||
@@ -424,12 +424,6 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
|
|||||||
"""Configures nginx HTTP server."""
|
"""Configures nginx HTTP server."""
|
||||||
need_restart = False
|
need_restart = False
|
||||||
|
|
||||||
files.link(
|
|
||||||
name="disable nginx default site",
|
|
||||||
path="/etc/nginx/sites-enabled/default",
|
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
main_config = files.template(
|
main_config = files.template(
|
||||||
src=importlib.resources.files(__package__).joinpath("nginx/nginx.conf.j2"),
|
src=importlib.resources.files(__package__).joinpath("nginx/nginx.conf.j2"),
|
||||||
dest="/etc/nginx/nginx.conf",
|
dest="/etc/nginx/nginx.conf",
|
||||||
@@ -624,7 +618,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
|
|||||||
check_config(config)
|
check_config(config)
|
||||||
mail_domain = config.mail_domain
|
mail_domain = config.mail_domain
|
||||||
|
|
||||||
from .www import build_webpages
|
from .www import build_webpages, get_paths
|
||||||
|
|
||||||
server.group(name="Create vmail group", group="vmail", system=True)
|
server.group(name="Create vmail group", group="vmail", system=True)
|
||||||
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
|
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
|
||||||
@@ -681,10 +675,30 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
|
|||||||
# to use 127.0.0.1 as the resolver.
|
# to use 127.0.0.1 as the resolver.
|
||||||
from cmdeploy.cmdeploy import Out
|
from cmdeploy.cmdeploy import Out
|
||||||
|
|
||||||
process_on_53 = host.get_fact(Port, port=53)
|
port_services = [
|
||||||
if process_on_53 not in (None, "unbound"):
|
(["master", "smtpd"], 25),
|
||||||
Out().red(f"Can't install unbound: port 53 is occupied by: {process_on_53}")
|
("unbound", 53),
|
||||||
exit(1)
|
("acmetool", 80),
|
||||||
|
("imap-login", 143),
|
||||||
|
("nginx", 443),
|
||||||
|
(["master", "smtpd"], 465),
|
||||||
|
(["master", "smtpd"], 587),
|
||||||
|
("imap-login", 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)
|
||||||
|
|
||||||
apt.packages(
|
apt.packages(
|
||||||
name="Install unbound",
|
name="Install unbound",
|
||||||
packages=["unbound", "unbound-anchor", "dnsutils"],
|
packages=["unbound", "unbound-anchor", "dnsutils"],
|
||||||
@@ -737,12 +751,16 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
|
|||||||
packages=["fcgiwrap"],
|
packages=["fcgiwrap"],
|
||||||
)
|
)
|
||||||
|
|
||||||
www_path = importlib.resources.files(__package__).joinpath("../../../www").resolve()
|
www_path, src_dir, build_dir = get_paths(config)
|
||||||
|
# if www_folder was set to a non-existing folder, skip upload
|
||||||
build_dir = www_path.joinpath("build")
|
if not www_path.is_dir():
|
||||||
src_dir = www_path.joinpath("src")
|
logger.warning("Building web pages is disabled in chatmail.ini, skipping")
|
||||||
build_webpages(src_dir, build_dir, config)
|
else:
|
||||||
files.rsync(f"{build_dir}/", "/var/www/html", flags=["-avz"])
|
# 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"])
|
||||||
|
|
||||||
_install_remote_venv_with_chatmaild(config)
|
_install_remote_venv_with_chatmaild(config)
|
||||||
debug = False
|
debug = False
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import importlib.resources
|
import importlib.resources
|
||||||
|
|
||||||
from pyinfra import host
|
|
||||||
from pyinfra.facts.systemd import SystemdStatus
|
|
||||||
from pyinfra.operations import apt, files, server, systemd
|
from pyinfra.operations import apt, files, server, systemd
|
||||||
|
|
||||||
|
|
||||||
@@ -54,12 +52,6 @@ def deploy_acmetool(email="", domains=[]):
|
|||||||
group="root",
|
group="root",
|
||||||
mode="644",
|
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(
|
systemd.service(
|
||||||
name="Setup acmetool-redirector service",
|
name="Setup acmetool-redirector service",
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ def run_cmd(args, out):
|
|||||||
try:
|
try:
|
||||||
retcode = out.check_call(cmd, env=env)
|
retcode = out.check_call(cmd, env=env)
|
||||||
if retcode == 0:
|
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.")
|
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||||
elif not remote_data["acme_account_url"]:
|
elif not remote_data["acme_account_url"]:
|
||||||
out.red("Deploy completed but letsencrypt not configured")
|
out.red("Deploy completed but letsencrypt not configured")
|
||||||
|
|||||||
@@ -136,7 +136,4 @@ http {
|
|||||||
return 301 $scheme://{{ config.domain_name }}$request_uri;
|
return 301 $scheme://{{ config.domain_name }}$request_uri;
|
||||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Include custom pages; they need to listen on port 8443 instead of port 443
|
|
||||||
include /etc/nginx/sites-enabled/*;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import importlib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from cmdeploy.cmdeploy import get_parser, main
|
from cmdeploy.cmdeploy import get_parser, main
|
||||||
|
from cmdeploy.www import get_paths
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -27,3 +29,28 @@ class TestCmdline:
|
|||||||
assert main(["init", "chat.example.org"]) == 1
|
assert main(["init", "chat.example.org"]) == 1
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "path exists" in out.lower()
|
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")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import importlib.resources
|
|||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import markdown
|
import markdown
|
||||||
from chatmaild.config import read_config
|
from chatmaild.config import read_config
|
||||||
@@ -30,9 +31,25 @@ def prepare_template(source):
|
|||||||
return render_vars, page_layout
|
return render_vars, page_layout
|
||||||
|
|
||||||
|
|
||||||
def build_webpages(src_dir, build_dir, config):
|
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:
|
||||||
try:
|
try:
|
||||||
_build_webpages(src_dir, build_dir, config)
|
return _build_webpages(src_dir, build_dir, config)
|
||||||
except Exception:
|
except Exception:
|
||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
|
|
||||||
@@ -106,15 +123,11 @@ def main():
|
|||||||
config = read_config(inipath)
|
config = read_config(inipath)
|
||||||
config.webdev = True
|
config.webdev = True
|
||||||
assert config.mail_domain
|
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
|
# start web page generation, open a browser and wait for changes
|
||||||
build_webpages(src_dir, build_dir, config)
|
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")
|
||||||
webbrowser.open(str(index_path))
|
webbrowser.open(str(index_path))
|
||||||
stats = snapshot_dir_stats(src_path)
|
stats = snapshot_dir_stats(src_path)
|
||||||
print(f"\nOpened URL: file://{index_path.resolve()}\n")
|
print(f"\nOpened URL: file://{index_path.resolve()}\n")
|
||||||
@@ -135,7 +148,7 @@ def main():
|
|||||||
changenum += 1
|
changenum += 1
|
||||||
|
|
||||||
stats = newstats
|
stats = newstats
|
||||||
build_webpages(src_dir, build_dir, config)
|
build_webpages(src_path, build_dir, config)
|
||||||
print(f"[{changenum}] regenerated web pages at: {index_path}")
|
print(f"[{changenum}] regenerated web pages at: {index_path}")
|
||||||
print(f"URL: file://{index_path.resolve()}\n\n")
|
print(f"URL: file://{index_path.resolve()}\n\n")
|
||||||
count = 0
|
count = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user