diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..37c3d8664 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +data/ +venv/ +__pycache__ +*.pyc +*.orig +*.ini +.pytest_cache +.env + +# Slim build context — .git/ alone can be 100s of MB +.git +.github/ +docs/ +tests/ + +# Exclude markdown files but keep www/src/*.md (used by WebsiteDeployer) +*.md +!www/**/*.md diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml index 990963eca..c2d4c6857 100644 --- a/.github/workflows/test-and-deploy-ipv4only.yaml +++ b/.github/workflows/test-and-deploy-ipv4only.yaml @@ -4,6 +4,7 @@ on: push: branches: - main + - j4n/docker-pr pull_request: paths-ignore: - 'scripts/**' @@ -11,7 +12,67 @@ on: - 'CHANGELOG.md' - 'LICENSE' +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: + build-docker: + name: Build Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.image-ref.outputs.image }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # Tagged releases: v1.2.3 -> :1.2.3, :1.2, :latest + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + # Branch pushes: foo/docker-pr -> :foo-docker-pr + type=ref,event=branch + # Always: :sha- + type=sha + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/chatmail_relay.dockerfile + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + GIT_HASH=${{ github.sha }} + + - name: Output image reference + id: image-ref + run: | + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + deploy: name: deploy on staging-ipv4.testrun.org, and run tests runs-on: ubuntu-latest @@ -55,6 +116,7 @@ jobs: run: echo venv/bin >>$GITHUB_PATH - name: upload TLS cert after rebuilding + id: wait-for-vps run: | echo " --- wait until staging-ipv4.testrun.org VPS is rebuilt --- " rm ~/.ssh/known_hosts @@ -68,8 +130,8 @@ jobs: rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true - - name: run deploy-chatmail offline tests - run: pytest --pyargs cmdeploy + - name: run deploy-chatmail offline tests + run: pytest --pyargs cmdeploy - name: setup dependencies run: | @@ -102,3 +164,133 @@ jobs: - name: cmdeploy dns run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost" + # --- Docker deploy (push only, runs even if bare failed) --- + + - name: stop bare services + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + ssh root@staging-ipv4.testrun.org 'systemctl stop postfix dovecot nginx opendkim unbound filtermail doveauth chatmail-metadata iroh-relay mtail fcgiwrap acmetool 2>/dev/null || true' + + - name: install Docker on VPS + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + ssh root@staging-ipv4.testrun.org 'apt-get update && apt-get install -y ca-certificates curl' + ssh root@staging-ipv4.testrun.org 'install -m 0755 -d /etc/apt/keyrings' + ssh root@staging-ipv4.testrun.org 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && chmod a+r /etc/apt/keyrings/docker.asc' + ssh root@staging-ipv4.testrun.org 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list' + ssh root@staging-ipv4.testrun.org 'apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin' + + - name: prepare Docker bind mounts + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + ssh root@staging-ipv4.testrun.org 'mkdir -p /srv/chatmail/certs /srv/chatmail/dkim' + ssh root@staging-ipv4.testrun.org 'cp -a /var/lib/acme/. /srv/chatmail/certs/ && cp -a /etc/dkimkeys/. /srv/chatmail/dkim/' || true + + - name: upload chatmail.ini for Docker + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + # Reuse chatmail.ini already created by the bare-metal deploy steps + ssh root@staging-ipv4.testrun.org "cp relay/chatmail.ini /srv/chatmail/chatmail.ini" + + - name: deploy with Docker + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + GHCR_IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}" + rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ root@staging-ipv4.testrun.org:/srv/chatmail/relay/ + # Login to GHCR on VPS and pull pre-built image + echo "${{ secrets.GITHUB_TOKEN }}" | ssh root@staging-ipv4.testrun.org 'docker login ghcr.io -u ${{ github.actor }} --password-stdin' + ssh root@staging-ipv4.testrun.org "docker pull ${GHCR_IMAGE}" + ssh root@staging-ipv4.testrun.org "cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=staging-ipv4.testrun.org docker compose -f docker/docker-compose.yaml -f docker/docker-compose.ci.yaml up -d" + + - name: wait for container healthy + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + # Stream journald inside the container + ssh root@staging-ipv4.testrun.org 'docker exec chatmail journalctl -f --no-pager' & + LOG_PID=$! + trap "kill $LOG_PID 2>/dev/null || true" EXIT + for i in $(seq 1 60); do + status=$(ssh root@staging-ipv4.testrun.org 'docker inspect --format={{.State.Health.Status}} chatmail 2>/dev/null' || echo "missing") + echo " [$i/60] status=$status" + if [ "$status" = "healthy" ]; then + echo "Container is healthy." + exit 0 + fi + if [ "$status" = "unhealthy" ]; then + echo "Container is unhealthy!" + break + fi + sleep 5 + done + echo "Container did not become healthy." + kill $LOG_PID 2>/dev/null || true + echo "--- failed units ---" + ssh root@staging-ipv4.testrun.org 'docker exec chatmail systemctl --failed --no-pager' || true + echo "--- service logs ---" + ssh root@staging-ipv4.testrun.org 'docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50' || true + echo "--- listening ports ---" + ssh root@staging-ipv4.testrun.org 'docker exec chatmail ss -tlnp' || true + echo "--- chatmail.ini ---" + ssh root@staging-ipv4.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini' || true + exit 1 + + - name: show container state + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + echo "--- listening ports ---" + ssh root@staging-ipv4.testrun.org 'docker exec chatmail ss -tlnp' + echo "--- chatmail.ini ---" + ssh root@staging-ipv4.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini' + + - name: Docker integration tests + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy test --slow --ssh-host @local' + + - name: Docker DNS + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + # Reset zone file in case bare DNS already appended to it + git checkout .github/workflows/staging-ipv4.testrun.org-default.zone + ssh root@staging-ipv4.testrun.org 'docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys' + ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose' + ssh root@staging-ipv4.testrun.org 'docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone' + scp root@staging-ipv4.testrun.org:/tmp/staging.zone staging-generated.zone + cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone + cat .github/workflows/staging-ipv4.testrun.org-default.zone + scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone + ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone + ssh root@ns.testrun.org systemctl reload nsd + + - name: Docker final DNS check + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy dns -v --ssh-host @local' + + # --- Cleanup --- + + - name: add SSH keys + if: >- + !cancelled() + && steps.wait-for-vps.outcome == 'success' + run: ssh root@staging-ipv4.testrun.org 'curl -s https://github.com/hpk42.keys https://github.com/j4n.keys >> .ssh/authorized_keys' diff --git a/.github/workflows/test-and-deploy.yaml b/.github/workflows/test-and-deploy.yaml index 2f744cb8e..18f34d12f 100644 --- a/.github/workflows/test-and-deploy.yaml +++ b/.github/workflows/test-and-deploy.yaml @@ -4,6 +4,7 @@ on: push: branches: - main + - j4n/docker-pr pull_request: paths-ignore: - 'scripts/**' @@ -11,7 +12,67 @@ on: - 'CHANGELOG.md' - 'LICENSE' +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + jobs: + build-docker: + name: Build Docker image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + outputs: + image: ${{ steps.image-ref.outputs.image }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + if: github.event_name == 'push' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # Tagged releases: v1.2.3 -> :1.2.3, :1.2, :latest + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + # Branch pushes: foo/docker-pr -> :foo-docker-pr + type=ref,event=branch + # Always: :sha- + type=sha + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docker/chatmail_relay.dockerfile + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + GIT_HASH=${{ github.sha }} + + - name: Output image reference + id: image-ref + run: | + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + deploy: name: deploy on staging2.testrun.org, and run tests runs-on: ubuntu-latest @@ -55,6 +116,7 @@ jobs: run: echo venv/bin >>$GITHUB_PATH - name: upload TLS cert after rebuilding + id: wait-for-vps run: | echo " --- wait until staging2.testrun.org VPS is rebuilt --- " rm ~/.ssh/known_hosts @@ -71,8 +133,8 @@ jobs: - name: add hpk42 key to staging server run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys >> .ssh/authorized_keys' - - name: run deploy-chatmail offline tests - run: pytest --pyargs cmdeploy + - name: run deploy-chatmail offline tests + run: pytest --pyargs cmdeploy - run: | cmdeploy init staging2.testrun.org @@ -95,3 +157,133 @@ jobs: - name: cmdeploy dns run: cmdeploy dns -v + # --- Docker deploy (push only, runs even if bare failed) --- + + - name: stop bare services + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + ssh root@staging2.testrun.org 'systemctl stop postfix dovecot nginx opendkim unbound filtermail doveauth chatmail-metadata iroh-relay mtail fcgiwrap acmetool 2>/dev/null || true' + + - name: install Docker on VPS + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + ssh root@staging2.testrun.org 'apt-get update && apt-get install -y ca-certificates curl' + ssh root@staging2.testrun.org 'install -m 0755 -d /etc/apt/keyrings' + ssh root@staging2.testrun.org 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && chmod a+r /etc/apt/keyrings/docker.asc' + ssh root@staging2.testrun.org 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list' + ssh root@staging2.testrun.org 'apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin' + + - name: prepare Docker bind mounts + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + ssh root@staging2.testrun.org 'mkdir -p /srv/chatmail/certs /srv/chatmail/dkim' + ssh root@staging2.testrun.org 'cp -a /var/lib/acme/. /srv/chatmail/certs/ && cp -a /etc/dkimkeys/. /srv/chatmail/dkim/' || true + + - name: upload chatmail.ini for Docker + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + # Reuse chatmail.ini already created by the bare-metal deploy steps + scp chatmail.ini root@staging2.testrun.org:/srv/chatmail/chatmail.ini + + - name: deploy with Docker + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + GHCR_IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}" + rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ root@staging2.testrun.org:/srv/chatmail/relay/ + # Login to GHCR on VPS and pull pre-built image + echo "${{ secrets.GITHUB_TOKEN }}" | ssh root@staging2.testrun.org 'docker login ghcr.io -u ${{ github.actor }} --password-stdin' + ssh root@staging2.testrun.org "docker pull ${GHCR_IMAGE}" + ssh root@staging2.testrun.org "cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=staging2.testrun.org docker compose -f docker/docker-compose.yaml -f docker/docker-compose.ci.yaml up -d" + + - name: wait for container healthy + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + # Stream journald inside the container + ssh root@staging2.testrun.org 'docker exec chatmail journalctl -f --no-pager' & + LOG_PID=$! + trap "kill $LOG_PID 2>/dev/null || true" EXIT + for i in $(seq 1 60); do + status=$(ssh root@staging2.testrun.org 'docker inspect --format={{.State.Health.Status}} chatmail 2>/dev/null' || echo "missing") + echo " [$i/60] status=$status" + if [ "$status" = "healthy" ]; then + echo "Container is healthy." + exit 0 + fi + if [ "$status" = "unhealthy" ]; then + echo "Container is unhealthy!" + break + fi + sleep 5 + done + echo "Container did not become healthy." + kill $LOG_PID 2>/dev/null || true + echo "--- failed units ---" + ssh root@staging2.testrun.org 'docker exec chatmail systemctl --failed --no-pager' || true + echo "--- service logs ---" + ssh root@staging2.testrun.org 'docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50' || true + echo "--- listening ports ---" + ssh root@staging2.testrun.org 'docker exec chatmail ss -tlnp' || true + echo "--- chatmail.ini ---" + ssh root@staging2.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini' || true + exit 1 + + - name: show container state + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + echo "--- listening ports ---" + ssh root@staging2.testrun.org 'docker exec chatmail ss -tlnp' + echo "--- chatmail.ini ---" + ssh root@staging2.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini' + + - name: Docker integration tests + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy test --slow --ssh-host @local' + + - name: Docker DNS + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: | + # Reset zone file in case bare DNS already appended to it + git checkout .github/workflows/staging.testrun.org-default.zone + ssh root@staging2.testrun.org 'docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys' + ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose' + ssh root@staging2.testrun.org 'docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone' + scp root@staging2.testrun.org:/tmp/staging.zone staging-generated.zone + cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone + cat .github/workflows/staging.testrun.org-default.zone + scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone + ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone + ssh root@ns.testrun.org systemctl reload nsd + + - name: Docker final DNS check + if: >- + !cancelled() && github.event_name == 'push' + && steps.wait-for-vps.outcome == 'success' + run: ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy dns -v --ssh-host @local' + + # --- Cleanup --- + + - name: add SSH keys + if: >- + !cancelled() + && steps.wait-for-vps.outcome == 'success' + run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys https://github.com/j4n.keys >> .ssh/authorized_keys' diff --git a/.gitignore b/.gitignore index c0f40b9b1..db23c96e9 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,9 @@ cython_debug/ #.idea/ chatmail.zone + +# docker +/data/ +/custom/ +docker/docker-compose.override.yaml +docker/.env diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index aace16932..a070b096c 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -108,10 +108,7 @@ def run_cmd(args, out): pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" - if ssh_host in ["localhost", "@docker"]: - if ssh_host == "@docker": - env["CHATMAIL_NOPORTCHECK"] = "True" - env["CHATMAIL_NOSYSCTL"] = "True" + if ssh_host == "localhost": cmd = f"{pyinf} @local {deploy_path} -y" if version.parse(pyinfra.__version__) < version.parse("3"): @@ -316,7 +313,7 @@ def add_ssh_host_option(parser): parser.add_argument( "--ssh-host", dest="ssh_host", - help="Run commands on 'localhost', via '@docker', or on a specific SSH host " + help="Run commands on 'localhost' or on a specific SSH host " "instead of chatmail.ini's mail_domain.", ) @@ -378,9 +375,7 @@ def get_parser(): def get_sshexec(ssh_host: str, verbose=True): if ssh_host in ["localhost", "@local"]: - return LocalExec(verbose, docker=False) - elif ssh_host == "@docker": - return LocalExec(verbose, docker=True) + return LocalExec(verbose) if verbose: print(f"[ssh] login to {ssh_host}") return SSHExec(ssh_host, verbose=verbose) diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 11536062d..daf29ec0b 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -2,7 +2,6 @@ Chat Mail pyinfra deploy. """ -import os import shutil import subprocess import sys @@ -14,6 +13,7 @@ from pyinfra.api import FactBase from pyinfra.facts import hardware from pyinfra.facts.files import Sha256File +from pyinfra.facts.server import Command from pyinfra.facts.systemd import SystemdEnabled from pyinfra.operations import apt, files, pip, server, systemd @@ -593,7 +593,7 @@ 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) - if not os.environ.get("CHATMAIL_NOPORTCHECK"): + if host.get_fact(Command, "systemd-detect-virt -c || true") == "none": port_services = [ (["master", "smtpd"], 25), ("unbound", 53), diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index 7a36d0a17..7230350b6 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -1,10 +1,9 @@ -import os import urllib.request from chatmaild.config import Config from pyinfra import host from pyinfra.facts.deb import DebPackages -from pyinfra.facts.server import Arch, Sysctl +from pyinfra.facts.server import Arch, Command, Sysctl from pyinfra.operations import apt, files, server, systemd from cmdeploy.basedeploy import ( @@ -140,19 +139,25 @@ 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 - if not os.environ.get("CHATMAIL_NOSYSCTL"): - 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, + can_modify = host.get_fact(Command, "systemd-detect-virt -c || true") == "none" + for name in ("max_user_instances", "max_user_watches"): + key = f"fs.inotify.{name}" + value = host.get_fact(Sysctl)[key] + if value > 65534: + continue + if not can_modify: + print( + "\n!!!! refusing to attempt sysctl setting in shared-kernel containers\n" + f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n" + "!!!!" ) + 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/sshexec.py b/cmdeploy/src/cmdeploy/sshexec.py index 7470d9277..6a37b9398 100644 --- a/cmdeploy/src/cmdeploy/sshexec.py +++ b/cmdeploy/src/cmdeploy/sshexec.py @@ -87,9 +87,8 @@ def logged(self, call, kwargs): class LocalExec: FuncError = FuncError - def __init__(self, verbose=False, docker=False): + def __init__(self, verbose=False): self.verbose = verbose - self.docker = docker def __call__(self, call, kwargs=None, log_callback=None): if kwargs is None: @@ -101,10 +100,6 @@ def logged(self, call, kwargs: dict): if not title: title = call.__name__ where = "locally" - if self.docker: - if call == remote.rdns.perform_initial_checks: - kwargs["pre_command"] = "docker exec chatmail " - where = "in docker" if self.verbose: print_stderr(f"Running {where}: {title}(**{kwargs})") return self(call, kwargs, log_callback=print_stderr) diff --git a/doc/source/docker.rst b/doc/source/docker.rst new file mode 100644 index 000000000..c8de5be00 --- /dev/null +++ b/doc/source/docker.rst @@ -0,0 +1,264 @@ +Docker installation +=================== + +This section provides instructions for installing a chatmail relay +using Docker Compose. + +.. note:: + + - Docker support is experimental, CI builds and tests the image automatically, but please report bugs. + - The image wraps the cmdeploy process detailed in the :doc:`getting_started` instructions in a Debian-systemd image with r/w access to `/sys/fs` + - Currently amd64-only (arm64 should work but is untested). + + +Setup Preparation +----------------- + +We use ``chat.example.org`` as the chatmail domain in the following +steps. Please substitute it with your own domain. + +1. Install docker and docker compose v2 (check with `docker compose version`), install, e.g., on + - Debian 12 through the `official install instructions `_ + - Debian 13+ with `apt install docker docker-compose` + +2. 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.org. 3600 IN A 198.51.100.5 + chat.example.org. 3600 IN AAAA 2001:db8::5 + www.chat.example.org. 3600 IN CNAME chat.example.org. + mta-sts.chat.example.org. 3600 IN CNAME chat.example.org. + +3. Configure kernel parameters on the host, as these can not be set from the container:: + + 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 + + +Docker Compose Setup +-------------------- + +Pre-built images are available from GitHub Container Registry. The +``main`` branch and tagged releases are pushed automatically by CI:: + + docker pull ghcr.io/chatmail/relay:main # latest main branch + docker pull ghcr.io/chatmail/relay:1.2.3 # tagged release + + +Create service directory +^^^^^^^^^^^^^^^^^^^^^^^^ + +Either: + +- Create a service directory and download the compose files:: + + mkdir -p /srv/chatmail-relay && cd /srv/chatmail-relay + wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker/docker-compose.yaml + wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker/docker-compose.override.yaml.example -O docker-compose.override.yaml + +- or clone the chatmail repo and enter the docker directory:: + + git clone https://github.com/chatmail/relay + cd relay/docker + + +Customize and start +^^^^^^^^^^^^^^^^^^^ + +1. Set the fully qualified domain name of the relay:: + + echo 'MAIL_DOMAIN=chat.example.org' > .env + + The container generates a ``chatmail.ini`` with defaults from + ``MAIL_DOMAIN`` on first start. To customize chatmail settings, mount + your own ``chatmail.ini`` instead (see `Custom chatmail.ini`_ below). + +2. All local customizations (data paths, extra volumes, config mounts) go in + ``docker-compose.override.yaml``, which Compose merges automatically with + the base file. By default, all data is stored in docker volumes, you will + likely want to at least create and configure the mail storage location, but + you might also want to configure external TLS certificates there. + +3. Start the container:: + + docker compose up -d + docker compose logs -f chatmail # view logs, Ctrl+C to exit + +4. After installation is complete, open ``https://chat.example.org`` in + your browser. + +Finish install and test +----------------------- + +You can test the installation with:: + + pip install cmping chat.example.org # or + uvx cmping chat.example.org # if you use https://docs.astral.sh/uv/ + +You should check and extend your DNS records for better interoperability:: + + # Show required DNS records + docker exec chatmail cmdeploy dns --ssh-host @local + +You can check server status with:: + + docker exec chatmail cmdeploy status --ssh-host @local + +You can run some benchmarks (can also run from any machine with cmdeploy installed):: + + docker exec chatmail cmdeploy bench + +You can run the test suite with:: + + docker exec chatmail cmdeploy test --ssh-host localhost + +You can look at logs:: + + docker exec chatmail journalctl -fu postfix@- + + +Customization +------------- + +Website +^^^^^^^^^^^^^^ + +You can customize the chatmail landing page by mounting a directory with +your own website source files. + +1. Create a directory with your custom website source:: + + mkdir -p ./custom/www/src + nano ./custom/www/src/index.md + +2. Add the volume mount in ``docker-compose.override.yaml``:: + + services: + chatmail: + volumes: + - ./custom/www:/opt/chatmail-www + +3. Restart the service:: + + docker compose down + docker compose up -d + + +Custom chatmail.ini +^^^^^^^^^^^^^^^^^^^ + +If you want to go beyond simply setting the ``MAIL_DOMAIN`` in ``.env``, you +can use a regular `chatmail.ini` to give you full control. + +1. Extract the generated config from a running container:: + + docker cp chatmail:/etc/chatmail/chatmail.ini ./chatmail.ini + +2. Edit ``chatmail.ini`` as needed. + +3. Add the volume mount in ``docker-compose.override.yaml`` :: + + services: + chatmail: + volumes: + - ./chatmail.ini:/etc/chatmail/chatmail.ini + +4. Restart the container, the container skips generating a new one: :: + + docker compose down && docker compose up -d + + +External TLS certificates +^^^^^^^^^^^^^^^^^^^^^^^^^ + +If TLS certificates are managed outside the container (e.g. by certbot, +acmetool, or Traefik on the host), mount them into the container and set +``TLS_EXTERNAL_CERT_AND_KEY`` in ``docker-compose.override.yaml``. +Changed certificates are picked up automatically via inotify. +See the examples in the example override and :ref:`external-tls` in the getting started guide for details. + + +Migrating from a bare-metal install +------------------------------------ + +If you have an existing bare-metal chatmail installation and want to +switch to Docker: + +1. Stop all existing services:: + + systemctl stop postfix dovecot doveauth nginx opendkim unbound \ + acmetool-redirector filtermail filtermail-incoming chatmail-turn \ + iroh-relay chatmail-metadata lastlogin mtail + systemctl disable postfix dovecot doveauth nginx opendkim unbound \ + acmetool-redirector filtermail filtermail-incoming chatmail-turn \ + iroh-relay chatmail-metadata lastlogin mtail + +2. Copy your existing ``chatmail.ini`` and mount it into the container + (see `Custom chatmail.ini`_ above):: + + cp /usr/local/lib/chatmaild/chatmail.ini ./chatmail.ini + +3. Copy persistent data into the ``./data/`` subdirectories (for example, as configured in `Customize and start`_) :: + + mkdir -p data/dkim data/certs data/mail + + # DKIM keys + cp -a /etc/dkimkeys/* data/dkim/ + + # TLS certificates + rsync -a /var/lib/acme/ data/certs/ + + Note that ownership of dkim and acme is adjusted on container start. + + For the mail directory:: + + rsync -a /home/vmail/ data/mail/ + + Alternatively, mount ``/home/vmail`` directly by changing the volume + in ``docker-compose-override.yaml``:: + + - /home/vmail:/home/vmail + + The three ``./data/`` subdirectories cover all persistent state. + Everything else is regenerated by the ``configure`` and ``activate`` + stages on container start. + +Building the image +------------------ + +Clone the repository and build the Docker image:: + + git clone https://github.com/chatmail/relay + cd relay + docker/build.sh + +The build bakes all binaries, Python packages, and the install stage +into the image. After building, only the ``docker/`` directory and a ``.env`` +with ``MAIL_DOMAIN`` are needed to run the container. The `build.sh` passes the +git hash onto the docker build so it can be determined if there has been a +change that warrants a redeploy. + +You can transfer a locally built image to your server directly (pigz is parallel `gzip` which can be used instead as well) :: + + docker save chatmail-relay:latest | pigz | ssh chat.example.org 'pigz -d | docker load' + + +Forcing a full reinstall +------------------------ + +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:: + + docker compose build chatmail + docker compose up -d + +Or override the stages at runtime without rebuilding:: + + CMDEPLOY_STAGES="install,configure,activate" docker compose up -d diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 28781f28e..259d31b6e 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -98,6 +98,12 @@ steps. Please substitute it with your own domain. configure at your DNS provider (it can take some time until they are public). +Docker installation +------------------- + +There is experimental support for running chatmail via Docker Compose. +See :doc:`docker` for full setup instructions. + Other helpful commands ---------------------- diff --git a/doc/source/index.rst b/doc/source/index.rst index d37a10f68..2ff585997 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -13,6 +13,7 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay :maxdepth: 5 getting_started + docker proxy migrate overview diff --git a/docker/build.sh b/docker/build.sh new file mode 100755 index 000000000..04fa97aa2 --- /dev/null +++ b/docker/build.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Build the chatmail Docker image with the current git hash baked in. +# Usage: ./docker/build.sh [extra docker-compose build args...] +# +# .git/ is excluded from the build context (.dockerignore) so the hash +# must be passed as a build arg from the host. + +export GIT_HASH=$(git rev-parse HEAD) +exec docker compose -f docker/docker-compose.yaml build "$@" diff --git a/docker/chatmail-init.service b/docker/chatmail-init.service new file mode 100644 index 000000000..9e0a517b3 --- /dev/null +++ b/docker/chatmail-init.service @@ -0,0 +1,14 @@ +[Unit] +Description=Run container setup commands +After=multi-user.target +ConditionPathExists=/chatmail-init.sh + +[Service] +Type=oneshot +ExecStart=/bin/bash /chatmail-init.sh +RemainAfterExit=true +WorkingDirectory=/opt/chatmail +PassEnvironment= + +[Install] +WantedBy=multi-user.target diff --git a/docker/chatmail-init.sh b/docker/chatmail-init.sh new file mode 100755 index 000000000..32daff590 --- /dev/null +++ b/docker/chatmail-init.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +set -euo pipefail +export CHATMAIL_INI="${CHATMAIL_INI:-/etc/chatmail/chatmail.ini}" + +CMDEPLOY=/opt/cmdeploy/bin/cmdeploy + +if [ -z "$MAIL_DOMAIN" ]; then + echo "ERROR: Environment variable 'MAIL_DOMAIN' must be set!" >&2 + exit 1 +fi + +# Generate DKIM keys if not mounted +if [ ! -f /etc/dkimkeys/opendkim.private ]; then + /usr/sbin/opendkim-genkey -D /etc/dkimkeys -d "$MAIL_DOMAIN" -s opendkim +fi +# Fix ownership for bind-mounted keys (host opendkim UID may differ from container) +chown -R opendkim:opendkim /etc/dkimkeys + +# Create chatmail.ini, skip if mounted +mkdir -p "$(dirname "$CHATMAIL_INI")" +if [ ! -f "$CHATMAIL_INI" ]; then + $CMDEPLOY init --config "$CHATMAIL_INI" "$MAIL_DOMAIN" +fi + +# Auto-detect IPv6: if the host has no IPv6 connectivity, set disable_ipv6 +# in the ini so dovecot/postfix/nginx bind to IPv4 only. +# Uses network_mode:host so /proc/net/if_inet6 reflects the host's stack. +if [ ! -e /proc/net/if_inet6 ]; then + if grep -q '^disable_ipv6 = False' "$CHATMAIL_INI"; then + sed -i 's/^disable_ipv6 = False/disable_ipv6 = True/' "$CHATMAIL_INI" + echo "[INFO] IPv6 not available, set disable_ipv6 = True" + fi +fi + +# Inject external TLS paths from env var unless defined in chatmail.ini +if [ -n "${TLS_EXTERNAL_CERT_AND_KEY:-}" ]; then + if ! grep -q '^tls_external_cert_and_key' "$CHATMAIL_INI"; then + echo "tls_external_cert_and_key = $TLS_EXTERNAL_CERT_AND_KEY" >> "$CHATMAIL_INI" + fi +fi + +# Ensure mailboxes directory exists (chatmail-metadata needs it at startup, +# but Dovecot only creates it on first mail delivery) +mkdir -p "/home/vmail/mail/${MAIL_DOMAIN}" +chown vmail:vmail "/home/vmail/mail/${MAIL_DOMAIN}" + +# --- Deploy fingerprint: skip cmdeploy run if nothing changed --- +# On restart with identical image+config, systemd already brings up all +# enabled services only configure+activate are needed here. +IMAGE_VERSION_FILE="/etc/chatmail-image-version" +FINGERPRINT_FILE="/etc/chatmail/.deploy-fingerprint" +image_ver="none" +[ -f "$IMAGE_VERSION_FILE" ] && image_ver=$(cat "$IMAGE_VERSION_FILE") +config_hash=$(sha256sum "$CHATMAIL_INI" | cut -c1-16) +current_fp="${image_ver}:${config_hash}" + +# CMDEPLOY_STAGES non-empty in env = operator override -> always run. +# Otherwise, if fingerprint matches the last successful deploy, skip. +if [ -z "${CMDEPLOY_STAGES:-}" ] \ + && [ -f "$FINGERPRINT_FILE" ] \ + && [ "$(cat "$FINGERPRINT_FILE")" = "$current_fp" ]; then + echo "[INFO] No changes detected ($current_fp), skipping deploy." +else + export CMDEPLOY_STAGES="${CMDEPLOY_STAGES:-configure,activate}" + + # Skip DNS check when MAIL_DOMAIN is a bare IP address + SKIP_DNS="" + if [[ "$MAIL_DOMAIN" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "$MAIL_DOMAIN" =~ : ]]; then + SKIP_DNS="--skip-dns-check" + fi + $CMDEPLOY run --config "$CHATMAIL_INI" --ssh-host @local $SKIP_DNS + + # Restore the build-time hash + cp /etc/chatmail-image-version /etc/chatmail-version + echo "$current_fp" > "$FINGERPRINT_FILE" +fi + +# Signal success to Docker healthcheck +touch /run/chatmail-init.done + +# Forward journald to console so `docker compose logs` works +grep -q '^ForwardToConsole=yes' /etc/systemd/journald.conf \ + || echo "ForwardToConsole=yes" >> /etc/systemd/journald.conf +systemctl restart systemd-journald diff --git a/docker/chatmail_relay.dockerfile b/docker/chatmail_relay.dockerfile new file mode 100644 index 000000000..2e91f9ba7 --- /dev/null +++ b/docker/chatmail_relay.dockerfile @@ -0,0 +1,108 @@ +# syntax=docker/dockerfile:1 +FROM jrei/systemd-debian:12 AS base + +ENV LANG=en_US.UTF-8 + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \ + 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 && \ + DEBIAN_FRONTEND=noninteractive TZ=UTC \ + apt-get install -y \ + ca-certificates \ + gcc \ + git \ + python3 \ + python3-dev \ + python3-venv \ + tzdata \ + locales && \ + sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \ + dpkg-reconfigure --frontend=noninteractive locales && \ + update-locale LANG=$LANG + +# --- Build-time: install cmdeploy venv and run install stage --- +# Editable install so importlib.resources reads directly from the source tree. +# On container start only "configure,activate" stages run. + +# Copy dependency metadata first so pip install layer is cached +COPY cmdeploy/pyproject.toml /opt/chatmail/cmdeploy/pyproject.toml +COPY chatmaild/pyproject.toml /opt/chatmail/chatmaild/pyproject.toml + +# Dummy scaffolding so editable install can discover packages +RUN mkdir -p /opt/chatmail/cmdeploy/src/cmdeploy \ + /opt/chatmail/chatmaild/src/chatmaild && \ + touch /opt/chatmail/cmdeploy/src/cmdeploy/__init__.py \ + /opt/chatmail/chatmaild/src/chatmaild/__init__.py + +# Dummy git repo: .git/ is excluded from the build context (.dockerignore) +# but setuptools calls `git ls-files` when building the sdist. +WORKDIR /opt/chatmail +RUN --mount=type=cache,target=/root/.cache/pip \ + git init -q && \ + python3 -m venv /opt/cmdeploy && \ + /opt/cmdeploy/bin/pip install -e chatmaild/ -e cmdeploy/ + +# Full source copy (editable install's .egg-link still points here) +COPY . /opt/chatmail/ + +# Minimal chatmail.ini +RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini + +RUN CMDEPLOY_STAGES=install \ + CHATMAIL_INI=/tmp/chatmail.ini \ + /opt/cmdeploy/bin/pyinfra @local \ + /opt/chatmail/cmdeploy/src/cmdeploy/run.py -y + +RUN cp -a www/ /opt/chatmail-www/ + +# Remove build-only packages — not needed at runtime. +# Keep git: test_deployed_state needs `git rev-parse HEAD` to verify the +# deployed version hash matches /etc/chatmail-version. +RUN apt-get purge -y gcc python3-dev && \ + apt-get autoremove -y && \ + rm -f /tmp/chatmail.ini + +# Record image version (used in deploy fingerprint at runtime). +# GIT_HASH is passed as a build arg (from docker-compose or CI) so that +# .git/ can be excluded from the build context via .dockerignore. +# Two files: chatmail-image-version is the immutable build hash (survives +# deploys); chatmail-version is overwritten by cmdeploy run and restored +# from the image version after each deploy in chatmail-init.sh. +ARG GIT_HASH=unknown +RUN echo "$GIT_HASH" > /etc/chatmail-image-version && \ + echo "$GIT_HASH" > /etc/chatmail-version + +# Mock git HEAD so `git rev-parse HEAD` returns the source repo's commit hash. +# The .git/ dir was created by `git init` earlier (for setuptools); we just +# write the build hash into whatever branch HEAD points to. +RUN head_ref=$(sed 's/^ref: //' /opt/chatmail/.git/HEAD) && \ + mkdir -p "/opt/chatmail/.git/$(dirname "$head_ref")" && \ + echo "$GIT_HASH" > "/opt/chatmail/.git/$head_ref" +# --- End build-time install --- + +ENV TZ=:/etc/localtime +ENV PATH="/opt/cmdeploy/bin:${PATH}" +RUN ln -s /etc/chatmail/chatmail.ini /opt/chatmail/chatmail.ini + +ARG CHATMAIL_INIT_SERVICE_PATH=/lib/systemd/system/chatmail-init.service +COPY ./docker/chatmail-init.service "$CHATMAIL_INIT_SERVICE_PATH" +RUN ln -sf "$CHATMAIL_INIT_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/chatmail-init.service" + +# Remove default nginx site config at build time (not in entrypoint) +RUN rm -f /etc/nginx/sites-enabled/default + +COPY --chmod=555 ./docker/chatmail-init.sh /chatmail-init.sh +COPY --chmod=555 ./docker/entrypoint.sh /entrypoint.sh +COPY --chmod=555 ./docker/healthcheck.sh /healthcheck.sh + +HEALTHCHECK --interval=10s --start-period=180s --timeout=10s --retries=3 \ + CMD /healthcheck.sh + +STOPSIGNAL SIGRTMIN+3 + +ENTRYPOINT ["/entrypoint.sh"] + +CMD [ "--default-standard-output=journal+console", \ + "--default-standard-error=journal+console" ] diff --git a/docker/docker-compose-traefik.yaml b/docker/docker-compose-traefik.yaml new file mode 100644 index 000000000..2964fcce8 --- /dev/null +++ b/docker/docker-compose-traefik.yaml @@ -0,0 +1,70 @@ +# Traefik reverse-proxy example — use as a compose override: +# +# docker compose -f docker-compose.yaml -f docker-compose-traefik.yaml up -d +# +# Traefik handles HTTP→HTTPS redirect and ACME certificate issuance. +# traefik-certs-dumper extracts the certificates to the filesystem so +# chatmail's Postfix/Dovecot/nginx can use them via TLS_EXTERNAL_CERT_AND_KEY. +# +# Prerequisites: +# mkdir -p traefik/data traefik/dynamic-configs +# touch traefik/data/acme.json && chmod 600 traefik/data/acme.json +# cp traefik/config.yaml.example traefik/config.yaml # see below +# +# Required .env variables (in addition to MAIL_DOMAIN): +# ACME_EMAIL=admin@example.org + +services: + chatmail: + environment: + # Point chatmail at the certs dumped by traefik-certs-dumper. + # The container's tls-cert-reload.path watches for changes. + TLS_EXTERNAL_CERT_AND_KEY: >- + /traefik-certs/${MAIL_DOMAIN}/certificate.crt + /traefik-certs/${MAIL_DOMAIN}/privatekey.key + volumes: + - traefik-certs:/traefik-certs:ro + depends_on: + - traefik-certs-dumper + labels: + - traefik.enable=true + - traefik.http.services.chatmail.loadbalancer.server.scheme=https + - traefik.http.services.chatmail.loadbalancer.server.port=443 + - traefik.http.routers.chatmail.rule=Host(`${MAIL_DOMAIN}`) || Host(`mta-sts.${MAIL_DOMAIN}`) || Host(`www.${MAIL_DOMAIN}`) + - traefik.http.routers.chatmail.tls=true + - traefik.http.routers.chatmail.tls.certresolver=letsEncrypt + + traefik: + image: traefik:v3.3 + container_name: traefik + restart: unless-stopped + network_mode: host + command: + - "--configFile=/config.yaml" + - "--certificatesresolvers.letsEncrypt.acme.email=${ACME_EMAIL}" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./traefik/config.yaml:/config.yaml:ro + - ./traefik/data/acme.json:/acme.json + - ./traefik/dynamic-configs:/dynamic/conf:ro + + traefik-certs-dumper: + image: ldez/traefik-certs-dumper:v2.10.0 + restart: unless-stopped + depends_on: + - traefik + entrypoint: sh -c ' + apk add openssl + && while ! [ -e /data/acme.json ] + || ! [ $$(jq ".[] | .Certificates | length" /data/acme.json | jq -s "add") != 0 ]; do + sleep 1; + done + && traefik-certs-dumper file + --version v3 --watch --domain-subdir=true + --source /data/acme.json --dest /certs' + volumes: + - ./traefik/data/acme.json:/data/acme.json:ro + - traefik-certs:/certs + +volumes: + traefik-certs: diff --git a/docker/docker-compose.ci.yaml b/docker/docker-compose.ci.yaml new file mode 100644 index 000000000..760ad4519 --- /dev/null +++ b/docker/docker-compose.ci.yaml @@ -0,0 +1,11 @@ +# Used by .github/workflows/docker-ci.yaml +# The GHCR image is set via CHATMAIL_IMAGE env var at deploy time. +services: + chatmail: + image: ${CHATMAIL_IMAGE:-chatmail-relay:latest} + volumes: + - /srv/chatmail/chatmail.ini:/etc/chatmail/chatmail.ini + - /srv/chatmail/dkim:/etc/dkimkeys + - /srv/chatmail/certs:/var/lib/acme + environment: + TLS_EXTERNAL_CERT_AND_KEY: /var/lib/acme/live/${MAIL_DOMAIN}/fullchain /var/lib/acme/live/${MAIL_DOMAIN}/privkey diff --git a/docker/docker-compose.override.yaml.example b/docker/docker-compose.override.yaml.example new file mode 100644 index 000000000..c7ad53696 --- /dev/null +++ b/docker/docker-compose.override.yaml.example @@ -0,0 +1,44 @@ +# Local overrides: copy to docker-compose.override.yaml in this directory. +# Compose automatically merges this with docker-compose.yaml. +# +# cp docker-compose.override.yaml.example docker-compose.override.yaml +# +# Volumes are APPENDED to the base file's volumes list, environment and other scalar keys are MERGED by key. +services: + chatmail: + volumes: + ## Data paths — bind-mount to host directories for easy access/backup. + + # - ./data/dkim:/etc/dkimkeys + # - ./data/certs:/var/lib/acme + + # - ./data/mail:/home/vmail + ## Or mount from an existing bare-metal install. + # - /home/vmail:/home/vmail + + ## Mount your own chatmail.ini (skips auto-generation): + # - ./chatmail.ini:/etc/chatmail/chatmail.ini + + ## Custom website: + # - ./custom/www:/opt/chatmail-www + + ## Debug — mount scripts for live editing: + # - ./chatmail-init.sh:/chatmail-init.sh + # - ./entrypoint.sh:/entrypoint.sh + + # environment: + ## Mount certs (above) and set TLS_EXTERNAL_CERT_AND_KEY to in-container paths. + ## A tls-cert-reload.path watcher inside the container reloads services + ## when the cert file changes. However, inotify does not cross bind-mount + ## boundaries, so host-side renewals (certbot, acmetool, etc.) must + ## notify the container explicitly. Add this to your renewal hook: + ## + ## docker exec chatmail systemctl start tls-cert-reload.service + ## + ## Host acmetool (bare-metal migration): create mount above, and + ## rsync -a /var/lib/acme/live data/certs + # TLS_EXTERNAL_CERT_AND_KEY: "/var/lib/acme/live/${MAIL_DOMAIN}/fullchain /var/lib/acme/live/${MAIL_DOMAIN}/privkey" + ## + ## (Untested) Traefik certs-dumper (see docker/docker-compose-traefik.yaml) - also add volume: + ## - traefik-certs:/certs:ro + # TLS_EXTERNAL_CERT_AND_KEY: "/certs/${MAIL_DOMAIN}/certificate.crt /certs/${MAIL_DOMAIN}/privatekey.key" diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 000000000..8ec99e684 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,48 @@ +# Base compose file — do not edit. Put customizations (data paths, extra +# volumes, env overrides) in docker-compose.override.yaml instead. +# See docker-compose.override.yaml.example in this directory for a starting point. +# +# Security notes: this container uses +# - network_mode:host chatmail needs many ports (25, 53, 80, 143, 443, 465, +# 587, 993, 3340, 8443) and needs to operate from the real IP, which bridging +# would make tricky +# - cgroup:host (required for systemd). +# Together these give the container near-host-level access. This is acceptable +# for a dedicated mail server, but be aware that the container can bind any +# port and see all host network traffic. + +services: + chatmail: + build: + context: ../ + dockerfile: docker/chatmail_relay.dockerfile + args: + GIT_HASH: ${GIT_HASH:-unknown} + image: chatmail-relay:latest + restart: unless-stopped + container_name: chatmail + # Required for systemd — use only one of the following: + cgroup: host # compose v2 + # privileged: true # compose v1 (less restricted) + tty: true # required for logs + tmpfs: # required for systemd + - /tmp + - /run + - /run/lock + logging: + driver: none + environment: + MAIL_DOMAIN: $MAIL_DOMAIN + network_mode: "host" + volumes: + ## system (required) + - /sys/fs/cgroup:/sys/fs/cgroup:rw + ## data (defaults — override in docker-compose.override.yaml) + - mail:/home/vmail + - dkim:/etc/dkimkeys + - certs:/var/lib/acme + +volumes: + mail: + dkim: + certs: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..3e0e348a7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -eo pipefail + +CHATMAIL_INIT_SERVICE_PATH="${CHATMAIL_INIT_SERVICE_PATH:-/lib/systemd/system/chatmail-init.service}" + +env_vars="MAIL_DOMAIN CMDEPLOY_STAGES CHATMAIL_INI TLS_EXTERNAL_CERT_AND_KEY PATH" +sed -i "s||$env_vars|g" "$CHATMAIL_INIT_SERVICE_PATH" + +exec /lib/systemd/systemd "$@" diff --git a/docker/env.example b/docker/env.example new file mode 100644 index 000000000..3eebb3731 --- /dev/null +++ b/docker/env.example @@ -0,0 +1 @@ +MAIL_DOMAIN=chat.example.com diff --git a/docker/healthcheck.sh b/docker/healthcheck.sh new file mode 100644 index 000000000..88d9f8067 --- /dev/null +++ b/docker/healthcheck.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# returns 0 when chatmail-init succeeded and all expected services are running. + +set -e + +test -f /run/chatmail-init.done + +# Core services +services="chatmail-metadata doveauth dovecot filtermail filtermail-incoming nginx postfix unbound" + +# Optional services +for svc in iroh-relay turnserver; do + systemctl is-enabled "$svc" 2>/dev/null && services="$services $svc" +done + +exec systemctl is-active $services