diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5cd92bcc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +data/ +venv/ +__pycache__ +*.pyc +*.orig +.pytest_cache diff --git a/.gitignore b/.gitignore index 6e1054d0..da7cd1c8 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,8 @@ cython_debug/ #.idea/ chatmail.zone + +# docker +/data/ +/custom/ +.env diff --git a/CHANGELOG.md b/CHANGELOG.md index 91ba8794..49fbea8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -121,6 +121,13 @@ Provide an "fsreport" CLI for more fine grained analysis of message files. ([#637](https://github.com/chatmail/relay/pull/637)) +- Add installation via docker compose (MVP 1). The instructions, known issues and limitations are located in `/docs` + ([#614](https://github.com/chatmail/relay/pull/614)) + +- Add configuration parameters + ([#614](https://github.com/chatmail/relay/pull/614)): + - `change_kernel_settings` - Whether to change kernel parameters during installation (default: `True`) + - `fs_inotify_max_user_instances_and_watchers` - Value for kernel parameters `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches` (default: `65535`) ## 1.7.0 2025-09-11 diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index d19e966a..42fc9c51 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -47,6 +47,12 @@ def __init__(self, inipath, params): self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "") self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "") self.acme_email = params.get("acme_email", "") + self.change_kernel_settings = ( + params.get("change_kernel_settings", "true").lower() == "true" + ) + self.fs_inotify_max_user_instances_and_watchers = int( + params["fs_inotify_max_user_instances_and_watchers"] + ) self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true" if "iroh_relay" not in params: diff --git a/chatmaild/src/chatmaild/ini/chatmail.ini.f b/chatmaild/src/chatmaild/ini/chatmail.ini.f index 29d7baa9..fc2cfa78 100644 --- a/chatmaild/src/chatmaild/ini/chatmail.ini.f +++ b/chatmaild/src/chatmaild/ini/chatmail.ini.f @@ -69,6 +69,16 @@ # Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates acme_email = +# +# Kernel settings +# + +# if you set "True", the kernel settings will be configured according to the values below +change_kernel_settings = True + +# change fs.inotify.max_user_instances and fs.inotify.max_user_watches kernel settings +fs_inotify_max_user_instances_and_watchers = 65535 + # Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail # service. # If you set it to anything else, the service will be disabled diff --git a/cmdeploy/src/cmdeploy/basedeploy.py b/cmdeploy/src/cmdeploy/basedeploy.py index dcb17a3c..45654c27 100644 --- a/cmdeploy/src/cmdeploy/basedeploy.py +++ b/cmdeploy/src/cmdeploy/basedeploy.py @@ -5,6 +5,11 @@ from pyinfra.operations import files, server, systemd +def has_systemd(): + """Returns False during Docker image builds or any other non-systemd environment.""" + return os.path.isdir("/run/systemd/system") + + def get_resource(arg, pkg=__package__): return importlib.resources.files(pkg).joinpath(arg) diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 61a9b5f6..4336f102 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -109,6 +109,8 @@ def run_cmd(args, out): cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" if ssh_host in ["localhost", "@docker"]: + if ssh_host == "@docker": + env["CHATMAIL_DOCKER"] = "True" cmd = f"{pyinf} @local {deploy_path} -y" if version.parse(pyinfra.__version__) < version.parse("3"): diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 5812aa28..c38f2dd5 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -25,6 +25,7 @@ activate_remote_units, configure_remote_units, get_resource, + has_systemd, ) from .dovecot.deployer import DovecotDeployer from .filtermail.deployer import FiltermailDeployer @@ -65,6 +66,8 @@ def _build_chatmaild(dist_dir) -> None: def remove_legacy_artifacts(): + if not has_systemd(): + return # disable legacy doveauth-dictproxy.service if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"): systemd.service( @@ -299,7 +302,7 @@ def install(self): present=False, ) # remove echobot if it is still running - if host.get_fact(SystemdEnabled).get("echobot.service"): + if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"): systemd.service( name="Disable echobot.service", service="echobot.service", @@ -535,12 +538,13 @@ def activate(self): ) -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, docker: bool) -> None: """Deploy a chat-mail instance. :param config_path: path to chatmail.ini :param disable_mail: whether to disable postfix & dovecot :param website_only: if True, only deploy the website + :param docker: whether it is running in a docker container """ config = read_config(config_path) check_config(config) @@ -566,34 +570,35 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") exit(1) - 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), - ("mtail", 3903), - ("stats", 3904), - ("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) - services = [service] if isinstance(service, str) else service - if running_service: - if running_service not in services: - Out().red( - f"Deploy failed: port {port} is occupied by: {running_service}" - ) - exit(1) + if not docker: + 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), + ("mtail", 3903), + ("stats", 3904), + ("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) + services = [service] if isinstance(service, str) else service + if running_service: + if running_service not in services: + Out().red( + f"Deploy failed: port {port} is occupied by: {running_service}" + ) + exit(1) tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"] diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index 3b3a2715..9a7a4232 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -9,6 +9,7 @@ activate_remote_units, configure_remote_units, get_resource, + has_systemd, ) @@ -22,10 +23,11 @@ def __init__(self, config, disable_mail): def install(self): arch = host.get_fact(Arch) - if not "dovecot.service" in host.get_fact(SystemdEnabled): - _install_dovecot_package("core", arch) - _install_dovecot_package("imapd", arch) - _install_dovecot_package("lmtpd", arch) + if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled): + return # already installed and running + _install_dovecot_package("core", arch) + _install_dovecot_package("imapd", arch) + _install_dovecot_package("lmtpd", arch) def configure(self): configure_remote_units(self.config.mail_domain, self.units) @@ -116,18 +118,19 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool): # as per https://doc.dovecot.org/2.3/configuration_manual/os/ # it is recommended to set the following inotify limits - for name in ("max_user_instances", "max_user_watches"): - key = f"fs.inotify.{name}" - if host.get_fact(Sysctl)[key] > 65535: - # Skip updating limits if already sufficient - # (enables running in incus containers where sysctl readonly) - continue - server.sysctl( - name=f"Change {key}", - key=key, - value=65535, - persist=True, - ) + if config.change_kernel_settings: + for name in ("max_user_instances", "max_user_watches"): + key = f"fs.inotify.{name}" + if host.get_fact(Sysctl)[key] > 65535: + # Skip updating limits if already sufficient + # (enables running in incus containers where sysctl readonly) + continue + server.sysctl( + name=f"Change {key}", + key=key, + value=65535, + persist=True, + ) timezone_env = files.line( name="Set TZ environment variable", diff --git a/cmdeploy/src/cmdeploy/run.py b/cmdeploy/src/cmdeploy/run.py index 0b0fc858..e95d5a1e 100644 --- a/cmdeploy/src/cmdeploy/run.py +++ b/cmdeploy/src/cmdeploy/run.py @@ -15,8 +15,9 @@ def main(): ) disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL")) website_only = bool(os.environ.get("CHATMAIL_WEBSITE_ONLY")) + docker = bool(os.environ.get("CHATMAIL_DOCKER")) - deploy_chatmail(config_path, disable_mail, website_only) + deploy_chatmail(config_path, disable_mail, website_only, docker) if pyinfra.is_cli: diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 2553eff1..ac204ba6 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -80,6 +80,13 @@ steps. Please substitute it with your own domain. configure at your DNS provider (it can take some time until they are public). +Docker installation +------------------- + +We have experimental support for `docker compose `_, +but it is not covered by automated tests yet, +so don't expect everything to work. + Other helpful commands ---------------------- diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 00000000..8d5733ed --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,52 @@ +services: + chatmail: + build: + context: ./ + dockerfile: docker/chatmail_relay.dockerfile + tags: + - chatmail-relay:latest + image: chatmail-relay:latest + restart: unless-stopped + container_name: chatmail + cgroup: host # required for systemd + tty: true # required for logs + tmpfs: # required for systemd + - /tmp + - /run + - /run/lock + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + environment: + CHANGE_KERNEL_SETTINGS: "False" + MAIL_DOMAIN: $MAIL_DOMAIN + ACME_EMAIL: $ACME_EMAIL + RECREATE_VENV: $RECREATE_VENV + MAX_MESSAGE_SIZE: $MAX_MESSAGE_SIZE + DEBUG_COMMANDS_ENABLED: $DEBUG_COMMANDS_ENABLED + FORCE_REINIT_INI_FILE: $FORCE_REINIT_INI_FILE + USE_FOREIGN_CERT_MANAGER: $USE_FOREIGN_CERT_MANAGER + ENABLE_CERTS_MONITORING: $ENABLE_CERTS_MONITORING + CERTS_MONITORING_TIMEOUT: $CERTS_MONITORING_TIMEOUT + IS_DEVELOPMENT_INSTANCE: $IS_DEVELOPMENT_INSTANCE + CMDEPLOY_STAGES: ${CMDEPLOY_STAGES:-} + network_mode: "host" + volumes: + ## system + - /sys/fs/cgroup:/sys/fs/cgroup:rw # required for systemd + - ./:/opt/chatmail + + ## data + - ./data/chatmail:/home + - ./data/chatmail-dkimkeys:/etc/dkimkeys + - ./data/chatmail-acme:/var/lib/acme + + ## custom resources + # - ./custom/www/src/index.md:/opt/chatmail/www/src/index.md + + ## debug + # - ./docker/files/setup_chatmail_docker.sh:/setup_chatmail_docker.sh + # - ./docker/files/entrypoint.sh:/entrypoint.sh + # - ./docker/files/update_ini.sh:/update_ini.sh diff --git a/docker/chatmail_relay.dockerfile b/docker/chatmail_relay.dockerfile new file mode 100644 index 00000000..13322c6e --- /dev/null +++ b/docker/chatmail_relay.dockerfile @@ -0,0 +1,100 @@ +FROM jrei/systemd-debian:12 AS base + +ENV LANG=en_US.UTF-8 + +RUN echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend && \ + echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend && \ + apt-get update && \ + apt-get install -y \ + ca-certificates && \ + DEBIAN_FRONTEND=noninteractive \ + TZ=Europe/London \ + apt-get install -y tzdata && \ + apt-get install -y locales && \ + sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \ + dpkg-reconfigure --frontend=noninteractive locales && \ + update-locale LANG=$LANG \ + && rm -rf /var/lib/apt/lists/* + +RUN apt-get update && \ + apt-get install -y \ + git \ + python3 \ + python3-venv \ + python3-virtualenv \ + gcc \ + python3-dev \ + opendkim \ + opendkim-tools \ + curl \ + rsync \ + unbound \ + unbound-anchor \ + dnsutils \ + postfix \ + acl \ + nginx \ + libnginx-mod-stream \ + fcgiwrap \ + cron \ + && for pkg in core imapd lmtpd; do \ + case "$pkg" in \ + core) sha256="43f593332e22ac7701c62d58b575d2ca409e0f64857a2803be886c22860f5587" ;; \ + imapd) sha256="8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86" ;; \ + lmtpd) sha256="2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab" ;; \ + esac; \ + url="https://download.delta.chat/dovecot/dovecot-${pkg}_2.3.21%2Bdfsg1-3_amd64.deb"; \ + file="/tmp/$(basename "$url")"; \ + curl -fsSL "$url" -o "$file"; \ + echo "$sha256 $file" | sha256sum -c -; \ + apt-get install -y "$file"; \ + rm -f "$file"; \ + done \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/chatmail + +# --- Build-time install stage --- +# Bake the "install" deployer stage into the image; we can't use +# scripts/initenv.sh because /opt/chatmail is empty at build time as +# source arrives at runtime via volume mount., so we use a throwaway venv. +# On container start only "configure,activate" stages run. +COPY . /tmp/chatmail-src/ +WORKDIR /tmp/chatmail-src + +# Dummy config — deploy_chatmail() needs a parseable ini to instantiate deployers +RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini + +# Do what initenv.sh would do without the docs +RUN python3 -m venv /tmp/build-venv && \ + /tmp/build-venv/bin/pip install --no-cache-dir \ + -e chatmaild -e cmdeploy + +RUN CMDEPLOY_STAGES=install \ + CHATMAIL_INI=/tmp/chatmail.ini \ + CHATMAIL_DOCKER=True \ + /tmp/build-venv/bin/pyinfra @local \ + /tmp/chatmail-src/cmdeploy/src/cmdeploy/run.py -y + +RUN rm -rf /tmp/chatmail-src /tmp/build-venv /tmp/chatmail.ini + +WORKDIR /opt/chatmail +# --- End build-time install stage --- + +ARG SETUP_CHATMAIL_SERVICE_PATH=/lib/systemd/system/setup_chatmail.service +COPY ./docker/files/setup_chatmail.service "$SETUP_CHATMAIL_SERVICE_PATH" +RUN ln -sf "$SETUP_CHATMAIL_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/setup_chatmail.service" + +COPY --chmod=555 ./docker/files/setup_chatmail_docker.sh /setup_chatmail_docker.sh +COPY --chmod=555 ./docker/files/update_ini.sh /update_ini.sh +COPY --chmod=555 ./docker/files/entrypoint.sh /entrypoint.sh + +VOLUME ["/sys/fs/cgroup", "/home"] + +STOPSIGNAL SIGRTMIN+3 + +ENTRYPOINT ["/entrypoint.sh"] + +CMD [ "--default-standard-output=journal+console", \ + "--default-standard-error=journal+console" ] + diff --git a/docker/example.env b/docker/example.env new file mode 100644 index 00000000..4aa8e2dc --- /dev/null +++ b/docker/example.env @@ -0,0 +1,11 @@ +MAIL_DOMAIN="chat.example.com" +# ACME_EMAIL="" +# RECREATE_VENV="false" +# MAX_MESSAGE_SIZE="50M" +# DEBUG_COMMANDS_ENABLED="true" +# FORCE_REINIT_INI_FILE="true" +# USE_FOREIGN_CERT_MANAGER="True" +# ENABLE_CERTS_MONITORING="true" +# CERTS_MONITORING_TIMEOUT=10 +# IS_DEVELOPMENT_INSTANCE="True" +# CMDEPLOY_STAGES - default: "configure,activate". Set to "install,configure,activate" to force full reinstall. diff --git a/docker/files/entrypoint.sh b/docker/files/entrypoint.sh new file mode 100755 index 00000000..bce52a56 --- /dev/null +++ b/docker/files/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -eo pipefail + +unlink /etc/nginx/sites-enabled/default || true + +SETUP_CHATMAIL_SERVICE_PATH="${SETUP_CHATMAIL_SERVICE_PATH:-/lib/systemd/system/setup_chatmail.service}" + +env_vars=$(printenv | cut -d= -f1 | xargs) +sed -i "s||$env_vars|g" $SETUP_CHATMAIL_SERVICE_PATH + +exec /lib/systemd/systemd $@ diff --git a/docker/files/setup_chatmail.service b/docker/files/setup_chatmail.service new file mode 100644 index 00000000..2a0a48bc --- /dev/null +++ b/docker/files/setup_chatmail.service @@ -0,0 +1,14 @@ +[Unit] +Description=Run container setup commands +After=multi-user.target +ConditionPathExists=/setup_chatmail_docker.sh + +[Service] +Type=oneshot +ExecStart=/bin/bash /setup_chatmail_docker.sh +RemainAfterExit=true +WorkingDirectory=/opt/chatmail +PassEnvironment= + +[Install] +WantedBy=multi-user.target diff --git a/docker/files/setup_chatmail_docker.sh b/docker/files/setup_chatmail_docker.sh new file mode 100755 index 00000000..81a47bd9 --- /dev/null +++ b/docker/files/setup_chatmail_docker.sh @@ -0,0 +1,81 @@ +#!/bin/bash + +set -eo pipefail +export INI_FILE="${INI_FILE:-chatmail.ini}" +export ENABLE_CERTS_MONITORING="${ENABLE_CERTS_MONITORING:-true}" +export CERTS_MONITORING_TIMEOUT="${CERTS_MONITORING_TIMEOUT:-60}" +export PATH_TO_SSL="${PATH_TO_SSL:-/var/lib/acme/live/${MAIL_DOMAIN}}" +export CHANGE_KERNEL_SETTINGS=${CHANGE_KERNEL_SETTINGS:-"False"} +export RECREATE_VENV=${RECREATE_VENV:-"false"} + +if [ -z "$MAIL_DOMAIN" ]; then + echo "ERROR: Environment variable 'MAIL_DOMAIN' must be set!" >&2 + exit 1 +fi + +debug_commands() { + echo "Executing debug commands" + # git config --global --add safe.directory /opt/chatmail + # ./scripts/initenv.sh +} + +calculate_hash() { + find "$PATH_TO_SSL" -type f -exec sha1sum {} \; | sort | sha1sum | awk '{print $1}' +} + +monitor_certificates() { + if [ "$ENABLE_CERTS_MONITORING" != "true" ]; then + echo "Certs monitoring disabled." + exit 0 + fi + + current_hash=$(calculate_hash) + previous_hash=$current_hash + + while true; do + current_hash=$(calculate_hash) + if [[ "$current_hash" != "$previous_hash" ]]; then + # TODO: add an option to restart at a specific time interval + echo "[INFO] Certificate's folder hash was changed, reloading nginx, dovecot and postfix services." + systemctl reload nginx.service + systemctl reload dovecot.service + systemctl reload postfix.service + previous_hash=$current_hash + fi + sleep $CERTS_MONITORING_TIMEOUT + done +} + +### MAIN + +if [ "$DEBUG_COMMANDS_ENABLED" = true ]; then + debug_commands +fi + +if [ "$FORCE_REINIT_INI_FILE" = true ]; then + INI_CMD_ARGS=--force +fi + +if [ ! -f /etc/dkimkeys/opendkim.private ]; then + /usr/sbin/opendkim-genkey -D /etc/dkimkeys -d $MAIL_DOMAIN -s opendkim +fi +chown opendkim:opendkim /etc/dkimkeys/opendkim.private +chown opendkim:opendkim /etc/dkimkeys/opendkim.txt + +# TODO: Move to debug_commands after git clone is moved to dockerfile. +git config --global --add safe.directory /opt/chatmail +if [ "$RECREATE_VENV" = true ]; then + rm -rf venv +fi +./scripts/initenv.sh + +./scripts/cmdeploy init --config "${INI_FILE}" $INI_CMD_ARGS $MAIL_DOMAIN || true +bash /update_ini.sh + +export CMDEPLOY_STAGES="${CMDEPLOY_STAGES:-configure,activate}" +./scripts/cmdeploy run --ssh-host @docker + +echo "ForwardToConsole=yes" >> /etc/systemd/journald.conf +systemctl restart systemd-journald + +monitor_certificates & diff --git a/docker/files/update_ini.sh b/docker/files/update_ini.sh new file mode 100644 index 00000000..c5d65661 --- /dev/null +++ b/docker/files/update_ini.sh @@ -0,0 +1,79 @@ +#!/bin/bash +set -eo pipefail + +INI_FILE="${INI_FILE:-chatmail.ini}" + +if [ ! -f "$INI_FILE" ]; then + echo "Error: file $INI_FILE not found." >&2 + exit 1 +fi + +TMP_FILE="$(mktemp)" + +convert_to_bytes() { + local value="$1" + if [[ "$value" =~ ^([0-9]+)([KkMmGgTt])$ ]]; then + local num="${BASH_REMATCH[1]}" + local unit="${BASH_REMATCH[2]}" + case "$unit" in + [Kk]) echo $((num * 1024)) ;; + [Mm]) echo $((num * 1024 * 1024)) ;; + [Gg]) echo $((num * 1024 * 1024 * 1024)) ;; + [Tt]) echo $((num * 1024 * 1024 * 1024 * 1024)) ;; + esac + elif [[ "$value" =~ ^[0-9]+$ ]]; then + echo "$value" + else + echo "Error: incorrect size format: $value." >&2 + return 1 + fi +} + +process_specific_params() { + local key=$1 + local value=$2 + local destination_file=$3 + + if [[ "$key" == "max_message_size" ]]; then + converted=$(convert_to_bytes "$value") || exit 1 + if grep -q -e "## .* = .* bytes" "$destination_file"; then + sed "s|## .* = .* bytes|## $value = $converted bytes|g" "$destination_file"; + else + echo "## $value = $converted bytes" >> "$destination_file" + fi + echo "$key = $converted" >> "$destination_file" + else + echo "$key = $value" >> "$destination_file" + fi +} + +while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*#.* || "$line" =~ ^[[:space:]]*$ ]]; then + echo "$line" >> "$TMP_FILE" + continue + fi + + if [[ "$line" =~ ^([a-z0-9_]+)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + current_value="${BASH_REMATCH[2]}" + env_var_name=$(echo "$key" | tr 'a-z' 'A-Z') + env_value="${!env_var_name}" + + if [[ -n "$env_value" ]]; then + process_specific_params "$key" "$env_value" "$TMP_FILE" + else + echo "$line" >> "$TMP_FILE" + fi + else + echo "$line" >> "$TMP_FILE" + fi +done < "$INI_FILE" + +PERMS=$(stat -c %a "$INI_FILE") +OWNER=$(stat -c %u "$INI_FILE") +GROUP=$(stat -c %g "$INI_FILE") + +chmod "$PERMS" "$TMP_FILE" +chown "$OWNER":"$GROUP" "$TMP_FILE" + +mv "$TMP_FILE" "$INI_FILE" diff --git a/docs/DOCKER_INSTALLATION_EN.md b/docs/DOCKER_INSTALLATION_EN.md new file mode 100644 index 00000000..d306b2ef --- /dev/null +++ b/docs/DOCKER_INSTALLATION_EN.md @@ -0,0 +1,132 @@ +# Known issues and limitations + +- Requires cgroups v2 configured in the system. Operation with cgroups v1 has not been tested. +- Yes, of course, using systemd inside a container is a hack, and it would be better to split it into several services, but since this is an MVP, it turned out to be easier to do it this way initially than to rewrite the entire deployment system. +- The Docker image is only suitable for amd64. If you need to run it on a different architecture, try modifying the Dockerfile (specifically the part responsible for installing dovecot). + +# Docker installation +This section provides instructions for installing Chatmail using docker-compose. + +## Preliminary setup +We use `chat.example.org` as the Chatmail domain in the following steps. +Please substitute it with your own domain. + +1. Setup the initial DNS records. + The following is an example in the familiar BIND zone file format with + a TTL of 1 hour (3600 seconds). + Please substitute your domain and IP addresses. + + ``` + chat.example.com. 3600 IN A 198.51.100.5 + chat.example.com. 3600 IN AAAA 2001:db8::5 + www.chat.example.com. 3600 IN CNAME chat.example.com. + mta-sts.chat.example.com. 3600 IN CNAME chat.example.com. + ``` + +2. clone the repository on your server. + + ```shell + git clone https://github.com/chatmail/relay + cd relay + ``` + +## Installation + +1. Configure kernel parameters because they cannot be changed inside the container, specifically `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches`. Run the following: + +```shell +echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf +echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf +sudo sysctl --system +``` + +2. Copy `./docker/example.env` and rename it to `.env`. This file stores variables used in `docker-compose.yaml`. + +```shell +cp ./docker/example.env .env +``` + +3. Configure environment variables in the `.env` file. These variables are used in the `docker-compose.yaml` file to pass repeated values. + Below is the list of variables used during deployment: + +- `MAIL_DOMAIN` – The domain name of the future server. (required) +- `DEBUG_COMMANDS_ENABLED` – Run debug commands before installation. (default: `false`) +- `FORCE_REINIT_INI_FILE` – Recreate the ini configuration file on startup. (default: `false`) +- `USE_FOREIGN_CERT_MANAGER` – Use a third-party certificate manager. (default: `false`) +- `RECREATE_VENV` - Recreate the virtual environment (venv). If set to `true`, the environment will be recreated when the container starts, which will increase the startup time of the service but can help avoid certain errors. (default: `false`) +- `INI_FILE` – Path to the ini configuration file. (default: `./chatmail.ini`) +- `PATH_TO_SSL` – Path to where the certificates are stored. (default: `/var/lib/acme/live/${MAIL_DOMAIN}`) +- `ENABLE_CERTS_MONITORING` – Enable certificate monitoring if `USE_FOREIGN_CERT_MANAGER=true`. If certificates change, services will be automatically restarted. (default: `false`) +- `CERTS_MONITORING_TIMEOUT` – Interval in seconds to check if certificates have changed. (default: `'60'`) +- `CMDEPLOY_STAGES` – Deployment stages to run on container start. (default: `"configure,activate"`). Set to `"install,configure,activate"` to force a full reinstall. + +You can also use any variables from the [ini configuration file](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/ini/chatmail.ini.f); they must be in uppercase. + +4. Build the Docker image: + +```shell +docker compose build chatmail +``` + +5. Start docker compose and wait for the installation to finish: + +```shell +docker compose up -d # start service +docker compose logs -f chatmail # view container logs, press CTRL+C to exit +``` + +6. After installation is complete, you can open `https://` in your browser. + +## Using custom files + +When using Docker, you can apply modified configuration files to make the installation more personalized. This is usually needed for the `www/src` section so that the Chatmail landing page is customized to your taste, but it can be used for any other cases as well. + +To replace files correctly: + +1. Create the `./custom` directory. It is in `.gitignore`, so it won’t cause conflicts when updating. + +```shell +mkdir -p ./custom +``` + +2. Modify the required file. For example, `index.md`: + +```shell +mkdir -p ./custom/www/src +nano ./custom/www/src/index.md +``` + +3. In `docker-compose.yaml`, add the file mount in the `volumes` section: + +```yaml +services: + chatmail: + volumes: + ... + ## custom resources + - ./custom/www/src/index.md:/opt/chatmail/www/src/index.md +``` + +4. Restart the service: + +```shell +docker compose down +docker compose up -d +``` + +## Forcing a full reinstall + +The Docker image bakes the install stage (binary downloads, package setup, chatmaild venv) into the image at build time. On container start, only the `configure` and `activate` stages run by default. + +To force a full reinstall (e.g., after updating the source), either rebuild the image: + +```shell +docker compose build chatmail +docker compose up -d +``` + +Or override the stages at runtime without rebuilding: + +```shell +CMDEPLOY_STAGES="install,configure,activate" docker compose up -d +``` diff --git a/docs/DOCKER_INSTALLATION_RU.md b/docs/DOCKER_INSTALLATION_RU.md new file mode 100644 index 00000000..fef0c29b --- /dev/null +++ b/docs/DOCKER_INSTALLATION_RU.md @@ -0,0 +1,174 @@ +# Известные проблемы и ограничения +- Chatmail будет переустановлен при каждом запуске контейнера (при первом - долго, при последующих быстрее). Так устроен изначальный установщик, потому что он не был заточен под docker. В конце документации [представлено](#фиксирование-версии-chatmail) возможное решение +- Требуется настроенный в системе cgroups v2. Работа с cgroups v1 не тестировалась. +- Да, понятно дело что systemd использовать в контейнере костыль и надо это всё разнести на несколько сервисов, но это MVP и в первом приближении оказалось сделать проще так, чем переписывать всю систему развертывания. +- docker образ подходит только для amd64, если нужно запустить на другой архитектуре, попробуйте изменить dockerfile (конкретно ту часть что ответсвенна за установку dovecot) + +# Docker installation +Здесь представлена инструкция по установке chatmail с помощью docker-compose. + +## Предварительная настройка +We use `chat.example.org` as the chatmail domain in the following steps. +Please substitute it with your own domain. + +1. Настройте начальные записи DNS.Ниже приведен пример в привычном формате файла зоны BIND сTTL 1 час (3600 секунд). + Замените домен и IP-адреса на свои. + + ``` + chat.example.com. 3600 IN A 198.51.100.5 + chat.example.com. 3600 IN AAAA 2001:db8::5 + www.chat.example.com. 3600 IN CNAME chat.example.com. + mta-sts.chat.example.com. 3600 IN CNAME chat.example.com. + ``` + +2. Склонируйте репозиторий на свой сервер. + + ```shell + git clone https://github.com/chatmail/relay + cd relay + ``` + +## Installation + +1. Настроить параметры ядра, потому что внутри контейнера их нельзя изменить, а конкретно `fs.inotify.max_user_instances` и `fs.inotify.max_user_watches`. Для этого выполнить следующее: +```shell +echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf +echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf +sudo sysctl --system +``` + +2. Скопировать `./docker/example.env` и переименовать в `.env`. Здесь хранятся переменные, которые используются в `docker-compose.yaml`. +```shell +cp ./docker/example.env .env +``` + +3. Настроить переменные окружения в `.env` файле. Эти переменные используются в `docker-compose.yaml` файле, чтобы передавать повторяющиеся значения. + Ниже перечислен список переменных учавствующих при развертывании: + +- `MAIL_DOMAIN` - Доменное имя будущего сервера. (required) +- `DEBUG_COMMANDS_ENABLED` - Выполнить debug команды перед установкой. (default: `false`) +- `FORCE_REINIT_INI_FILE` - Пересоздавать ini файл конфигурации при запуске. (default: `false`) +- `USE_FOREIGN_CERT_MANAGER` - Использовать сторонний менеджер сертификатов. (default: `false`) +- `RECREATE_VENV` - Пересоздать виртуальное окружение (venv). Если выставлено `true`, то окружение будет пересоздано при запуске контейнера, из-за чего включение сервиса займет больше времени, но поможет избежать ряда ошибок. (default: `false`) +- `INI_FILE` - путь к ini файлу конфигурации. (default: `./chatmail.ini`) +- `PATH_TO_SSL` - Путь где располагаются сертификаты. (default: `/var/lib/acme/live/${MAIL_DOMAIN}`) +- `ENABLE_CERTS_MONITORING` - Включить мониторинг сертификатов, если `USE_FOREIGN_CERT_MANAGER=true`. Если сертфикаты изменятся сервисы будут автоматически перезапущены. (default: `false`) +- `CERTS_MONITORING_TIMEOUT` - Раз во сколько секунд проверять что изменились сертификаты. (default: `'60'`) + +Также могут быть использованы все переменные из [ini файла конфигурации](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/ini/chatmail.ini.f), они обязаны быть в uppercase формате. + +4. Собрать docker образ +```shell +docker compose build chatmail +``` + +5. Запустить docker compose и дождаться завершения установки +```shell +docker compose up -d # запуск сервиса +docker compose logs -f chatmail # просмотр логов контейнера. Для выхода нажать CTRL+C +``` + +6. По окончанию установки можно открыть в браузер `https://` + +## Использование кастомных файлов +При использовании docker есть возможность использовать измененые файлы конфигурации, чтобы сделать установку более персонализированной. Обычно это требуется для секции `www/src`, чтобы ознакомительная страница Chatmail была сделана на ваш вкус. Но также это можно использовать и для любых других случаев. + +Для того чтобы корректно выполнить подмену файлов необходимо +1. создать каталог `./custom`, он находится в `.gitignore`, поэтому при обновлении не вызовет конфликтов. +```shell +mkdir -p ./custom +``` + +2. Изменить нужный файл. Для примера возьмем `index.md` +```shell +mkdir -p ./custom/www/src +nano ./custom/www/src/index.md +``` + +3. В `docker-compose.yaml` добавить монтирование файла с помощью секции `volumes` +```yaml +services: + chatmail: + volumes: + ... + ## custom resources + - ./custom/www/src/index.md:/opt/chatmail/www/src/index.md +``` + +4. Перезапустить сервис +```shell +docker compose down +docker compose up -d +``` + +## Фиксирование версии Chatmail +> [!note] +> Это опциональные шаги, их делать требуется только если вас не устраивает что сервис устанавливается каждый раз при запуске + +Поскольку в текущей версии docker chatmail сервис устанавливается каждый раз запуске контейнера, чтобы этого не происходило можно зафиксировать версию контейнера после установки. Делается это следующим образом: + +1. Зафиксировать текущее состояние сконфигурированного контейнера +```shell +docker container commit chatmail configured-chatmail:$(date +'%Y-%m-%d') +docker image ls | grep configured-chatmail +``` + +2. Изменить entrypoint для контейнера в `docker-compose.yaml` на +```yaml +services: + chatmail: + image: + volumes: + ... + ## custom resources + - ./custom/setup_chatmail_docker.sh:/setup_chatmail_docker.sh +``` + +3. Создать файл `./custom/setup_chatmail_docker.sh` с новым файлом конфигурации +```shell +mkdir -p ./custom +cat > ./custom/setup_chatmail_docker.sh << 'EOF' +#!/bin/bash + +set -eo pipefail + +export ENABLE_CERTS_MONITORING="${ENABLE_CERTS_MONITORING:-true}" +export CERTS_MONITORING_TIMEOUT="${CERTS_MONITORING_TIMEOUT:-60}" +export PATH_TO_SSL="${PATH_TO_SSL:-/var/lib/acme/live/${MAIL_DOMAIN}}" + +calculate_hash() { + find "$PATH_TO_SSL" -type f -exec sha1sum {} \; | sort | sha1sum | awk '{print $1}' +} + +monitor_certificates() { + if [ "$ENABLE_CERTS_MONITORING" != "true" ]; then + echo "Certs monitoring disabled." + exit 0 + fi + + current_hash=$(calculate_hash) + previous_hash=$current_hash + + while true; do + current_hash=$(calculate_hash) + if [[ "$current_hash" != "$previous_hash" ]]; then + # TODO: add an option to restart at a specific time interval + echo "[INFO] Certificate's folder hash was changed, reloading nginx, dovecot and postfix services." + systemctl reload nginx.service + systemctl reload dovecot.service + systemctl reload postfix.service + previous_hash=$current_hash + fi + sleep $CERTS_MONITORING_TIMEOUT + done +} + +monitor_certificates & +EOF +``` + +4. Перезапустить сервис +```shell +docker compose down +docker compose up -d +```