Compare commits

..

1 Commits

Author SHA1 Message Date
missytake
1493acb87b cmdeploy: run SSH commands locally if ssh_host is localhost 2025-08-20 18:17:59 +02:00
12 changed files with 82 additions and 187 deletions

View File

@@ -70,6 +70,9 @@ 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

View File

@@ -70,6 +70,9 @@ 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

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,9 +2,6 @@
## untagged ## untagged
- Make www upload path configurable
([#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,9 +29,6 @@
- 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))

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. - 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 ## Mailbox directory layout
Fresh chatmail addresses have a mailbox directory that contains: Fresh chatmail addresses have a mailbox directory that contains:

View File

@@ -33,7 +33,6 @@ 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"]

View File

@@ -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, logger from pyinfra import facts, host
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
@@ -618,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, get_paths from .www import build_webpages
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)
@@ -675,30 +675,10 @@ 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
port_services = [ process_on_53 = host.get_fact(Port, port=53)
(["master", "smtpd"], 25), if process_on_53 not in (None, "unbound"):
("unbound", 53), Out().red(f"Can't install unbound: port 53 is occupied by: {process_on_53}")
("acmetool", 80), exit(1)
("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"],
@@ -751,16 +731,12 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
packages=["fcgiwrap"], packages=["fcgiwrap"],
) )
www_path, src_dir, build_dir = get_paths(config) www_path = importlib.resources.files(__package__).joinpath("../../../www").resolve()
# if www_folder was set to a non-existing folder, skip upload
if not www_path.is_dir(): build_dir = www_path.joinpath("build")
logger.warning("Building web pages is disabled in chatmail.ini, skipping") src_dir = www_path.joinpath("src")
else: build_webpages(src_dir, build_dir, config)
# if www_folder is a hugo page, build it files.rsync(f"{build_dir}/", "/var/www/html", flags=["-avz"])
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

View File

@@ -1,5 +1,7 @@
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
@@ -52,6 +54,12 @@ 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",

View File

@@ -19,7 +19,7 @@ from packaging import version
from termcolor import colored from termcolor import colored
from . import dns, remote from . import dns, remote
from .sshexec import SSHExec from .sshexec import SSHExec, Local
# #
# cmdeploy sub commands and options # cmdeploy sub commands and options
@@ -62,13 +62,18 @@ def run_cmd_options(parser):
"--ssh-host", "--ssh-host",
dest="ssh_host", dest="ssh_host",
help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default", help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default",
default=None,
) )
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
sshexec = args.get_sshexec() ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
if ssh_host == "localhost":
sshexec = Local(ssh_host)
else:
sshexec = args.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):
@@ -80,7 +85,7 @@ 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 ssh_host = "@local" if ssh_host == "localhost" else f"--ssh-host {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 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.")
@@ -89,14 +94,6 @@ 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")
@@ -338,9 +335,9 @@ 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(): def get_sshexec(host):
print(f"[ssh] login to {args.config.mail_domain}") print(f"[ssh] login to {host}")
return SSHExec(args.config.mail_domain, verbose=args.verbose) return SSHExec(host, verbose=args.verbose)
args.get_sshexec = get_sshexec args.get_sshexec = get_sshexec

View File

@@ -1,5 +1,6 @@
import inspect import inspect
import os import os
import subprocess
import sys import sys
from queue import Queue from queue import Queue
@@ -44,30 +45,16 @@ def print_stderr(item="", end="\n"):
print(item, file=sys.stderr, end=end) print(item, file=sys.stderr, end=end)
class SSHExec: class Exec:
RemoteError = execnet.RemoteError
FuncError = FuncError FuncError = FuncError
def __init__(self, host, verbose=False, python="python3", timeout=60): def __init__(self, host, verbose, timeout):
self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}") self.host = host
self._remote_cmdloop_channel = bootstrap_remote(self.gateway, remote)
self.timeout = timeout self.timeout = timeout
self.verbose = verbose self.verbose = verbose
def __call__(self, call, kwargs=None, log_callback=None): def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None: return subprocess.check_output(call)
kwargs = {}
assert call.__module__.startswith("cmdeploy.remote")
modname = call.__module__.replace("cmdeploy.", "")
self._remote_cmdloop_channel.send((modname, call.__name__, kwargs))
while 1:
code, data = self._remote_cmdloop_channel.receive(timeout=self.timeout)
if log_callback is not None and code == "log":
log_callback(data)
elif code == "finish":
return data
elif code == "error":
raise self.FuncError(data)
def logged(self, call, kwargs): def logged(self, call, kwargs):
def log_progress(data): def log_progress(data):
@@ -85,3 +72,33 @@ class SSHExec:
res = self(call, kwargs, log_callback=log_progress) res = self(call, kwargs, log_callback=log_progress)
print_stderr() print_stderr()
return res return res
class Local(Exec):
def __init__(self, host, verbose=False, timeout=60):
super().__init__(host, verbose, timeout)
class SSHExec(Exec):
RemoteError = execnet.RemoteError
def __init__(self, host, verbose=False, timeout=60):
super().__init__(host, verbose, timeout)
self.gateway = execnet.makegateway(f"ssh=root@{host}//python=python3")
self._remote_cmdloop_channel = bootstrap_remote(self.gateway, remote)
def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None:
kwargs = {}
assert call.__module__.startswith("cmdeploy.remote")
modname = call.__module__.replace("cmdeploy.", "")
self._remote_cmdloop_channel.send((modname, call.__name__, kwargs))
while 1:
code, data = self._remote_cmdloop_channel.receive(timeout=self.timeout)
if log_callback is not None and code == "log":
log_callback(data)
elif code == "finish":
return data
elif code == "error":
raise self.FuncError(data)

View File

@@ -1,10 +1,8 @@
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)
@@ -29,28 +27,3 @@ 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")

View File

@@ -3,7 +3,6 @@ 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
@@ -31,25 +30,9 @@ def prepare_template(source):
return render_vars, page_layout return render_vars, page_layout
def get_paths(config) -> (Path, Path, Path): def build_webpages(src_dir, build_dir, config):
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:
return _build_webpages(src_dir, build_dir, config) _build_webpages(src_dir, build_dir, config)
except Exception: except Exception:
print(traceback.format_exc()) print(traceback.format_exc())
@@ -123,11 +106,15 @@ 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
www_path, src_path, build_dir = get_paths(config) build_webpages(src_dir, build_dir, 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")
@@ -148,7 +135,7 @@ def main():
changenum += 1 changenum += 1
stats = newstats 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"[{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