Compare commits

..

1 Commits

Author SHA1 Message Date
holger krekel
d32b2497ed apply cmdeploy fmt linting, no content changes 2026-04-28 21:31:38 +02:00
38 changed files with 178 additions and 527 deletions

View File

@@ -1,35 +0,0 @@
name: No-DNS
on:
# Triggers when a PR is merged into main or a direct push occurs
push:
branches: [ "main" ]
# Triggers for any PR (and its subsequent commits) targeting the main branch
pull_request:
branches: [ "main" ]
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
no-dns:
name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@d39fe34c39cee6d760c3479325e8dc82b66a8928
with:
cmlxc_commands: |
cmlxc init
# single cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --no-dns cm0
cmlxc -v test-cmdeploy --no-dns cm0
# cross cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo cm1
cmlxc -v test-cmdeploy --no-dns cm0 cm1
# cross cmdeploy/madmail relay tests
cmlxc -v deploy-madmail mad0
cmlxc -v test-cmdeploy --no-dns cm0 mad0

View File

@@ -1,4 +1,4 @@
name: CI name: Run unit-tests and container-based deploy+test verification
on: on:
# Triggers when a PR is merged into main or a direct push occurs # Triggers when a PR is merged into main or a direct push occurs
@@ -9,8 +9,6 @@ on:
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
permissions: {}
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts # Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -27,9 +25,8 @@ jobs:
# Otherwise `test_deployed_state` will be unhappy. # Otherwise `test_deployed_state` will be unhappy.
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: download filtermail - name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.4/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox
@@ -41,7 +38,6 @@ jobs:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
@@ -57,7 +53,7 @@ jobs:
lxc-test: lxc-test:
name: LXC deploy and test name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@d39fe34c39cee6d760c3479325e8dc82b66a8928 uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.10.0
with: with:
cmlxc_commands: | cmlxc_commands: |
cmlxc init cmlxc init

View File

@@ -1,37 +0,0 @@
# Notify the docker repo to build and test a new image after relay CI passes.
#
# Sends a repository_dispatch event to chatmail/docker with the relay ref
# and short SHA, which triggers docker-ci.yaml to build, push to GHCR,
# and run integration tests via cmlxc.
name: Trigger Docker build
on:
push:
branches: [main]
workflow_dispatch:
permissions: {}
jobs:
dispatch:
name: Dispatch build to chatmail/docker
runs-on: ubuntu-latest
if: github.repository == 'chatmail/relay'
steps:
- name: Compute short SHA
id: sha
run: echo "short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT"
- name: Send repository_dispatch
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
with:
token: ${{ secrets.CHATMAIL_DOCKER_DISPATCH_TOKEN }}
repository: chatmail/docker
event-type: relay-updated
client-payload: >-
{
"relay_ref": "${{ github.ref_name }}",
"relay_sha": "${{ github.sha }}",
"relay_sha_short": "${{ steps.sha.outputs.short }}"
}

View File

@@ -7,8 +7,6 @@ on:
- 'scripts/build-docs.sh' - 'scripts/build-docs.sh'
- '.github/workflows/docs-preview.yaml' - '.github/workflows/docs-preview.yaml'
permissions: {}
jobs: jobs:
scripts: scripts:
name: build name: build
@@ -18,8 +16,6 @@ jobs:
url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }} url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
@@ -38,22 +34,18 @@ jobs:
- name: Get Pullrequest ID - name: Get Pullrequest ID
id: prepare id: prepare
run: | run: |
export PULLREQUEST_ID=$(echo "${GITHUB_REF}" | cut -d "/" -f3) export PULLREQUEST_ID=$(echo "${{ github.ref }}" | cut -d "/" -f3)
echo "prid=$PULLREQUEST_ID" >> $GITHUB_OUTPUT echo "prid=$PULLREQUEST_ID" >> $GITHUB_OUTPUT
if [ $(expr length "${{ secrets.USERNAME }}") -gt "1" ]; then echo "uploadtoserver=true" >> $GITHUB_OUTPUT; fi if [ $(expr length "${{ secrets.USERNAME }}") -gt "1" ]; then echo "uploadtoserver=true" >> $GITHUB_OUTPUT; fi
- run: | - run: |
echo "baseurl: /${STEPS_PREPARE_OUTPUTS_PRID}" >> _config.yml echo "baseurl: /${{ steps.prepare.outputs.prid }}" >> _config.yml
env:
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
- name: Upload preview - name: Upload preview
run: | run: |
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/staging.chatmail.at/doc/relay/${STEPS_PREPARE_OUTPUTS_PRID}/" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}/"
env:
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
- name: check links - name: check links
working-directory: doc working-directory: doc

View File

@@ -10,8 +10,6 @@ on:
- 'scripts/build-docs.sh' - 'scripts/build-docs.sh'
- '.github/workflows/docs.yaml' - '.github/workflows/docs.yaml'
permissions: {}
jobs: jobs:
scripts: scripts:
name: build name: build
@@ -21,8 +19,6 @@ jobs:
url: https://chatmail.at/doc/relay/ url: https://chatmail.at/doc/relay/
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh

View File

@@ -1,26 +0,0 @@
name: GitHub Actions Security Analysis with zizmor
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3

7
.github/zizmor.yml vendored
View File

