mirror of
https://github.com/chatmail/relay.git
synced 2026-05-19 12:28:06 +00:00
Merge remote-tracking branch 'origin/hpk/tls-external' into j4n/docker-traefik
This commit is contained in:
37
.github/workflows/test-tls-external.yaml
vendored
Normal file
37
.github/workflows/test-tls-external.yaml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: test tls_external_cert_and_key on staging2.testrun.org
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- "deploy on staging2.testrun.org, and run tests"
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-tls-external:
|
||||||
|
name: test tls_external_cert_and_key
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
concurrency: staging2.testrun.org
|
||||||
|
environment:
|
||||||
|
name: staging2.testrun.org
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: prepare SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan staging2.testrun.org >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- run: scripts/initenv.sh
|
||||||
|
|
||||||
|
- name: append venv/bin to PATH
|
||||||
|
run: echo venv/bin >>$GITHUB_PATH
|
||||||
|
|
||||||
|
- name: run tls_external e2e test
|
||||||
|
run: |
|
||||||
|
python -m cmdeploy.tests.setup_tls_external \
|
||||||
|
staging2.testrun.org
|
||||||
@@ -61,10 +61,24 @@ class Config:
|
|||||||
self.privacy_pdo = params.get("privacy_pdo")
|
self.privacy_pdo = params.get("privacy_pdo")
|
||||||
self.privacy_supervisor = params.get("privacy_supervisor")
|
self.privacy_supervisor = params.get("privacy_supervisor")
|
||||||
|
|
||||||
# TLS certificate management: derived from the domain name.
|
# TLS certificate management.
|
||||||
# Domains starting with "_" use self-signed certificates
|
# If tls_external_cert_and_key is set, use externally managed certs.
|
||||||
# All other domains use ACME.
|
# Otherwise derived from the domain name:
|
||||||
if self.mail_domain.startswith("_"):
|
# - Domains starting with "_" use self-signed certificates
|
||||||
|
# - All other domains use ACME.
|
||||||
|
external = params.get("tls_external_cert_and_key", "").strip()
|
||||||
|
|
||||||
|
if external:
|
||||||
|
parts = external.split()
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise ValueError(
|
||||||
|
"tls_external_cert_and_key must have two space-separated"
|
||||||
|
" paths: CERT_PATH KEY_PATH"
|
||||||
|
)
|
||||||
|
self.tls_cert_mode = "external"
|
||||||
|
self.tls_cert_path = parts[0]
|
||||||
|
self.tls_key_path = parts[1]
|
||||||
|
elif self.mail_domain.startswith("_"):
|
||||||
self.tls_cert_mode = "self"
|
self.tls_cert_mode = "self"
|
||||||
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
|
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
|
||||||
self.tls_key_path = "/etc/ssl/private/mailserver.key"
|
self.tls_key_path = "/etc/ssl/private/mailserver.key"
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ passthrough_senders =
|
|||||||
# (space-separated, item may start with "@" to whitelist whole recipient domains)
|
# (space-separated, item may start with "@" to whitelist whole recipient domains)
|
||||||
passthrough_recipients =
|
passthrough_recipients =
|
||||||
|
|
||||||
|
# Use externally managed TLS certificates instead of built-in acmetool.
|
||||||
|
# Paths refer to files on the deployment server (not the build machine).
|
||||||
|
# Both files must already exist before running cmdeploy.
|
||||||
|
# Certificate renewal is your responsibility; changed files are
|
||||||
|
# picked up automatically by all relay services.
|
||||||
|
# tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
|
||||||
|
|
||||||
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
|
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
|
||||||
#www_folder = www
|
#www_folder = www
|
||||||
|
|
||||||
|
|||||||
@@ -87,3 +87,36 @@ def test_config_tls_self(make_config):
|
|||||||
assert config.tls_cert_mode == "self"
|
assert config.tls_cert_mode == "self"
|
||||||
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
|
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
|
||||||
assert config.tls_key_path == "/etc/ssl/private/mailserver.key"
|
assert config.tls_key_path == "/etc/ssl/private/mailserver.key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_tls_external(make_config):
|
||||||
|
config = make_config(
|
||||||
|
"chat.example.org",
|
||||||
|
{
|
||||||
|
"tls_external_cert_and_key": "/custom/fullchain.pem /custom/privkey.pem",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert config.tls_cert_mode == "external"
|
||||||
|
assert config.tls_cert_path == "/custom/fullchain.pem"
|
||||||
|
assert config.tls_key_path == "/custom/privkey.pem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_tls_external_overrides_underscore(make_config):
|
||||||
|
config = make_config(
|
||||||
|
"_test.example.org",
|
||||||
|
{
|
||||||
|
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert config.tls_cert_mode == "external"
|
||||||
|
assert config.tls_cert_path == "/certs/fullchain.pem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_tls_external_bad_format(make_config):
|
||||||
|
with pytest.raises(ValueError, match="two space-separated"):
|
||||||
|
make_config(
|
||||||
|
"chat.example.org",
|
||||||
|
{
|
||||||
|
"tls_external_cert_and_key": "/only/one/path.pem",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from pyinfra.operations import apt, files, pip, server, systemd
|
|||||||
from cmdeploy.cmdeploy import Out
|
from cmdeploy.cmdeploy import Out
|
||||||
|
|
||||||
from .acmetool import AcmetoolDeployer
|
from .acmetool import AcmetoolDeployer
|
||||||
from .selfsigned.deployer import SelfSignedTlsDeployer
|
from .external.deployer import ExternalTlsDeployer
|
||||||
from .basedeploy import (
|
from .basedeploy import (
|
||||||
Deployer,
|
Deployer,
|
||||||
Deployment,
|
Deployment,
|
||||||
@@ -35,6 +35,7 @@ from .mtail.deployer import MtailDeployer
|
|||||||
from .nginx.deployer import NginxDeployer
|
from .nginx.deployer import NginxDeployer
|
||||||
from .opendkim.deployer import OpendkimDeployer
|
from .opendkim.deployer import OpendkimDeployer
|
||||||
from .postfix.deployer import PostfixDeployer
|
from .postfix.deployer import PostfixDeployer
|
||||||
|
from .selfsigned.deployer import SelfSignedTlsDeployer
|
||||||
from .www import build_webpages, find_merge_conflict, get_paths
|
from .www import build_webpages, find_merge_conflict, get_paths
|
||||||
|
|
||||||
|
|
||||||
@@ -540,6 +541,20 @@ class GithashDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tls_deployer(config, mail_domain):
|
||||||
|
"""Select the appropriate TLS deployer based on config."""
|
||||||
|
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
|
||||||
|
|
||||||
|
if config.tls_cert_mode == "acme":
|
||||||
|
return AcmetoolDeployer(config.acme_email, tls_domains)
|
||||||
|
elif config.tls_cert_mode == "self":
|
||||||
|
return SelfSignedTlsDeployer(mail_domain)
|
||||||
|
elif config.tls_cert_mode == "external":
|
||||||
|
return ExternalTlsDeployer(config.tls_cert_path, config.tls_key_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown tls_cert_mode: {config.tls_cert_mode}")
|
||||||
|
|
||||||
|
|
||||||
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
|
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
|
||||||
"""Deploy a chat-mail instance.
|
"""Deploy a chat-mail instance.
|
||||||
|
|
||||||
@@ -604,12 +619,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
)
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
|
tls_deployer = get_tls_deployer(config, mail_domain)
|
||||||
|
|
||||||
if config.tls_cert_mode == "acme":
|
|
||||||
tls_deployer = AcmetoolDeployer(config.acme_email, tls_domains)
|
|
||||||
else:
|
|
||||||
tls_deployer = SelfSignedTlsDeployer(mail_domain)
|
|
||||||
|
|
||||||
all_deployers = [
|
all_deployers = [
|
||||||
ChatmailDeployer(mail_domain),
|
ChatmailDeployer(mail_domain),
|
||||||
|
|||||||
69
cmdeploy/src/cmdeploy/external/deployer.py
vendored
Normal file
69
cmdeploy/src/cmdeploy/external/deployer.py
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
from pyinfra.operations import files, server, systemd
|
||||||
|
|
||||||
|
from cmdeploy.basedeploy import Deployer, get_resource
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalTlsDeployer(Deployer):
|
||||||
|
"""Expects TLS certificates to be managed on the server.
|
||||||
|
|
||||||
|
Validates that the configured certificate and key files
|
||||||
|
exist on the remote host. Installs a systemd path unit
|
||||||
|
that watches the certificate file and automatically
|
||||||
|
restarts/reloads affected services when it changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cert_path, key_path):
|
||||||
|
self.cert_path = cert_path
|
||||||
|
self.key_path = key_path
|
||||||
|
|
||||||
|
def configure(self):
|
||||||
|
server.shell(
|
||||||
|
name="Verify external TLS certificate and key exist",
|
||||||
|
commands=[
|
||||||
|
f"test -f {self.cert_path} && test -f {self.key_path}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Deploy the .path unit (templated with the cert path).
|
||||||
|
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
|
||||||
|
content = source.read_text().format(cert_path=self.cert_path).encode()
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
path_unit = files.put(
|
||||||
|
name="Upload tls-cert-reload.path",
|
||||||
|
src=io.BytesIO(content),
|
||||||
|
dest="/etc/systemd/system/tls-cert-reload.path",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="644",
|
||||||
|
)
|
||||||
|
|
||||||
|
service_unit = files.put(
|
||||||
|
name="Upload tls-cert-reload.service",
|
||||||
|
src=get_resource("tls-cert-reload.service", pkg=__package__),
|
||||||
|
dest="/etc/systemd/system/tls-cert-reload.service",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode="644",
|
||||||
|
)
|
||||||
|
|
||||||
|
if path_unit.changed or service_unit.changed:
|
||||||
|
self.need_restart = True
|
||||||
|
|
||||||
|
def activate(self):
|
||||||
|
systemd.service(
|
||||||
|
name="Enable tls-cert-reload path watcher",
|
||||||
|
service="tls-cert-reload.path",
|
||||||
|
running=True,
|
||||||
|
enabled=True,
|
||||||
|
restarted=self.need_restart,
|
||||||
|
daemon_reload=self.need_restart,
|
||||||
|
)
|
||||||
|
# Always trigger a reload so services pick up the current cert.
|
||||||
|
# The path unit handles future changes via inotify.
|
||||||
|
server.shell(
|
||||||
|
name="Reload TLS services for current certificate",
|
||||||
|
commands=["systemctl start tls-cert-reload.service"],
|
||||||
|
)
|
||||||
|
|
||||||
11
cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f
vendored
Normal file
11
cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Watch the TLS certificate file for changes.
|
||||||
|
# When the cert is updated (e.g. renewed by an external process),
|
||||||
|
# this triggers tls-cert-reload.service to restart the affected services.
|
||||||
|
[Unit]
|
||||||
|
Description=Watch TLS certificate for changes
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
PathChanged={cert_path}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
15
cmdeploy/src/cmdeploy/external/tls-cert-reload.service
vendored
Normal file
15
cmdeploy/src/cmdeploy/external/tls-cert-reload.service
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Reload services that cache the TLS certificate.
|
||||||
|
#
|
||||||
|
# dovecot: caches the cert at startup; reload re-reads SSL certs
|
||||||
|
# without dropping existing connections.
|
||||||
|
# nginx: caches the cert at startup; reload gracefully picks up
|
||||||
|
# the new cert for new connections.
|
||||||
|
# postfix: reads the cert fresh on each TLS handshake,
|
||||||
|
# does NOT need a reload/restart.
|
||||||
|
[Unit]
|
||||||
|
Description=Reload TLS services after certificate change
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/bin/systemctl reload dovecot
|
||||||
|
ExecStart=/bin/systemctl reload nginx
|
||||||
@@ -84,7 +84,7 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /new {
|
location /new {
|
||||||
{% if config.tls_cert_mode == "acme" %}
|
{% if config.tls_cert_mode != "self" %}
|
||||||
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.
|
||||||
@@ -106,7 +106,7 @@ http {
|
|||||||
#
|
#
|
||||||
# Redirects are only for browsers.
|
# Redirects are only for browsers.
|
||||||
location /cgi-bin/newemail.py {
|
location /cgi-bin/newemail.py {
|
||||||
{% if config.tls_cert_mode == "acme" %}
|
{% if config.tls_cert_mode != "self" %}
|
||||||
if ($request_method = GET) {
|
if ($request_method = GET) {
|
||||||
return 301 dcaccount:https://{{ config.mail_domain }}/new;
|
return 301 dcaccount:https://{{ config.mail_domain }}/new;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
from pyinfra.operations import apt, files, server
|
import shlex
|
||||||
|
|
||||||
|
from pyinfra.operations import apt, server
|
||||||
|
|
||||||
from cmdeploy.basedeploy import Deployer
|
from cmdeploy.basedeploy import Deployer
|
||||||
|
|
||||||
|
|
||||||
|
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
|
||||||
|
"""Return the openssl argument list for a self-signed certificate.
|
||||||
|
|
||||||
|
The certificate uses an EC P-256 key with SAN entries for *domain*,
|
||||||
|
``www.<domain>`` and ``mta-sts.<domain>``.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
"openssl", "req", "-x509",
|
||||||
|
"-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256",
|
||||||
|
"-noenc", "-days", str(days),
|
||||||
|
"-keyout", str(key_path),
|
||||||
|
"-out", str(cert_path),
|
||||||
|
"-subj", f"/CN={domain}",
|
||||||
|
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
|
||||||
|
"-addext",
|
||||||
|
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class SelfSignedTlsDeployer(Deployer):
|
class SelfSignedTlsDeployer(Deployer):
|
||||||
"""Generates a self-signed TLS certificate for all chatmail endpoints."""
|
"""Generates a self-signed TLS certificate for all chatmail endpoints."""
|
||||||
|
|
||||||
@@ -18,18 +39,13 @@ class SelfSignedTlsDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
|
args = openssl_selfsigned_args(
|
||||||
|
self.mail_domain, self.cert_path, self.key_path,
|
||||||
|
)
|
||||||
|
cmd = shlex.join(args)
|
||||||
server.shell(
|
server.shell(
|
||||||
name="Generate self-signed TLS certificate if not present",
|
name="Generate self-signed TLS certificate if not present",
|
||||||
commands=[
|
commands=[f"[ -f {self.cert_path} ] || {cmd}"],
|
||||||
f"[ -f {self.cert_path} ] || openssl req -x509"
|
|
||||||
f" -newkey ec -pkeyopt ec_paramgen_curve:P-256"
|
|
||||||
f" -noenc -days 36500"
|
|
||||||
f" -keyout {self.key_path}"
|
|
||||||
f" -out {self.cert_path}"
|
|
||||||
f' -subj "/CN={self.mail_domain}"'
|
|
||||||
f' -addext "extendedKeyUsage=serverAuth,clientAuth"'
|
|
||||||
f' -addext "subjectAltName=DNS:{self.mail_domain},DNS:www.{self.mail_domain},DNS:mta-sts.{self.mail_domain}"',
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
|
|||||||
362
cmdeploy/src/cmdeploy/tests/setup_tls_external.py
Normal file
362
cmdeploy/src/cmdeploy/tests/setup_tls_external.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"""Setup and verify external TLS certificates for a chatmail server.
|
||||||
|
|
||||||
|
Generates a self-signed TLS certificate, uploads it to the chatmail
|
||||||
|
server via SCP, runs ``cmdeploy run``, and then probes all TLS-enabled
|
||||||
|
ports (nginx, postfix, dovecot) to verify the certificate is actually
|
||||||
|
served. After probing, checks remote service logs for errors.
|
||||||
|
|
||||||
|
Prerequisites
|
||||||
|
~~~~~~~~~~~~~
|
||||||
|
- SSH root access to the target server (same as ``cmdeploy run``)
|
||||||
|
- ``cmdeploy`` in PATH (activate the venv first)
|
||||||
|
|
||||||
|
How to run
|
||||||
|
~~~~~~~~~~
|
||||||
|
From the repository root::
|
||||||
|
|
||||||
|
# Full run: generate cert, deploy, probe ports, check services
|
||||||
|
python -m cmdeploy.tests.setup_tls_external DOMAIN
|
||||||
|
|
||||||
|
# Re-probe only (after a previous deploy)
|
||||||
|
python -m cmdeploy.tests.setup_tls_external DOMAIN \\
|
||||||
|
--skip-deploy --skip-certgen
|
||||||
|
|
||||||
|
# Override SSH host (e.g. when domain doesn't resolve to the server)
|
||||||
|
python -m cmdeploy.tests.setup_tls_external DOMAIN \\
|
||||||
|
--ssh-host staging-ipv4.testrun.org
|
||||||
|
|
||||||
|
Arguments
|
||||||
|
~~~~~~~~~
|
||||||
|
DOMAIN mail domain for the chatmail server (SSH root login must work)
|
||||||
|
|
||||||
|
Options
|
||||||
|
~~~~~~~
|
||||||
|
--skip-deploy skip ``cmdeploy run``, only probe ports
|
||||||
|
--skip-certgen skip cert generation/upload, use certs already on server
|
||||||
|
--ssh-host HOST SSH host override (defaults to DOMAIN)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import smtplib
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Cert paths on the remote server
|
||||||
|
REMOTE_CERT = "/etc/ssl/certs/tmp_fullchain.pem"
|
||||||
|
REMOTE_KEY = "/etc/ssl/private/tmp_privkey.pem"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def generate_config(domain: str, config_dir: Path) -> Path:
|
||||||
|
"""Generate a chatmail.ini with tls_external_cert_and_key for *domain*."""
|
||||||
|
from chatmaild.config import write_initial_config
|
||||||
|
|
||||||
|
ini_path = config_dir / "chatmail.ini"
|
||||||
|
write_initial_config(
|
||||||
|
ini_path,
|
||||||
|
domain,
|
||||||
|
overrides={
|
||||||
|
"tls_external_cert_and_key": f"{REMOTE_CERT} {REMOTE_KEY}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print(f"[+] Generated chatmail.ini for {domain} in {config_dir}")
|
||||||
|
return ini_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Certificate generation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def generate_cert(domain: str, cert_dir: Path) -> tuple:
|
||||||
|
"""Generate a self-signed TLS cert+key for *domain* with proper SANs."""
|
||||||
|
from cmdeploy.selfsigned.deployer import openssl_selfsigned_args
|
||||||
|
|
||||||
|
cert_path = cert_dir / "fullchain.pem"
|
||||||
|
key_path = cert_dir / "privkey.pem"
|
||||||
|
subprocess.check_call(openssl_selfsigned_args(domain, cert_path, key_path, days=30))
|
||||||
|
print(f"[+] Generated cert for {domain} in {cert_dir}")
|
||||||
|
return cert_path, key_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Upload certs to remote server
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def upload_certs(
|
||||||
|
ssh_host: str,
|
||||||
|
cert_path: Path,
|
||||||
|
key_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""SCP cert and key to the remote server."""
|
||||||
|
subprocess.check_call([
|
||||||
|
"scp", str(cert_path), f"root@{ssh_host}:{REMOTE_CERT}",
|
||||||
|
])
|
||||||
|
subprocess.check_call([
|
||||||
|
"scp", str(key_path), f"root@{ssh_host}:{REMOTE_KEY}",
|
||||||
|
])
|
||||||
|
# Ensure cert is world-readable and key is readable by ssl-cert group
|
||||||
|
# (dovecot/postfix/nginx need to read these files)
|
||||||
|
subprocess.check_call([
|
||||||
|
"ssh", f"root@{ssh_host}",
|
||||||
|
f"chmod 644 {REMOTE_CERT} && chmod 640 {REMOTE_KEY}"
|
||||||
|
f" && chgrp ssl-cert {REMOTE_KEY}",
|
||||||
|
])
|
||||||
|
print(f"[+] Uploaded cert/key to {ssh_host}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Deploy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def run_deploy(ini_path: str) -> None:
|
||||||
|
"""Run ``cmdeploy run --skip-dns-check --config <ini>``."""
|
||||||
|
cmd = ["cmdeploy", "run", "--config", str(ini_path), "--skip-dns-check"]
|
||||||
|
print(f"[+] Running: {' '.join(cmd)}")
|
||||||
|
subprocess.check_call(cmd)
|
||||||
|
print("[+] Deploy completed successfully")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TLS port probing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_peer_cert_binary(host: str, port: int) -> bytes:
|
||||||
|
"""Connect to host:port with TLS and return the DER-encoded peer cert."""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
with socket.create_connection((host, port), timeout=15) as sock:
|
||||||
|
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
|
||||||
|
return ssock.getpeercert(binary_form=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_smtp_starttls_cert_binary(host: str, port: int = 587) -> bytes:
|
||||||
|
"""Connect via SMTP STARTTLS and return the DER cert."""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
with smtplib.SMTP(host, port, timeout=15) as smtp:
|
||||||
|
smtp.starttls(context=ctx)
|
||||||
|
return smtp.sock.getpeercert(binary_form=True)
|
||||||
|
|
||||||
|
|
||||||
|
def check_cert_matches(
|
||||||
|
label: str, served_der: bytes, expected_der: bytes,
|
||||||
|
) -> bool:
|
||||||
|
"""Compare served DER cert against the expected cert."""
|
||||||
|
if served_der == expected_der:
|
||||||
|
print(f" [OK] {label}: certificate matches")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" [FAIL] {label}: certificate does NOT match")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def load_cert_der(cert_pem_path: Path) -> bytes:
|
||||||
|
"""Load a PEM cert file and return its DER encoding."""
|
||||||
|
pem_text = cert_pem_path.read_text()
|
||||||
|
start = pem_text.index("-----BEGIN CERTIFICATE-----")
|
||||||
|
end = pem_text.index("-----END CERTIFICATE-----") + len(
|
||||||
|
"-----END CERTIFICATE-----"
|
||||||
|
)
|
||||||
|
return ssl.PEM_cert_to_DER_cert(pem_text[start:end])
|
||||||
|
|
||||||
|
|
||||||
|
def probe_all_ports(host: str, expected_cert_der: bytes) -> bool:
|
||||||
|
"""Probe TLS ports and verify the served certificate matches.
|
||||||
|
|
||||||
|
Checks ports 993 (IMAP), 465 (SMTPS), 587 (STARTTLS), and 443
|
||||||
|
(nginx stream). Port 8443 is skipped as nginx binds it to
|
||||||
|
localhost behind the stream proxy on 443.
|
||||||
|
"""
|
||||||
|
print(f"\n[+] Probing TLS ports on {host}...")
|
||||||
|
all_ok = True
|
||||||
|
|
||||||
|
for label, port in [
|
||||||
|
("IMAP/TLS (993)", 993),
|
||||||
|
("SMTP/TLS (465)", 465),
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
served = get_peer_cert_binary(host, port)
|
||||||
|
if not check_cert_matches(label, served, expected_cert_der):
|
||||||
|
all_ok = False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] {label}: connection failed: {e}")
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
# STARTTLS on port 587
|
||||||
|
try:
|
||||||
|
served = get_smtp_starttls_cert_binary(host, 587)
|
||||||
|
if not check_cert_matches("SMTP/STARTTLS (587)", served, expected_cert_der):
|
||||||
|
all_ok = False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] SMTP/STARTTLS (587): connection failed: {e}")
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
# Port 443 (nginx stream proxy with ALPN routing)
|
||||||
|
try:
|
||||||
|
served = get_peer_cert_binary(host, 443)
|
||||||
|
if not check_cert_matches("nginx/443 (stream)", served, expected_cert_der):
|
||||||
|
all_ok = False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] nginx/443 (stream): connection failed: {e}")
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Post-deploy service health checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SERVICES = ["dovecot", "postfix", "nginx"]
|
||||||
|
|
||||||
|
|
||||||
|
def check_remote_services(ssh_host: str, since: str = "") -> bool:
|
||||||
|
"""SSH to the server and check for service failures or errors.
|
||||||
|
|
||||||
|
*since* is a ``journalctl --since`` timestamp (e.g. ``"5 min ago"``).
|
||||||
|
If empty, checks the entire boot journal.
|
||||||
|
"""
|
||||||
|
print(f"\n[+] Checking remote service health on {ssh_host}...")
|
||||||
|
all_ok = True
|
||||||
|
|
||||||
|
for svc in SERVICES:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ssh", f"root@{ssh_host}",
|
||||||
|
f"systemctl is-active {svc}.service"],
|
||||||
|
capture_output=True, text=True, timeout=15, check=False,
|
||||||
|
)
|
||||||
|
status = result.stdout.strip()
|
||||||
|
if status == "active":
|
||||||
|
print(f" [OK] {svc}: active")
|
||||||
|
else:
|
||||||
|
print(f" [FAIL] {svc}: {status}")
|
||||||
|
all_ok = False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] {svc}: check failed: {e}")
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
since_arg = f'--since="{since}"' if since else ""
|
||||||
|
print(f"\n[+] Checking journal for errors on {ssh_host}...")
|
||||||
|
for svc in SERVICES:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["ssh", f"root@{ssh_host}",
|
||||||
|
f"journalctl -u {svc}.service {since_arg}"
|
||||||
|
f" --no-pager -p err -q"],
|
||||||
|
capture_output=True, text=True, timeout=15, check=False,
|
||||||
|
)
|
||||||
|
errors = result.stdout.strip()
|
||||||
|
if errors:
|
||||||
|
print(f" [WARN] {svc} errors in journal:")
|
||||||
|
for line in errors.splitlines()[:10]:
|
||||||
|
print(f" {line}")
|
||||||
|
all_ok = False
|
||||||
|
else:
|
||||||
|
print(f" [OK] {svc}: no errors in journal")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [FAIL] {svc}: journal check failed: {e}")
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"domain",
|
||||||
|
help="mail domain (SSH root login must work to this host)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-deploy",
|
||||||
|
action="store_true",
|
||||||
|
help="skip cmdeploy run, only probe ports",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--skip-certgen",
|
||||||
|
action="store_true",
|
||||||
|
help="skip cert generation and upload (use existing)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--ssh-host",
|
||||||
|
help="SSH host override (defaults to DOMAIN)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
domain = args.domain
|
||||||
|
ssh_host = args.ssh_host or domain
|
||||||
|
print(f"[+] Domain: {domain}")
|
||||||
|
print(f"[+] SSH host: {ssh_host}")
|
||||||
|
print(f"[+] Remote cert: {REMOTE_CERT}")
|
||||||
|
print(f"[+] Remote key: {REMOTE_KEY}")
|
||||||
|
|
||||||
|
work_dir = Path(tempfile.mkdtemp(prefix="tls-external-test-"))
|
||||||
|
try:
|
||||||
|
# Generate chatmail.ini
|
||||||
|
ini_path = generate_config(domain, work_dir)
|
||||||
|
|
||||||
|
if not args.skip_certgen:
|
||||||
|
local_cert, local_key = generate_cert(domain, work_dir)
|
||||||
|
upload_certs(ssh_host, local_cert, local_key)
|
||||||
|
else:
|
||||||
|
local_cert = work_dir / "fullchain.pem"
|
||||||
|
subprocess.check_call([
|
||||||
|
"scp", f"root@{ssh_host}:{REMOTE_CERT}", str(local_cert),
|
||||||
|
])
|
||||||
|
|
||||||
|
# Record timestamp before deploy for journal filtering
|
||||||
|
deploy_start = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
if not args.skip_deploy:
|
||||||
|
run_deploy(ini_path)
|
||||||
|
|
||||||
|
# Probe TLS ports
|
||||||
|
expected_der = load_cert_der(local_cert)
|
||||||
|
ports_ok = probe_all_ports(domain, expected_der)
|
||||||
|
|
||||||
|
# Check service health (only errors since deploy started)
|
||||||
|
services_ok = check_remote_services(ssh_host, since=deploy_start)
|
||||||
|
|
||||||
|
if ports_ok and services_ok:
|
||||||
|
print(
|
||||||
|
"\n[SUCCESS] All TLS port probes passed and services are healthy"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
if not ports_ok:
|
||||||
|
print("\n[FAILURE] Some TLS port probes failed", file=sys.stderr)
|
||||||
|
if not services_ok:
|
||||||
|
print(
|
||||||
|
"\n[FAILURE] Some services have errors", file=sys.stderr
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(work_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
78
cmdeploy/src/cmdeploy/tests/test_external_tls.py
Normal file
78
cmdeploy/src/cmdeploy/tests/test_external_tls.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""Functional tests for tls_external_cert_and_key option."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import chatmaild.newemail
|
||||||
|
import pytest
|
||||||
|
from chatmaild.config import read_config, write_initial_config
|
||||||
|
|
||||||
|
|
||||||
|
def make_external_config(tmp_path, cert_key=None):
|
||||||
|
inipath = tmp_path / "chatmail.ini"
|
||||||
|
overrides = {}
|
||||||
|
if cert_key is not None:
|
||||||
|
overrides["tls_external_cert_and_key"] = cert_key
|
||||||
|
write_initial_config(inipath, "chat.example.org", overrides=overrides)
|
||||||
|
return inipath
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_tls_config_reads_paths(tmp_path):
|
||||||
|
inipath = make_external_config(
|
||||||
|
tmp_path,
|
||||||
|
cert_key=(
|
||||||
|
"/etc/letsencrypt/live/chat.example.org/fullchain.pem"
|
||||||
|
" /etc/letsencrypt/live/chat.example.org/privkey.pem"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
config = read_config(inipath)
|
||||||
|
assert config.tls_cert_mode == "external"
|
||||||
|
assert (
|
||||||
|
config.tls_cert_path == "/etc/letsencrypt/live/chat.example.org/fullchain.pem"
|
||||||
|
)
|
||||||
|
assert config.tls_key_path == "/etc/letsencrypt/live/chat.example.org/privkey.pem"
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_tls_missing_option_uses_acme(tmp_path):
|
||||||
|
config = read_config(make_external_config(tmp_path))
|
||||||
|
assert config.tls_cert_mode == "acme"
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_tls_bad_format_raises(tmp_path):
|
||||||
|
inipath = make_external_config(tmp_path, cert_key="/only/one/path.pem")
|
||||||
|
with pytest.raises(ValueError, match="two space-separated"):
|
||||||
|
read_config(inipath)
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_tls_three_paths_raises(tmp_path):
|
||||||
|
inipath = make_external_config(tmp_path, cert_key="/a /b /c")
|
||||||
|
with pytest.raises(ValueError, match="two space-separated"):
|
||||||
|
read_config(inipath)
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_tls_no_dclogin_url(tmp_path, capsys, monkeypatch):
|
||||||
|
inipath = make_external_config(
|
||||||
|
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(inipath))
|
||||||
|
chatmaild.newemail.print_new_account()
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
lines = out.split("\n")
|
||||||
|
dic = json.loads(lines[2])
|
||||||
|
assert "dclogin_url" not in dic
|
||||||
|
|
||||||
|
|
||||||
|
def test_external_tls_selects_correct_deployer(tmp_path):
|
||||||
|
from cmdeploy.deployers import get_tls_deployer
|
||||||
|
from cmdeploy.external.deployer import ExternalTlsDeployer
|
||||||
|
from cmdeploy.selfsigned.deployer import SelfSignedTlsDeployer
|
||||||
|
|
||||||
|
inipath = make_external_config(
|
||||||
|
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
|
||||||
|
)
|
||||||
|
config = read_config(inipath)
|
||||||
|
deployer = get_tls_deployer(config, "chat.example.org")
|
||||||
|
|
||||||
|
assert isinstance(deployer, ExternalTlsDeployer)
|
||||||
|
assert not isinstance(deployer, SelfSignedTlsDeployer)
|
||||||
|
assert deployer.cert_path == "/certs/fullchain.pem"
|
||||||
|
assert deployer.key_path == "/certs/privkey.pem"
|
||||||
@@ -204,6 +204,40 @@ and all other relays will accept connections from it
|
|||||||
without requiring certificate verification.
|
without requiring certificate verification.
|
||||||
This is useful for experimental setups and testing.
|
This is useful for experimental setups and testing.
|
||||||
|
|
||||||
|
.. _external-tls:
|
||||||
|
|
||||||
|
Running a relay with externally managed certificates
|
||||||
|
-----------------------------------------------------
|
||||||
|
|
||||||
|
If you already have a TLS certificate manager
|
||||||
|
(e.g. Traefik, certbot, or another ACME client)
|
||||||
|
running on the deployment server,
|
||||||
|
you can configure the relay to use those certificates
|
||||||
|
instead of the built-in ``acmetool``.
|
||||||
|
|
||||||
|
Set the following in ``chatmail.ini``::
|
||||||
|
|
||||||
|
tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
|
||||||
|
|
||||||
|
The paths must point to certificate and key files
|
||||||
|
on the deployment server.
|
||||||
|
During ``cmdeploy run``, these paths are written into
|
||||||
|
the Postfix, Dovecot, and Nginx configurations.
|
||||||
|
No certificate files are transferred from the build machine —
|
||||||
|
they must already exist on the server,
|
||||||
|
managed by your external certificate tool.
|
||||||
|
|
||||||
|
The deploy will verify that both files exist on the server.
|
||||||
|
``acmetool`` is **not** installed or run in this mode.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
You are responsible for certificate renewal.
|
||||||
|
When the certificate file changes on disk,
|
||||||
|
all relay services pick up the new certificate automatically
|
||||||
|
(via a systemd path watcher installed during deploy).
|
||||||
|
|
||||||
|
|
||||||
Migrating to a new build machine
|
Migrating to a new build machine
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -308,6 +308,11 @@ When providing a TLS certificate to your chatmail relay server, make
|
|||||||
sure to provide the full certificate chain and not just the last
|
sure to provide the full certificate chain and not just the last
|
||||||
certificate.
|
certificate.
|
||||||
|
|
||||||
|
If you use an external certificate manager (e.g. Traefik or certbot),
|
||||||
|
set ``tls_external_cert_and_key`` in ``chatmail.ini``
|
||||||
|
to provide the certificate and key paths.
|
||||||
|
See :ref:`external-tls` for details.
|
||||||
|
|
||||||
If you are running an Exim server and don’t see incoming connections
|
If you are running an Exim server and don’t see incoming connections
|
||||||
from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log
|
from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log
|
||||||
item is enabled in the config with ``log_selector = +smtp_no_mail``. By
|
item is enabled in the config with ``log_selector = +smtp_no_mail``. By
|
||||||
|
|||||||
Reference in New Issue
Block a user