@@ -1,7 +0,0 @@
rules:
unpinned-uses:
config:
policies:
actions/*: ref-pin
dependabot/*: ref-pin
chatmail/*: ref-pin

View File

@@ -1,89 +1,5 @@
# Changelog for chatmail deployment # Changelog for chatmail deployment
## 1.10.0 2026-04-30
* start mtail after networking is fully up <https://github.com/chatmail/relay/pull/942>
* support specifying custom filtermail binary through environment variable <https://github.com/chatmail/relay/pull/941>
* add automated zizmor scanning of github workflows <https://github.com/chatmail/relay/pull/938>
* added dispatch for *automated builds of chatmail relay docker images* <https://github.com/chatmail/relay/pull/934>
* do not bind SMTP client sockets to public addresses <https://github.com/chatmail/relay/pull/932>
* underline in docs that scripts/initenv.sh should be used for building the docs <https://github.com/chatmail/relay/pull/933>
* automatic oldest-first message removal from mailboxes to always stay under max_mailbox_size <https://github.com/chatmail/relay/pull/929>
* remove --slow from cmdeploy test <https://github.com/chatmail/relay/pull/931>
* handle missing inotify sysctl keys in containers <https://github.com/chatmail/relay/pull/930>
* replace resolvconf with static resolv.conf <https://github.com/chatmail/relay/pull/928>
* disable fsync for LMTP and IMAP services <https://github.com/chatmail/relay/pull/925>
* re-use cmlxc workflow, replacing CI with hetzner staging servers with local lxc containers <https://github.com/chatmail/relay/pull/917>
* explicitly install resolvconf <https://github.com/chatmail/relay/pull/924>
* detect stale dovecot binary and force restart in activate() <https://github.com/chatmail/relay/pull/922>
* Rename filtermail_http_port to filtermail_http_port_incoming <https://github.com/chatmail/relay/pull/921>
* consolidated is_in_container() check https://github.com/chatmail/relay/pull/920>
* restart dovecot after package replacement (rebase, test condense) <https://github.com/chatmail/relay/pull/913>
* Set permissions on dovecot pin prefs <https://github.com/chatmail/relay/pull/915>
* Route `/mxdeliv/` to configurable port <https://github.com/chatmail/relay/pull/901>
* fix VM detection, automated testing fixes, use newer chatmail-turn and move to standard BIND DNS zone format <https://github.com/chatmail/relay/pull/912>
* Upgrade to filtermail 0.6.1 <https://github.com/chatmail/relay/pull/910>
* pin dovecot packages to prevent apt upgrades <https://github.com/chatmail/relay/pull/908>
* add rpc server to cmdeploy along with client <https://github.com/chatmail/relay/pull/906>
* remove unused deps from chatmaild <https://github.com/chatmail/relay/pull/905>
* set default smtp_tls_security_level to "verify" unconditionally <https://github.com/chatmail/relay/pull/902>
* featprefer IPv4 in SMTP client <https://github.com/chatmail/relay/pull/900>
* Install dovecot .deb packages atomically <https://github.com/chatmail/relay/pull/899>
* stop installing cron package <https://github.com/chatmail/relay/pull/898>
* Rewrite dovecot install logic, update <https://github.com/chatmail/relay/pull/862>
* fix a test and some linting fixes <https://github.com/chatmail/relay/pull/897>
* Disable IP verification on domain-literal addresses <https://github.com/chatmail/relay/pull/895>
* disable installing recommended packages globally on the relay <https://github.com/chatmail/relay/pull/887>
* multiple bug fixes across chatmaild and cmdeploy <https://github.com/chatmail/relay/pull/883>
* remove /metrics from the website <https://github.com/chatmail/relay/pull/703>
* add Prometheus textfile output to fsreport <https://github.com/chatmail/relay/pull/881>
* chown opendkim: private key <https://github.com/chatmail/relay/pull/879>
* make sure chatmail-metadata was started <https://github.com/chatmail/relay/pull/882>
* dovecot update url <https://github.com/chatmail/relay/pull/880>
* upgrade to filtermail v0.5.2 <https://github.com/chatmail/relay/pull/876>
* download dovecot packages from github release <https://github.com/chatmail/relay/pull/875>
* replace DKIM verification with filtermail v0.5 <https://github.com/chatmail/relay/pull/831>
* remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings <https://github.com/chatmail/relay/pull/872>
* prepare chatmaild/cmdeploy changes for Docker support <https://github.com/chatmail/relay/pull/857>
* stabilize online benchmark timing adding rate-limit-aware cooldown between iterations <https://github.com/chatmail/relay/pull/867>
* move rate-limit cooldown to benchmark fixture <https://github.com/chatmail/relay/pull/868>
* reconfigure acmetool from redirector to proxy mode <https://github.com/chatmail/relay/pull/861>
* make tests work with `--ssh-host localhost` <https://github.com/chatmail/relay/pull/856>
* mark f-string with f prefix in test_expunged <https://github.com/chatmail/relay/pull/863>
* install also if dovecot.service=False in SystemdEnabled Fact <https://github.com/chatmail/relay/pull/841>
* Introduce support for self-signed chatmail relays <https://github.com/chatmail/relay/pull/855>
* Strip Received headers before delivery <https://github.com/chatmail/relay/pull/849>
* upgrade to filtermail v0.3 <https://github.com/chatmail/relay/pull/850>
* fix link to Maddy and update madmail URL <https://github.com/chatmail/relay/pull/847>
* accept self-signed certificates for IP-only relays <https://github.com/chatmail/relay/pull/846>
* enforce sending from public IP addresses <https://github.com/chatmail/relay/pull/845>
* port check: check addresses, fix single services <https://github.com/chatmail/relay/pull/844>
* remediates issue with improper concat on resolver injection <https://github.com/chatmail/relay/pull/834>
* ipv6 boolean not being respected during operations <https://github.com/chatmail/relay/pull/832>
* upgrade to filtermail v0.2 by <https://github.com/chatmail/relay/pull/825>
* fix link to filtermail <https://github.com/chatmail/relay/pull/824>
* print timestamps when sending messages <https://github.com/chatmail/relay/pull/823>
* fix flaky test_exceed_rate_limit <https://github.com/chatmail/relay/pull/822>
* Replace filtermail with rust reimplementation <https://github.com/chatmail/relay/pull/808>
* Set default internal SMTP ports in Config <https://github.com/chatmail/relay/pull/819>
* separate metrics for incoming and outgoing messages <https://github.com/chatmail/relay/pull/820>
* disable appending the Received header <https://github.com/chatmail/relay/pull/815>
* fail on errors in postfix/dovecot config <https://github.com/chatmail/relay/pull/813>
* tweak idle/hibernate metrics some more <https://github.com/chatmail/relay/pull/811>
* add config flag to export statistics <https://github.com/chatmail/relay/pull/806>
* add --website-only option to run subcommand <https://github.com/chatmail/relay/pull/768>
* Strip DKIM-Signature header before LMTP <https://github.com/chatmail/relay/pull/803>
* properly make sure that postfix gets restarted on failure <https://github.com/chatmail/relay/pull/802>
* expire.py: use absolute path to maildirsize <https://github.com/chatmail/relay/pull/807>
* pin Dovecot documentation URLs to version 2.3 <https://github.com/chatmail/relay/pull/800>
* try to use "build machine" and "deployment server" consistently <https://github.com/chatmail/relay/pull/797>
* adds instructions for migrating control machines <https://github.com/chatmail/relay/pull/795>
* use consistent naming schema in getting started <https://github.com/chatmail/relay/pull/793>
* remove jsok/serialize-workflow-action dependency <https://github.com/chatmail/relay/pull/790>
* streamline migration guide wording, provide titled steps <https://github.com/chatmail/relay/pull/789>
* increases default max mailbox size <https://github.com/chatmail/relay/pull/792>
* use daemon_name for OpenDKIM sign-verify decision instead of IP <https://github.com/chatmail/relay/pull/784>
## 1.9.0 2025-12-18 ## 1.9.0 2025-12-18
### Documentation ### Documentation

View File

@@ -10,7 +10,6 @@ dependencies = [
"filelock", "filelock",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'", "crypt-r >= 3.13.1 ; python_version >= '3.11'",
"domain-validator",
] ]
[tool.setuptools] [tool.setuptools]

View File

@@ -1,8 +1,6 @@
import ipaddress
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
from domain_validator import DomainValidator
from chatmaild.user import User from chatmaild.user import User
@@ -21,19 +19,7 @@ def read_config(inipath):
class Config: class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
raw_domain = params["mail_domain"] self.mail_domain = params["mail_domain"]
self.mail_domain_bare = raw_domain
if is_valid_ipv4(raw_domain):
self.ipv4_relay = raw_domain
self.mail_domain = f"[{raw_domain}]"
self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer
else:
DomainValidator().validate_domain_re(raw_domain)
self.ipv4_relay = None
self.mail_domain = raw_domain
self.postfix_myhostname = raw_domain
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60)) self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10)) self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params["max_mailbox_size"] self.max_mailbox_size = params["max_mailbox_size"]
@@ -54,9 +40,6 @@ class Config:
self.filtermail_http_port_incoming = int( self.filtermail_http_port_incoming = int(
params.get("filtermail_http_port_incoming", "10082") params.get("filtermail_http_port_incoming", "10082")
) )
self.filtermail_lmtp_port_transport = int(
params.get("filtermail_lmtp_port_transport", "10083")
)
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025")) self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.get("postfix_reinject_port_incoming", "10026") params.get("postfix_reinject_port_incoming", "10026")
@@ -67,7 +50,7 @@ class Config:
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true"
if "iroh_relay" not in params: if "iroh_relay" not in params:
self.iroh_relay = "https://" + raw_domain self.iroh_relay = "https://" + params["mail_domain"]
self.enable_iroh_relay = True self.enable_iroh_relay = True
else: else:
self.iroh_relay = params["iroh_relay"].strip() self.iroh_relay = params["iroh_relay"].strip()
@@ -93,17 +76,17 @@ class Config:
) )
self.tls_cert_mode = "external" self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts self.tls_cert_path, self.tls_key_path = parts
elif raw_domain.startswith("_") or self.ipv4_relay: 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"
else: else:
self.tls_cert_mode = "acme" self.tls_cert_mode = "acme"
self.tls_cert_path = f"/var/lib/acme/live/{raw_domain}/fullchain" self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain"
self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey" self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey"
# deprecated option # deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{raw_domain}") mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
self.mailboxes_dir = Path(mbdir.strip()) self.mailboxes_dir = Path(mbdir.strip())
# old unused option (except for first migration from sqlite to maildir store) # old unused option (except for first migration from sqlite to maildir store)
@@ -189,27 +172,3 @@ def get_default_config_content(mail_domain, **overrides):
lines.append(line) lines.append(line)
content = "\n".join(lines) content = "\n".join(lines)
return content return content
def is_valid_ipv4(address: str) -> bool:
"""Check if a mail_domain is an IPv4 address."""
try:
ipaddress.IPv4Address(address)
return True
except ValueError:
return False
def format_arpa_address(address: str) -> str:
if is_valid_ipv4(address):
return ipaddress.IPv4Address(address).reverse_pointer
DomainValidator().validate_domain_re(address)
return address
def format_mail_domain(raw_domain: str) -> str:
if is_valid_ipv4(raw_domain):
return f"[{raw_domain}]"
DomainValidator().validate_domain_re(raw_domain)
return raw_domain

View File

@@ -2,6 +2,7 @@
"""CGI script for creating new accounts.""" """CGI script for creating new accounts."""
import ipaddress
import json import json
import secrets import secrets
import string import string
@@ -14,6 +15,16 @@ ALPHANUMERIC = string.ascii_lowercase + string.digits
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
def wrap_ip(host):
if host.startswith("[") and host.endswith("]"):
return host
try:
ipaddress.ip_address(host)
return f"[{host}]"
except ValueError:
return host
def create_newemail_dict(config: Config): def create_newemail_dict(config: Config):
user = "".join( user = "".join(
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length) secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
@@ -22,22 +33,16 @@ def create_newemail_dict(config: Config):
secrets.choice(ALPHANUMERIC_PUNCT) secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3) for _ in range(config.password_min_length + 3)
) )
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}") return dict(email=f"{user}@{wrap_ip(config.mail_domain)}", password=f"{password}")
def create_dclogin_url(config, email, password): def create_dclogin_url(email, password):
"""Build a dclogin: URL with credentials and self-signed cert acceptance. """Build a dclogin: URL with credentials and self-signed cert acceptance.
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates. can connect to servers with self-signed TLS certificates.
""" """
if config.ipv4_relay: return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3"
imap_host = "&ih=" + config.ipv4_relay
smtp_host = "&sh=" + config.ipv4_relay
else:
imap_host = ""
smtp_host = ""
return f"dclogin:{quote(email, safe='@[]')}?p={quote(password, safe='')}&v=1{imap_host}{smtp_host}&ic=3"
def print_new_account(): def print_new_account():
@@ -46,9 +51,7 @@ def print_new_account():
result = dict(email=creds["email"], password=creds["password"]) result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self": if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url( result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
config, creds["email"], creds["password"]
)
print("Content-Type: application/json") print("Content-Type: application/json")
print("") print("")

View File

@@ -31,11 +31,6 @@ def example_config(make_config):
return make_config("chat.example.org") return make_config("chat.example.org")
@pytest.fixture
def ipv4_config(make_config):
return make_config("1.3.3.7")
@pytest.fixture @pytest.fixture
def maildomain(example_config): def maildomain(example_config):
return example_config.mail_domain return example_config.mail_domain

View File

@@ -1,14 +1,6 @@
from contextlib import nullcontext as does_not_raise
import pytest import pytest
from chatmaild.config import ( from chatmaild.config import parse_size_mb, read_config
format_arpa_address,
format_mail_domain,
is_valid_ipv4,
parse_size_mb,
read_config,
)
def test_read_config_basic(example_config): def test_read_config_basic(example_config):
@@ -21,12 +13,6 @@ def test_read_config_basic(example_config):
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 37 assert example_config.max_user_send_per_minute == 37
assert example_config.mail_domain == "chat.example.org" assert example_config.mail_domain == "chat.example.org"
assert example_config.ipv4_relay is None
def test_read_config_ipv4(ipv4_config):
assert ipv4_config.ipv4_relay == "1.3.3.7"
assert ipv4_config.mail_domain == "[1.3.3.7]"
def test_read_config_basic_using_defaults(tmp_path, maildomain): def test_read_config_basic_using_defaults(tmp_path, maildomain):
@@ -149,45 +135,3 @@ def test_max_mailbox_size_mb(make_config):
config = make_config("chat.example.org") config = make_config("chat.example.org")
assert config.max_mailbox_size == "500M" assert config.max_mailbox_size == "500M"
assert config.max_mailbox_size_mb == 500 assert config.max_mailbox_size_mb == 500
@pytest.mark.parametrize(
["input", "result"],
[
("example.org", False),
("1.3.3.7", True),
("fe::1", False),
("ad.1e.dag.adf", False),
("12394142", False),
],
)
def test_is_valid_ipv4(input, result):
assert result == is_valid_ipv4(input)
@pytest.mark.parametrize(
["input", "result", "exception"],
[
("example.org", "example.org", does_not_raise()),
("1.3.3.7", "7.3.3.1.in-addr.arpa", does_not_raise()),
("fe::1", None, pytest.raises(ValueError)),
("12394142", None, pytest.raises(ValueError)),
],
)
def test_format_arpa_address(input, result, exception):
with exception:
assert result == format_arpa_address(input)
@pytest.mark.parametrize(
["input", "result", "exception"],
[
("example.org", "example.org", does_not_raise()),
("1.3.3.7", "[1.3.3.7]", does_not_raise()),
("fe::1", None, pytest.raises(ValueError)),
("12394142", None, pytest.raises(ValueError)),
],
)
def test_format_mail_domain(input, result, exception):
with exception:
assert result == format_mail_domain(input)

View File

@@ -19,35 +19,24 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"] assert ac1["password"] != ac2["password"]
def test_create_newemail_dict_ip(ipv4_config): def test_create_newemail_dict_ip(make_config):
ac = create_newemail_dict(ipv4_config) config = make_config("1.2.3.4")
assert ac["email"].endswith("@[1.3.3.7]") ac = create_newemail_dict(config)
assert ac["email"].endswith("@[1.2.3.4]")
def test_create_dclogin_url(example_config): def test_create_dclogin_url():
addr = "user@example.org" url = create_dclogin_url("user@example.org", "p@ss w+rd")
password = "p@ss w+rd"
url = create_dclogin_url(example_config, addr, password)
assert url.startswith("dclogin:") assert url.startswith("dclogin:")
assert "v=1" in url assert "v=1" in url
assert "ic=3" in url assert "ic=3" in url
assert addr in url assert "user@example.org" in url
# password special chars must be encoded # password special chars must be encoded
assert "p%40ss" in url assert "p%40ss" in url
assert "w%2Brd" in url assert "w%2Brd" in url
def test_create_dclogin_url_ipv4(ipv4_config):
addr = "user@[1.3.3.7]"
password = "p@ss w+rd"
url = create_dclogin_url(ipv4_config, addr, password)
assert url.startswith("dclogin:")
assert "v=1" in url
assert "ic=3" in url
assert addr in url
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config): def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account() print_new_account()

View File

@@ -87,15 +87,15 @@ def run_cmd_options(parser):
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host) sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme" strict_tls = args.config.tls_cert_mode == "acme"
if args.config.ipv4_relay:
args.dns_check_disabled = True
if not args.dns_check_disabled: if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): if not dns.check_initial_remote_data(
remote_data, strict_tls=strict_tls, print=out.red
):
return 1 return 1
env = os.environ.copy() env = os.environ.copy()
@@ -118,11 +118,13 @@ def run_cmd(args, out):
out.check_call(cmd, env=env) out.check_call(cmd, env=env)
if args.website_only: if args.website_only:
out.green("Website deployment completed.") out.green("Website deployment completed.")
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: elif (
not args.dns_check_disabled
and strict_tls
and not remote_data["acme_account_url"]
):
out.red("Deploy completed but letsencrypt not configured") out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again") out.red("Run 'cmdeploy run' again")
elif args.config.ipv4_relay:
out.green("Deploy completed.")
else: else:
out.green("Deploy completed, call `cmdeploy dns` next.") out.green("Deploy completed, call `cmdeploy dns` next.")
return 0 return 0
@@ -144,10 +146,6 @@ def dns_cmd_options(parser):
def dns_cmd(args, out): def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
if args.config.ipv4_relay:
ipv4 = args.config.ipv4_relay
print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.")
return 0
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
tls_cert_mode = args.config.tls_cert_mode tls_cert_mode = args.config.tls_cert_mode
@@ -185,7 +183,7 @@ def status_cmd_options(parser):
def status_cmd(args, out): def status_cmd(args, out):
"""Display status for online chatmail instance.""" """Display status for online chatmail instance."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}") out.green(f"chatmail domain: {args.config.mail_domain}")

View File

@@ -468,7 +468,7 @@ class ChatmailVenvDeployer(Deployer):
def configure(self): def configure(self):
_configure_remote_venv_with_chatmaild(self.config) _configure_remote_venv_with_chatmaild(self.config)
configure_remote_units(self.config.mail_domain_bare, self.units) configure_remote_units(self.config.mail_domain, self.units)
def activate(self): def activate(self):
activate_remote_units(self.units) activate_remote_units(self.units)
@@ -584,18 +584,24 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
""" """
config = read_config(config_path) config = read_config(config_path)
check_config(config) check_config(config)
bare_host = config.mail_domain_bare mail_domain = config.mail_domain
if website_only: if website_only:
Deployment().perform_stages([WebsiteDeployer(config)]) Deployment().perform_stages([WebsiteDeployer(config)])
return return
# Check if mtail_address interface is available (if configured) # Check if mtail_address interface is available (if configured)
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'): if config.mtail_address and config.mtail_address not in (
"127.0.0.1",
"::1",
"localhost",
):
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
if config.mtail_address not in all_addresses: if config.mtail_address not in all_addresses:
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") Out().red(
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
)
exit(1) exit(1)
if not is_in_container(): if not is_in_container():
@@ -635,7 +641,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
) )
exit(1) exit(1)
tls_deployer = get_tls_deployer(config, bare_host) tls_deployer = get_tls_deployer(config, mail_domain)
all_deployers = [ all_deployers = [
ChatmailDeployer(config), ChatmailDeployer(config),
@@ -643,13 +649,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
FiltermailDeployer(), FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),
UnboundDeployer(config), UnboundDeployer(config),
TurnDeployer(bare_host), TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
tls_deployer, tls_deployer,
WebsiteDeployer(config), WebsiteDeployer(config),
ChatmailVenvDeployer(config), ChatmailVenvDeployer(config),
MtastsDeployer(), MtastsDeployer(),
OpendkimDeployer(config.mail_domain), OpendkimDeployer(mail_domain),
# Dovecot should be started before Postfix # Dovecot should be started before Postfix
# because it creates authentication socket # because it creates authentication socket
# required by Postfix. # required by Postfix.

View File

@@ -20,12 +20,30 @@ DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}" DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
DOVECOT_SHA256 = { DOVECOT_SHA256 = {
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d", (
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9", "core",
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", "amd64",
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", ): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", (
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", "core",
"arm64",
): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
(
"imapd",
"amd64",
): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
(
"imapd",
"arm64",
): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
(
"lmtpd",
"amd64",
): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
(
"lmtpd",
"arm64",
): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
} }
@@ -61,11 +79,7 @@ class DovecotDeployer(Deployer):
self.need_restart = True self.need_restart = True
files.put( files.put(
name="Pin dovecot packages to block Debian dist-upgrades", name="Pin dovecot packages to block Debian dist-upgrades",
src=io.StringIO( src=io.StringIO("Package: dovecot-*\nPin: version *\nPin-Priority: -1\n"),
"Package: dovecot-*\n"
"Pin: version *\n"
"Pin-Priority: -1\n"
),
dest="/etc/apt/preferences.d/pin-dovecot", dest="/etc/apt/preferences.d/pin-dovecot",
user="root", user="root",
group="root", group="root",
@@ -73,7 +87,7 @@ class DovecotDeployer(Deployer):
) )
def configure(self): def configure(self):
configure_remote_units(self.config.mail_domain_bare, self.units) configure_remote_units(self.config.mail_domain, self.units)
config_restart, self.daemon_reload = _configure_dovecot(self.config) config_restart, self.daemon_reload = _configure_dovecot(self.config)
self.need_restart |= config_restart self.need_restart |= config_restart
@@ -84,7 +98,7 @@ class DovecotDeployer(Deployer):
if not self.disable_mail and not self.need_restart: if not self.disable_mail and not self.need_restart:
stale = host.get_fact( stale = host.get_fact(
Command, Command,
'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);' "pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);"
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"' ' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
" && echo STALE || true", " && echo STALE || true",
) )

View File

@@ -7,7 +7,6 @@ listen = 0.0.0.0
protocols = imap lmtp protocols = imap lmtp
auth_mechanisms = plain auth_mechanisms = plain
auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[]
{% if debug == true %} {% if debug == true %}
auth_verbose = yes auth_verbose = yes

View File

@@ -1,5 +1,3 @@
import os
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import files, systemd from pyinfra.operations import files, systemd
@@ -7,7 +5,7 @@ from cmdeploy.basedeploy import Deployer, get_resource
class FiltermailDeployer(Deployer): class FiltermailDeployer(Deployer):
services = ["filtermail", "filtermail-incoming", "filtermail-transport"] services = ["filtermail", "filtermail-incoming"]
bin_path = "/usr/local/bin/filtermail" bin_path = "/usr/local/bin/filtermail"
config_path = "/usr/local/lib/chatmaild/chatmail.ini" config_path = "/usr/local/lib/chatmaild/chatmail.ini"
@@ -15,21 +13,11 @@ class FiltermailDeployer(Deployer):
self.need_restart = False self.need_restart = False
def install(self): def install(self):
local_bin = os.environ.get("CHATMAIL_FILTERMAIL_BINARY")
if local_bin:
self.need_restart |= files.put(
name="Upload locally built filtermail",
src=local_bin,
dest=self.bin_path,
mode="755",
).changed
return
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.4/filtermail-{arch}" url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "5295115952c72e4c4ec3c85546e094b4155a4c702c82bd71fcdcb744dc73adf6", "x86_64": "48b3fb80c092d00b9b0a0ef77a8673496da3b9aed5ec1851e1df936d5589d62f",
"aarch64": "6892244f17b8f26ccb465766e96028e7222b3c8adefca9fc6bfe9ff332ca8dff", "aarch64": "c65bd5f45df187d3d65d6965a285583a3be0f44a6916ff12909ff9a8d702c22e",
}[arch] }[arch]
self.need_restart |= files.download( self.need_restart |= files.download(
name="Download filtermail", name="Download filtermail",

View File

@@ -1,11 +0,0 @@
[Unit]
Description=Chatmail transport service
[Service]
ExecStart={{ bin_path }} {{ config_path }} transport
Restart=always
RestartSec=30
User=vmail
[Install]
WantedBy=multi-user.target

View File

@@ -1,6 +1,5 @@
[Unit] [Unit]
Description=mtail Description=mtail
After=multi-user.target
[Service] [Service]
Type=simple Type=simple

View File

@@ -73,7 +73,7 @@ http {
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
location /mxdeliv { location /mxdeliv/ {
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }}; proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
} }

View File

@@ -54,16 +54,14 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
tls_preempt_cipherlist = yes tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.postfix_myhostname }} myhostname = {{ config.mail_domain }}
alias_maps = hash:/etc/aliases alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases alias_database = hash:/etc/aliases
# When postfix receives mail for $mydestination, # Postfix does not deliver mail for any domain by itself.
# it hands it over to dovecot via $local_transport. # Primary domain is listed in `virtual_mailbox_domains` instead
mydestination = {{ config.mail_domain }} # and handed over to Dovecot.
local_transport = lmtp:unix:private/dovecot-lmtp mydestination =
# postfix doesn't check whether local users exist or not:
local_recipient_maps =
relayhost = relayhost =
{% if disable_ipv6 %} {% if disable_ipv6 %}
@@ -81,6 +79,24 @@ inet_protocols = ipv4
inet_protocols = all inet_protocols = all
{% endif %} {% endif %}
# Postfix does not try IPv4 and IPv6 connections
# concurrently as of version 3.7.11.
#
# When relay has both A (IPv4) and AAAA (IPv6) records,
# but broken IPv6 connectivity,
# every second message is delayed by the connection timeout
# <https://www.postfix.org/postconf.5.html#smtp_connect_timeout>
# which defaults to 30 seconds. Reducing timeouts is not a solution
# as this will result in a failure to connect to slow servers.
#
# As a workaround we always prefer IPv4 when it is available.
#
# The setting is documented at
# <https://www.postfix.org/postconf.5.html#smtp_address_preference>
smtp_address_preference=ipv4
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
mua_client_restrictions = permit_sasl_authenticated, reject mua_client_restrictions = permit_sasl_authenticated, reject
@@ -93,12 +109,3 @@ smtpd_sender_login_maps = regexp:/etc/postfix/login_map
# Do not lookup SMTP client hostnames to reduce delays # Do not lookup SMTP client hostnames to reduce delays
# and avoid unnecessary DNS requests. # and avoid unnecessary DNS requests.
smtpd_peername_lookup = no smtpd_peername_lookup = no
# Use filtermail-transport to relay messages.
# We can't force postfix to split messages per destination,
# when specifying a custom next-hop,
# so instead this is handled in filtermail.
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
lmtp-filtermail_initial_destination_concurrency=10000
lmtp-filtermail_destination_concurrency_limit=10000

View File

@@ -80,9 +80,8 @@ filter unix - n n - - lmtp
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean -o cleanup_service_name=authclean
{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock
{% endif %}
# Local SMTP server for reinjecting incoming filtered mail # Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
@@ -101,8 +100,3 @@ filter unix - n n - - lmtp
# cannot send unprotected Subject. # cannot send unprotected Subject.
authclean unix n - - - 0 cleanup authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup -o header_checks=regexp:/etc/postfix/submission_header_cleanup
lmtp-filtermail unix - - y - 10000 lmtp
-o syslog_name=postfix/lmtp-filtermail
-o lmtp_header_checks=
-o lmtp_tls_security_level=none

View File

@@ -12,15 +12,27 @@ def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
``www.<domain>`` and ``mta-sts.<domain>``. ``www.<domain>`` and ``mta-sts.<domain>``.
""" """
return [ return [
"openssl", "req", "-x509", "openssl",
"-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256", "req",
"-noenc", "-days", str(days), "-x509",
"-keyout", str(key_path), "-newkey",
"-out", str(cert_path), "ec",
"-subj", f"/CN={domain}", "-pkeyopt",
"ec_paramgen_curve:P-256",
"-noenc",
"-days",
str(days),
"-keyout",
str(key_path),
"-out",
str(cert_path),
"-subj",
f"/CN={domain}",
# Mark as end-entity cert so it cannot be used as a CA to sign others. # Mark as end-entity cert so it cannot be used as a CA to sign others.
"-addext", "basicConstraints=critical,CA:FALSE", "-addext",
"-addext", "extendedKeyUsage=serverAuth,clientAuth", "basicConstraints=critical,CA:FALSE",
"-addext",
"extendedKeyUsage=serverAuth,clientAuth",
"-addext", "-addext",
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}", f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
] ]
@@ -42,7 +54,9 @@ class SelfSignedTlsDeployer(Deployer):
def configure(self): def configure(self):
args = openssl_selfsigned_args( args = openssl_selfsigned_args(
self.mail_domain, self.cert_path, self.key_path, self.mail_domain,
self.cert_path,
self.key_path,
) )
cmd = shlex.join(args) cmd = shlex.join(args)
server.shell( server.shell(

View File

@@ -89,11 +89,12 @@ def test_concurrent_logins_same_account(
assert login_results.get() assert login_results.get()
def test_no_vrfy(cmfactory, chatmail_config, maildomain): def test_no_vrfy(cmfactory, chatmail_config):
ac = cmfactory.get_online_account() ac = cmfactory.get_online_account()
addr = ac.get_config("addr") addr = ac.get_config("addr")
domain = chatmail_config.mail_domain
s = smtplib.SMTP(maildomain) s = smtplib.SMTP(domain)
s.starttls() s.starttls()
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")

View File

@@ -30,12 +30,15 @@ def test_newemail_configure(maildomain, rpc, chatmail_config):
# set_config_from_qr, so fetch credentials via requests instead # set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False) res = requests.post(f"https://{maildomain}/new", verify=False)
data = res.json() data = res.json()
rpc.add_or_update_transport(account_id, { rpc.add_or_update_transport(
"addr": data["email"], account_id,
"password": data["password"], {
"imapServer": maildomain, "addr": data["email"],
"smtpServer": maildomain, "password": data["password"],
"certificateChecks": "acceptInvalidCertificates", "imapServer": maildomain,
}) "smtpServer": maildomain,
"certificateChecks": "acceptInvalidCertificates",
},
)
else: else:
rpc.add_transport_from_qr(account_id, url) rpc.add_transport_from_qr(account_id, url)

View File

@@ -8,7 +8,6 @@ import pytest
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
from chatmaild.config import is_valid_ipv4
class TestSSHExecutor: class TestSSHExecutor:
@@ -22,8 +21,6 @@ class TestSSHExecutor:
assert out == out2 assert out == out2
def test_perform_initial(self, sshexec, maildomain): def test_perform_initial(self, sshexec, maildomain):
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is not a domain")
res = sshexec( res = sshexec(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )

View File

@@ -15,7 +15,7 @@ def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1) (ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr") user = ac1.get_config("addr")
password = ac1.get_config("mail_pw") password = ac1.get_config("mail_pw")
host = user.split("@")[1].strip("[").strip("]") host = user.split("@")[1]
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(user, password) mailbox.login(user, password)
mailbox.dc_ac = ac1 mailbox.dc_ac = ac1
@@ -178,7 +178,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2.wait_for_incoming_msg() user2.wait_for_incoming_msg()
addr = user2.get_config("addr") addr = user2.get_config("addr")
host = addr.split("@")[1].strip("[").strip("]") host = addr.split("@")[1]
pw = user2.get_config("mail_pw") pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw) mailbox.login(addr, pw)

View File

@@ -10,8 +10,7 @@ import time
from pathlib import Path from pathlib import Path
import pytest import pytest
from chatmaild.config import read_config, format_mail_domain, is_valid_ipv4 from chatmaild.config import read_config
conftestdir = Path(__file__).parent conftestdir = Path(__file__).parent
@@ -59,12 +58,7 @@ def chatmail_config(pytestconfig):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def maildomain(chatmail_config): def maildomain(chatmail_config):
return chatmail_config.mail_domain_bare return chatmail_config.mail_domain
@pytest.fixture(scope="session")
def maildomain_deliverable(maildomain):
return format_mail_domain(maildomain)
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -284,6 +278,7 @@ def gencreds(chatmail_config):
def gen(domain=None): def gen(domain=None):
domain = domain if domain else chatmail_config.mail_domain domain = domain if domain else chatmail_config.mail_domain
addr_domain = f"[{domain}]" if _is_ip(domain) else domain
while 1: while 1:
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
@@ -297,7 +292,7 @@ def gencreds(chatmail_config):
password = "".join( password = "".join(
random.choices(alphanumeric, k=chatmail_config.password_min_length) random.choices(alphanumeric, k=chatmail_config.password_min_length)
) )
yield f"{user}@{domain}", f"{password}" yield f"{user}@{addr_domain}", f"{password}"
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@@ -322,8 +317,7 @@ class ChatmailACFactory:
def _make_transport(self, domain): def _make_transport(self, domain):
"""Build a transport config dict for the given domain.""" """Build a transport config dict for the given domain."""
domain_deliverable = format_mail_domain(domain) addr, password = self.gencreds(domain)
addr, password = self.gencreds(domain_deliverable)
transport = { transport = {
"addr": addr, "addr": addr,
"password": password, "password": password,
@@ -332,7 +326,7 @@ class ChatmailACFactory:
"imapServer": domain, "imapServer": domain,
"smtpServer": domain, "smtpServer": domain,
} }
if domain.startswith("_") or is_valid_ipv4(domain): if self.chatmail_config.tls_cert_mode == "self":
transport["certificateChecks"] = "acceptInvalidCertificates" transport["certificateChecks"] = "acceptInvalidCertificates"
return transport return transport
@@ -347,8 +341,7 @@ class ChatmailACFactory:
accounts = [] accounts = []
for _ in range(num): for _ in range(num):
account = self.dc.add_account() account = self.dc.add_account()
domain_deliverable = format_mail_domain(domain) addr, password = self.gencreds(domain)
addr, password = self.gencreds(domain_deliverable)
if _is_ip(domain): if _is_ip(domain):
# Use DCLOGIN scheme with explicit server hosts, # Use DCLOGIN scheme with explicit server hosts,
# matching how madmail presents its addresses to users. # matching how madmail presents its addresses to users.
@@ -424,9 +417,12 @@ class Remote:
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain) print(self.sshdomain)
match self.sshdomain: match self.sshdomain:
case "@local": command = [] case "@local":
case "localhost": command = [] command = []
case _: command = ["ssh", f"root@{self.sshdomain}"] case "localhost":
command = []
case _:
command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()] [command.append(arg) for arg in getjournal.split()]
popen = subprocess.Popen( popen = subprocess.Popen(
command, command,

View File

@@ -39,14 +39,6 @@ class TestCmdline:
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "deleting config file" in out.lower() assert "deleting config file" in out.lower()
def test_dns_skip_on_ip(self, capsys, tmp_path, monkeypatch):
monkeypatch.delenv("CHATMAIL_INI", raising=False)
inipath = tmp_path / "chatmail.ini"
assert main(["init", "--config", str(inipath), "1.3.3.7"]) == 0
assert main(["dns", "--config", str(inipath)]) == 0
out, err = capsys.readouterr()
assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n"
def test_www_folder(example_config, tmp_path): def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()

View File

@@ -23,8 +23,7 @@ def make_host(*fact_pairs):
if cls not in facts: if cls not in facts:
registered = ", ".join(c.__name__ for c in facts) registered = ", ".join(c.__name__ for c in facts)
raise LookupError( raise LookupError(
f"unexpected get_fact({cls.__name__}); " f"unexpected get_fact({cls.__name__}); only registered: {registered}"
f"only registered: {registered}"
) )
return facts[cls] return facts[cls]

View File

@@ -1,10 +1,11 @@
from pathlib import Path import importlib.resources
from cmdeploy.www import build_webpages from cmdeploy.www import build_webpages
def test_build_webpages(tmp_path, make_config): def test_build_webpages(tmp_path, make_config):
src_dir = (Path(__file__).resolve() / "../../../../../www/src").resolve() pkgroot = importlib.resources.files("cmdeploy")
src_dir = pkgroot.joinpath("../../../www/src").resolve()
assert src_dir.exists(), src_dir assert src_dir.exists(), src_dir
config = make_config("chat.example.org") config = make_config("chat.example.org")
build_dir = tmp_path.joinpath("build") build_dir = tmp_path.joinpath("build")

View File

@@ -1,4 +1,5 @@
import hashlib import hashlib
import importlib.resources
import re import re
import time import time
import traceback import traceback
@@ -36,7 +37,7 @@ def prepare_template(source):
def get_paths(config) -> (Path, Path, Path): def get_paths(config) -> (Path, Path, Path):
reporoot = (Path(__file__).resolve() / "../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../").resolve()
www_path = Path(config.www_folder) www_path = Path(config.www_folder)
# if www_folder was not set, use default directory # if www_folder was not set, use default directory
if config.www_folder == "": if config.www_folder == "":
@@ -132,7 +133,8 @@ def find_merge_conflict(src_dir) -> Path:
def main(): def main():
reporoot = (Path(__file__).resolve() / "../../../../").resolve() path = importlib.resources.files(__package__)
reporoot = path.joinpath("../../../").resolve()
inipath = reporoot.joinpath("chatmail.ini") inipath = reporoot.joinpath("chatmail.ini")
config = read_config(inipath) config = read_config(inipath)
config.webdev = True config.webdev = True

View File

@@ -14,6 +14,8 @@ Minimal requirements and prerequisites
You will need the following: You will need the following:
- Control over a domain through a DNS provider of your choice.
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports. - A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
@@ -26,11 +28,6 @@ You will need the following:
(An ed25519 private key is required due to an `upstream bug in (An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_) paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
- Control over a domain through a DNS provider of your choice
(there is experimental support for :ref:`DNS-less relays <iponly>`).
.. _setup:
Setup with ``scripts/cmdeploy`` Setup with ``scripts/cmdeploy``
------------------------------------- -------------------------------------

View File

@@ -19,4 +19,3 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay
reverse_dns reverse_dns
related related
faq faq
iponly

View File

@@ -1,29 +0,0 @@
.. _iponly:
Hosting without DNS records
===========================
.. note::
This option is experimental and might change without notice.
In case you don't have a domain,
for example in a local network,
you can run a chatmail relay with only an IPv4 address as well.
To deploy a relay without a domain,
run ``cmdeploy init`` with only the IPv4 address
during the :ref:`installation steps <setup>`,
for example ``cmdeploy init 13.12.23.42``.
Drawbacks
---------
- your transport encryption will only use self-signed TLS certificates,
which are vulnerable against MITM attacks.
the chatmail core's end-to-end encryption should suffice in most scenarios though.
- your messages will not be DKIM-signed;
experimentally, most chatmail relays accept non-DKIM-signed messages from IPv4-only relays,
but some relays might not accept messages from yours.

View File

@@ -153,7 +153,6 @@ Chatmail relay dependency diagram
autoconfig.xml --- dovecot; autoconfig.xml --- dovecot;
postfix --- |10080|filtermail-outgoing; postfix --- |10080|filtermail-outgoing;
postfix --- |10081|filtermail-incoming; postfix --- |10081|filtermail-incoming;
postfix --- |10083|filtermail-transport;
filtermail-outgoing --- |10025 reinject|postfix; filtermail-outgoing --- |10025 reinject|postfix;
filtermail-incoming --- |10026 reinject|postfix; filtermail-incoming --- |10026 reinject|postfix;
dovecot --- |doveauth.socket|doveauth; dovecot --- |doveauth.socket|doveauth;
@@ -296,7 +295,9 @@ ensured by ``filtermail`` proxy.
TLS requirements TLS requirements
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
Filtermail (used for delivery) requires a valid TLS. Postfix is configured to require valid TLS by setting
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
to ``verify``.
You can test it by resolving ``MX`` records of your relay domain and You can test it by resolving ``MX`` records of your relay domain and
then connecting to MX relays (e.g ``mx.example.org``) with then connecting to MX relays (e.g ``mx.example.org``) with