1
0
mirror of https://github.com/mailcow/mailcow-dockerized.git synced 2026-01-04 12:39:15 +00:00

[Dovecot] use python bootstrapper to start DOVECOT container

This commit is contained in:
FreddleSpl0it
2025-05-21 07:56:28 +02:00
parent 5a097ed5f7
commit 2efea9c832
36 changed files with 1223 additions and 690 deletions

View File

@@ -10,13 +10,15 @@ def main():
from modules.BootstrapNginx import Bootstrap
elif container_name == "postfix-mailcow":
from modules.BootstrapPostfix import Bootstrap
elif container_name == "dovecot-mailcow":
from modules.BootstrapDovecot import Bootstrap
else:
print(f"No bootstrap handler for container: {container_name}", file=sys.stderr)
sys.exit(1)
b = Bootstrap(
container=container_name,
db_config = {
db_config={
"host": "localhost",
"user": os.getenv("DBUSER"),
"password": os.getenv("DBPASS"),
@@ -25,7 +27,13 @@ def main():
'connection_timeout': 2
},
db_table="service_settings",
db_settings=['sogo']
db_settings=['sogo'],
redis_config={
"host": os.getenv("REDIS_SLAVEOF_IP") or "redis-mailcow",
"port": int(os.getenv("REDIS_SLAVEOF_PORT") or 6379),
"password": os.getenv("REDISPASS"),
"db": 0
}
)
b.bootstrap()

View File

@@ -9,21 +9,25 @@ import time
import socket
import signal
import re
import redis
import hashlib
import json
from pathlib import Path
import mysql.connector
from jinja2 import Environment, FileSystemLoader
class BootstrapBase:
def __init__(self, container, db_config, db_table, db_settings):
def __init__(self, container, db_config, db_table, db_settings, redis_config):
self.container = container
self.db_config = db_config
self.db_table = db_table
self.db_settings = db_settings
self.redis_config = redis_config
self.env = None
self.env_vars = None
self.mysql_conn = None
self.redis_conn = None
def render_config(self, template_name, output_path):
"""
@@ -184,16 +188,21 @@ class BootstrapBase:
Args:
path (str or Path): Path to the file or directory.
user (str): Username for new owner.
group (str, optional): Group name; defaults to user's group if not provided.
user (str or int): Username or UID for new owner.
group (str or int, optional): Group name or GID; defaults to user's group if not provided.
recursive (bool): If True and path is a directory, ownership is applied recursively.
Raises:
FileNotFoundError: If the path does not exist.
"""
uid = pwd.getpwnam(user).pw_uid
gid = grp.getgrnam(group or user).gr_gid
# Resolve UID
uid = int(user) if str(user).isdigit() else pwd.getpwnam(user).pw_uid
# Resolve GID
if group is not None:
gid = int(group) if str(group).isdigit() else grp.getgrnam(group).gr_gid
else:
gid = uid if isinstance(user, int) or str(user).isdigit() else grp.getgrnam(user).gr_gid
p = Path(path)
if not p.exists():
@@ -231,6 +240,67 @@ class BootstrapBase:
shutil.move(str(src_path), str(dst_path))
def copy_file(self, src, dst, overwrite=True):
"""
Copies a file from src to dst using shutil.
Args:
src (str or Path): Source file path.
dst (str or Path): Destination file path.
overwrite (bool): Whether to overwrite the destination if it exists.
Raises:
FileNotFoundError: If the source file doesn't exist.
FileExistsError: If the destination exists and overwrite is False.
IOError: If the copy operation fails.
"""
src_path = Path(src)
dst_path = Path(dst)
if not src_path.is_file():
raise FileNotFoundError(f"Source file not found: {src_path}")
if dst_path.exists() and not overwrite:
raise FileExistsError(f"Destination exists: {dst_path}")
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src_path, dst_path)
def remove(self, path, recursive=False, wipe_contents=False):
"""
Removes a file or directory.
Args:
path (str or Path): The file or directory path to remove.
recursive (bool): If True, directories will be removed recursively.
wipe_contents (bool): If True and path is a directory, only its contents are removed, not the dir itself.
Raises:
FileNotFoundError: If the path does not exist.
ValueError: If a directory is passed without recursive or wipe_contents.
"""
path = Path(path)
if not path.exists():
raise FileNotFoundError(f"Cannot remove: {path} does not exist")
if wipe_contents and path.is_dir():
for child in path.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
elif path.is_file():
path.unlink()
elif path.is_dir():
if recursive:
shutil.rmtree(path)
else:
raise ValueError(f"{path} is a directory. Use recursive=True or wipe_contents=True to remove it.")
def create_dir(self, path):
"""
Creates a directory if it does not exist.
@@ -376,6 +446,49 @@ class BootstrapBase:
if self.mysql_conn and self.mysql_conn.is_connected():
self.mysql_conn.close()
def connect_redis(self, retries=10, delay=2):
"""
Establishes a Redis connection and stores it in `self.redis_conn`.
Args:
retries (int): Number of ping retries before giving up.
delay (int): Seconds between retries.
"""
client = redis.Redis(
host=self.redis_config['host'],
port=self.redis_config['port'],
password=self.redis_config['password'],
db=self.redis_config['db'],
decode_responses=True
)
for _ in range(retries):
try:
if client.ping():
self.redis_conn = client
return
except redis.RedisError as e:
print(f"Waiting for Redis... ({e})")
time.sleep(delay)
raise ConnectionError("Redis is not available after multiple attempts.")
def close_redis(self):
"""
Closes the Redis connection if it's open.
Safe to call even if Redis was never connected or already closed.
"""
if self.redis_conn:
try:
self.redis_conn.close()
except Exception as e:
print(f"Error while closing Redis connection: {e}")
finally:
self.redis_conn = None
def wait_for_schema_update(self, init_file_path="init_db.inc.php", check_interval=5):
"""
Waits until the current database schema version matches the expected version
@@ -559,4 +672,7 @@ class BootstrapBase:
print(e.stderr.strip())
if check:
raise
return e
return e
def sha1_filter(self, value):
return hashlib.sha1(value.encode()).hexdigest()

View File

@@ -0,0 +1,307 @@
from jinja2 import Environment, FileSystemLoader
from modules.BootstrapBase import BootstrapBase
from pathlib import Path
import os
import sys
import time
import pwd
import hashlib
class Bootstrap(BootstrapBase):
def bootstrap(self):
# Connect to MySQL
self.connect_mysql()
self.wait_for_schema_update()
# Connect to Redis
self.connect_redis()
self.redis_conn.set("DOVECOT_REPL_HEALTH", 1)
# Wait for DNS
self.wait_for_dns("mailcow.email")
# Create missing directories
self.create_dir("/etc/dovecot/sql/")
self.create_dir("/etc/dovecot/auth/")
self.create_dir("/var/vmail/_garbage")
self.create_dir("/var/vmail/sieve")
self.create_dir("/etc/sogo")
self.create_dir("/var/volatile")
# Setup Jinja2 Environment and load vars
self.env = Environment(
loader=FileSystemLoader('./etc/dovecot/config_templates'),
keep_trailing_newline=True,
lstrip_blocks=True,
trim_blocks=True
)
extra_vars = {
"VALID_CERT_DIRS": self.get_valid_cert_dirs(),
"RAND_USER": self.rand_pass(),
"RAND_PASS": self.rand_pass(),
"RAND_PASS2": self.rand_pass(),
"ENV_VARS": dict(os.environ)
}
self.env_vars = self.prepare_template_vars('/overwrites.json', extra_vars)
# Escape DBPASS
self.env_vars['DBPASS'] = self.env_vars['DBPASS'].replace('"', r'\"')
# Set custom filters
self.env.filters['sha1'] = self.sha1_filter
print("Set Timezone")
self.set_timezone()
print("Render config")
self.render_config("dovecot-dict-sql-quota.conf.j2", "/etc/dovecot/sql/dovecot-dict-sql-quota.conf")
self.render_config("dovecot-dict-sql-userdb.conf.j2", "/etc/dovecot/sql/dovecot-dict-sql-userdb.conf")
self.render_config("dovecot-dict-sql-sieve_before.conf.j2", "/etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf")
self.render_config("dovecot-dict-sql-sieve_after.conf.j2", "/etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf")
self.render_config("mail_plugins.j2", "/etc/dovecot/mail_plugins")
self.render_config("mail_plugins_imap.j2", "/etc/dovecot/mail_plugins_imap")
self.render_config("mail_plugins_lmtp.j2", "/etc/dovecot/mail_plugins_lmtp")
self.render_config("global_sieve_after.sieve.j2", "/var/vmail/sieve/global_sieve_after.sieve")
self.render_config("global_sieve_before.sieve.j2", "/var/vmail/sieve/global_sieve_before.sieve")
self.render_config("dovecot-master.passwd.j2", "/etc/dovecot/dovecot-master.passwd")
self.render_config("dovecot-master.userdb.j2", "/etc/dovecot/dovecot-master.userdb")
self.render_config("sieve.creds.j2", "/etc/sogo/sieve.creds")
self.render_config("sogo-sso.pass.j2", "/etc/phpfpm/sogo-sso.pass")
self.render_config("cron.creds.j2", "/etc/sogo/cron.creds")
self.render_config("source_env.sh.j2", "/source_env.sh")
self.render_config("maildir_gc.sh.j2", "/usr/local/bin/maildir_gc.sh")
self.render_config("dovecot.conf.j2", "/etc/dovecot/dovecot.conf")
files = [
"/etc/dovecot/mail_plugins",
"/etc/dovecot/mail_plugins_imap",
"/etc/dovecot/mail_plugins_lmtp",
"/templates/quarantine.tpl"
]
for file in files:
self.set_permissions(file, 0o644)
try:
# Migrate old sieve_after file
self.move_file("/etc/dovecot/sieve_after", "/var/vmail/sieve/global_sieve_after.sieve")
except Exception as e:
pass
try:
# Cleanup random user maildirs
self.remove("/var/vmail/mailcow.local", wipe_contents=True)
except Exception as e:
pass
try:
# Cleanup PIDs
self.remove("/tmp/quarantine_notify.pid")
except Exception as e:
pass
try:
self.remove("/var/run/dovecot/master.pid")
except Exception as e:
pass
# Check permissions of vmail/index/garbage directories.
# Do not do this every start-up, it may take a very long time. So we use a stat check here.
files = [
"/var/vmail",
"/var/vmail/_garbage",
"/var/vmail_index"
]
for file in files:
path = Path(file)
try:
stat_info = path.stat()
current_user = pwd.getpwuid(stat_info.st_uid).pw_name
if current_user != "vmail":
print(f"Ownership of {path} is {current_user}, fixing to vmail:vmail...")
self.set_owner(path, user="vmail", group="vmail", recursive=True)
else:
print(f"Ownership of {path} is already correct (vmail)")
except Exception as e:
print(f"Error checking ownership of {path}: {e}")
# Compile sieve scripts
files = [
"/var/vmail/sieve/global_sieve_before.sieve",
"/var/vmail/sieve/global_sieve_after.sieve",
"/usr/lib/dovecot/sieve/report-spam.sieve",
"/usr/lib/dovecot/sieve/report-ham.sieve",
]
for file in files:
self.run_command(["sievec", file], check=False)
# Fix permissions
for path in Path("/etc/dovecot/sql").glob("*.conf"):
self.set_owner(path, "root", "root")
self.set_permissions(path, 0o640)
files = [
"/etc/dovecot/auth/passwd-verify.lua",
*Path("/etc/dovecot/sql").glob("dovecot-dict-sql-sieve*"),
*Path("/etc/dovecot/sql").glob("dovecot-dict-sql-quota*")
]
for file in files:
self.set_owner(file, "root", "dovecot")
self.set_permissions("/etc/dovecot/auth/passwd-verify.lua", 0o640)
for file in ["/var/vmail/sieve", "/var/volatile", "/var/vmail_index"]:
self.set_owner(file, "vmail", "vmail", recursive=True)
self.run_command(["adduser", "vmail", "tty"])
self.run_command(["chmod", "g+rw", "/dev/console"])
self.set_owner("/dev/console", "root", "tty")
files = [
"/usr/lib/dovecot/sieve/rspamd-pipe-ham",
"/usr/lib/dovecot/sieve/rspamd-pipe-spam",
"/usr/local/bin/imapsync_runner.pl",
"/usr/local/bin/imapsync",
"/usr/local/bin/trim_logs.sh",
"/usr/local/bin/sa-rules.sh",
"/usr/local/bin/clean_q_aged.sh",
"/usr/local/bin/maildir_gc.sh",
"/usr/local/sbin/stop-supervisor.sh",
"/usr/local/bin/quota_notify.py",
"/usr/local/bin/repl_health.sh",
"/usr/local/bin/optimize-fts.sh"
]
for file in files:
self.set_permissions(file, 0o755)
# Collect SA rules once now
self.run_command(["/usr/local/bin/sa-rules.sh"], check=False)
self.generate_mail_crypt_keys()
self.cleanup_imapsync_jobs()
self.generate_guid_version()
def get_valid_cert_dirs(self):
"""
Returns a mapping of domains to their certificate directory path.
Example:
{
"example.com": "/etc/ssl/mail/example.com/",
"www.example.com": "/etc/ssl/mail/example.com/"
}
"""
sni_map = {}
base_path = Path("/etc/ssl/mail")
if not base_path.exists():
return sni_map
for cert_dir in base_path.iterdir():
if not cert_dir.is_dir():
continue
domains_file = cert_dir / "domains"
cert_file = cert_dir / "cert.pem"
key_file = cert_dir / "key.pem"
if not (domains_file.exists() and cert_file.exists() and key_file.exists()):
continue
with open(domains_file, "r") as f:
domains = [line.strip() for line in f if line.strip()]
for domain in domains:
sni_map[domain] = str(cert_dir)
return sni_map
def generate_mail_crypt_keys(self):
"""
Ensures mail_crypt EC keypair exists. Generates if missing. Adjusts permissions.
"""
key_dir = Path("/mail_crypt")
priv_key = key_dir / "ecprivkey.pem"
pub_key = key_dir / "ecpubkey.pem"
# Generate keys if they don't exist or are empty
if not priv_key.exists() or priv_key.stat().st_size == 0 or \
not pub_key.exists() or pub_key.stat().st_size == 0:
self.run_command(
"openssl ecparam -name prime256v1 -genkey | openssl pkey -out /mail_crypt/ecprivkey.pem",
shell=True
)
self.run_command(
"openssl pkey -in /mail_crypt/ecprivkey.pem -pubout -out /mail_crypt/ecpubkey.pem",
shell=True
)
# Set ownership to UID 401 (dovecot)
self.set_owner(priv_key, user='401')
self.set_owner(pub_key, user='401')
def cleanup_imapsync_jobs(self):
"""
Cleans up stale imapsync locks and resets running status in the database.
Deletes the imapsync_busy.lock file if present and sets `is_running` to 0
in the `imapsync` table, if it exists.
Logs:
Any issues with file operations or SQL execution.
"""
lock_file = Path("/tmp/imapsync_busy.lock")
if lock_file.exists():
try:
lock_file.unlink()
except Exception as e:
print(f"Failed to remove lock file: {e}")
try:
cursor = self.mysql_conn.cursor()
cursor.execute("SHOW TABLES LIKE 'imapsync'")
result = cursor.fetchone()
if result:
cursor.execute("UPDATE imapsync SET is_running='0'")
self.mysql_conn.commit()
cursor.close()
except Exception as e:
print(f"Error updating imapsync table: {e}")
def generate_guid_version(self):
"""
Waits for the `versions` table to be created, then generates a GUID
based on the mail hostname and Dovecot's public key and inserts it
into the `versions` table.
If the key or hash is missing or malformed, marks it as INVALID.
"""
try:
result = self.run_command(["doveconf", "-P"], check=True)
pubkey_path = None
for line in result.stdout.splitlines():
if "mail_crypt_global_public_key" in line:
parts = line.split('<')
if len(parts) > 1:
pubkey_path = parts[1].strip()
break
if pubkey_path and Path(pubkey_path).exists():
with open(pubkey_path, "rb") as key_file:
pubkey_data = key_file.read()
hostname = self.env_vars.get("MAILCOW_HOSTNAME", "mailcow.local").encode("utf-8")
concat = hostname + pubkey_data
guid = hashlib.sha256(concat).hexdigest()
if len(guid) == 64:
version_value = guid
else:
version_value = "INVALID"
cursor = self.mysql_conn.cursor()
cursor.execute(
"REPLACE INTO versions (application, version) VALUES (%s, %s)",
("GUID", version_value)
)
self.mysql_conn.commit()
cursor.close()
else:
print("Public key not found or unreadable. GUID not generated.")
except Exception as e:
print(f"Failed to generate or store GUID: {e}")

View File

@@ -87,7 +87,7 @@ RUN addgroup -g 5000 vmail \
perl-proc-processtable \
perl-app-cpanminus \
procps \
python3 \
python3 py3-pip \
py3-mysqlclient \
py3-html2text \
py3-jinja2 \
@@ -115,25 +115,34 @@ RUN addgroup -g 5000 vmail \
&& chmod +x /usr/local/bin/gosu \
&& gosu nobody true
COPY trim_logs.sh /usr/local/bin/trim_logs.sh
COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
COPY imapsync /usr/local/bin/imapsync
COPY imapsync_runner.pl /usr/local/bin/imapsync_runner.pl
COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
COPY rspamd-pipe-spam /usr/lib/dovecot/sieve/rspamd-pipe-spam
COPY sa-rules.sh /usr/local/bin/sa-rules.sh
COPY maildir_gc.sh /usr/local/bin/maildir_gc.sh
COPY docker-entrypoint.sh /
COPY supervisord.conf /etc/supervisor/supervisord.conf
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
COPY quarantine_notify.py /usr/local/bin/quarantine_notify.py
COPY quota_notify.py /usr/local/bin/quota_notify.py
COPY repl_health.sh /usr/local/bin/repl_health.sh
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
RUN pip install --break-system-packages \
mysql-connector-python \
jinja2 \
redis
COPY data/Dockerfiles/bootstrap /bootstrap
COPY data/Dockerfiles/dovecot/trim_logs.sh /usr/local/bin/trim_logs.sh
COPY data/Dockerfiles/dovecot/clean_q_aged.sh /usr/local/bin/clean_q_aged.sh
COPY data/Dockerfiles/dovecot/syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
COPY data/Dockerfiles/dovecot/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
COPY data/Dockerfiles/dovecot/imapsync /usr/local/bin/imapsync
COPY data/Dockerfiles/dovecot/imapsync_runner.pl /usr/local/bin/imapsync_runner.pl
COPY data/Dockerfiles/dovecot/report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
COPY data/Dockerfiles/dovecot/report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
COPY data/Dockerfiles/dovecot/rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
COPY data/Dockerfiles/dovecot/rspamd-pipe-spam /usr/lib/dovecot/sieve/rspamd-pipe-spam
COPY data/Dockerfiles/dovecot/sa-rules.sh /usr/local/bin/sa-rules.sh
COPY data/Dockerfiles/dovecot/docker-entrypoint.sh /
COPY data/Dockerfiles/dovecot/supervisord.conf /etc/supervisor/supervisord.conf
COPY data/Dockerfiles/dovecot/stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
COPY data/Dockerfiles/dovecot/quarantine_notify.py /usr/local/bin/quarantine_notify.py
COPY data/Dockerfiles/dovecot/quota_notify.py /usr/local/bin/quota_notify.py
COPY data/Dockerfiles/dovecot/repl_health.sh /usr/local/bin/repl_health.sh
COPY data/Dockerfiles/dovecot/optimize-fts.sh /usr/local/bin/optimize-fts.sh
RUN chmod +x /docker-entrypoint.sh \
/usr/local/sbin/stop-supervisor.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]

View File

@@ -1,253 +1,15 @@
#!/bin/bash
set -e
# Wait for MySQL to warm-up
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
echo "Waiting for database to come up..."
sleep 2
done
until dig +short mailcow.email > /dev/null; do
echo "Waiting for DNS..."
sleep 1
done
# Do not attempt to write to slave
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
else
REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning"
fi
until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
echo "Waiting for Redis..."
sleep 2
done
${REDIS_CMDLINE} SET DOVECOT_REPL_HEALTH 1 > /dev/null
# Create missing directories
[[ ! -d /etc/dovecot/sql/ ]] && mkdir -p /etc/dovecot/sql/
[[ ! -d /etc/dovecot/auth/ ]] && mkdir -p /etc/dovecot/auth/
[[ ! -d /etc/dovecot/conf.d/ ]] && mkdir -p /etc/dovecot/conf.d/
[[ ! -d /var/vmail/_garbage ]] && mkdir -p /var/vmail/_garbage
[[ ! -d /var/vmail/sieve ]] && mkdir -p /var/vmail/sieve
[[ ! -d /etc/sogo ]] && mkdir -p /etc/sogo
[[ ! -d /var/volatile ]] && mkdir -p /var/volatile
# Set Dovecot sql config parameters, escape " in db password
DBPASS=$(echo ${DBPASS} | sed 's/"/\\"/g')
# Create quota dict for Dovecot
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
QUOTA_TABLE=quota2
else
QUOTA_TABLE=quota2replica
fi
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-quota.conf
# Autogenerated by mailcow
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
map {
pattern = priv/quota/storage
table = ${QUOTA_TABLE}
username_field = username
value_field = bytes
}
map {
pattern = priv/quota/messages
table = ${QUOTA_TABLE}
username_field = username
value_field = messages
}
EOF
# Create dict used for sieve pre and postfilters
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf
# Autogenerated by mailcow
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
map {
pattern = priv/sieve/name/\$script_name
table = sieve_before
username_field = username
value_field = id
fields {
script_name = \$script_name
}
}
map {
pattern = priv/sieve/data/\$id
table = sieve_before
username_field = username
value_field = script_data
fields {
id = \$id
}
}
EOF
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf
# Autogenerated by mailcow
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
map {
pattern = priv/sieve/name/\$script_name
table = sieve_after
username_field = username
value_field = id
fields {
script_name = \$script_name
}
}
map {
pattern = priv/sieve/data/\$id
table = sieve_after
username_field = username
value_field = script_data
fields {
id = \$id
}
}
EOF
echo -n ${ACL_ANYONE} > /etc/dovecot/acl_anyone
if [[ "${SKIP_FTS}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo -e "\e[33mDetecting SKIP_FTS=y... not enabling Flatcurve (FTS) then...\e[0m"
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify listescape replication lazy_expunge' > /etc/dovecot/mail_plugins
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify listescape replication mail_log' > /etc/dovecot/mail_plugins_imap
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
else
echo -e "\e[32mDetecting SKIP_FTS=n... enabling Flatcurve (FTS)\e[0m"
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify fts fts_flatcurve listescape replication lazy_expunge' > /etc/dovecot/mail_plugins
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify mail_log fts fts_flatcurve listescape replication' > /etc/dovecot/mail_plugins_imap
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl fts fts_flatcurve notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
fi
chmod 644 /etc/dovecot/mail_plugins /etc/dovecot/mail_plugins_imap /etc/dovecot/mail_plugins_lmtp /templates/quarantine.tpl
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
# Autogenerated by mailcow
driver = mysql
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%d/%n/${MAILDIR_SUB}:VOLATILEDIR=/var/volatile/%u:INDEX=/var/vmail_index/%u') AS mail, '%s' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule FROM mailbox WHERE username = '%u' AND (active = '1' OR active = '2')
iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2';
EOF
# Migrate old sieve_after file
[[ -f /etc/dovecot/sieve_after ]] && mv /etc/dovecot/sieve_after /etc/dovecot/global_sieve_after
# Create global sieve scripts
cat /etc/dovecot/global_sieve_after > /var/vmail/sieve/global_sieve_after.sieve
cat /etc/dovecot/global_sieve_before > /var/vmail/sieve/global_sieve_before.sieve
# Check permissions of vmail/index/garbage directories.
# Do not do this every start-up, it may take a very long time. So we use a stat check here.
if [[ $(stat -c %U /var/vmail/) != "vmail" ]] ; then chown -R vmail:vmail /var/vmail ; fi
if [[ $(stat -c %U /var/vmail/_garbage) != "vmail" ]] ; then chown -R vmail:vmail /var/vmail/_garbage ; fi
if [[ $(stat -c %U /var/vmail_index) != "vmail" ]] ; then chown -R vmail:vmail /var/vmail_index ; fi
# Cleanup random user maildirs
rm -rf /var/vmail/mailcow.local/*
# Cleanup PIDs
[[ -f /tmp/quarantine_notify.pid ]] && rm /tmp/quarantine_notify.pid
# create sni configuration
echo "" > /etc/dovecot/sni.conf
for cert_dir in /etc/ssl/mail/*/ ; do
if [[ ! -f ${cert_dir}domains ]] || [[ ! -f ${cert_dir}cert.pem ]] || [[ ! -f ${cert_dir}key.pem ]]; then
continue
# Run hooks
for file in /hooks/*; do
if [ -x "${file}" ]; then
echo "Running hook ${file}"
"${file}"
fi
domains=($(cat ${cert_dir}domains))
for domain in ${domains[@]}; do
echo 'local_name '${domain}' {' >> /etc/dovecot/sni.conf;
echo ' ssl_cert = <'${cert_dir}'cert.pem' >> /etc/dovecot/sni.conf;
echo ' ssl_key = <'${cert_dir}'key.pem' >> /etc/dovecot/sni.conf;
echo '}' >> /etc/dovecot/sni.conf;
done
done
# Create random master for SOGo sieve features
RAND_USER=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 16 | head -n 1)
RAND_PASS=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 24 | head -n 1)
if [[ ! -z ${DOVECOT_MASTER_USER} ]] && [[ ! -z ${DOVECOT_MASTER_PASS} ]]; then
RAND_USER=${DOVECOT_MASTER_USER}
RAND_PASS=${DOVECOT_MASTER_PASS}
fi
echo ${RAND_USER}@mailcow.local:{SHA1}$(echo -n ${RAND_PASS} | sha1sum | awk '{print $1}'):::::: > /etc/dovecot/dovecot-master.passwd
echo ${RAND_USER}@mailcow.local::5000:5000:::: > /etc/dovecot/dovecot-master.userdb
echo ${RAND_USER}@mailcow.local:${RAND_PASS} > /etc/sogo/sieve.creds
if [[ -z ${MAILDIR_SUB} ]]; then
MAILDIR_SUB_SHARED=
else
MAILDIR_SUB_SHARED=/${MAILDIR_SUB}
fi
cat <<EOF > /etc/dovecot/shared_namespace.conf
# Autogenerated by mailcow
namespace {
type = shared
separator = /
prefix = Shared/%%u/
location = maildir:%%h${MAILDIR_SUB_SHARED}:INDEX=~${MAILDIR_SUB_SHARED}/Shared/%%u
subscriptions = no
list = children
}
EOF
cat <<EOF > /etc/dovecot/sogo_trusted_ip.conf
# Autogenerated by mailcow
remote ${IPV4_NETWORK}.248 {
disable_plaintext_auth = no
}
EOF
# Create random master Password for SOGo SSO
RAND_PASS=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 32 | head -n 1)
echo -n ${RAND_PASS} > /etc/phpfpm/sogo-sso.pass
# Creating additional creds file for SOGo notify crons (calendars, etc)
echo -n ${RAND_USER}@mailcow.local:${RAND_PASS} > /etc/sogo/cron.creds
cat <<EOF > /etc/dovecot/sogo-sso.conf
# Autogenerated by mailcow
passdb {
driver = static
args = allow_real_nets=${IPV4_NETWORK}.248/32 password={plain}${RAND_PASS}
}
EOF
if [[ "${MASTER}" =~ ^([nN][oO]|[nN])+$ ]]; then
# Toggling MASTER will result in a rebuild of containers, so the quota script will be recreated
cat <<'EOF' > /usr/local/bin/quota_notify.py
#!/usr/bin/python3
import sys
sys.exit()
EOF
fi
# Set mail_replica for HA setups
if [[ -n ${MAILCOW_REPLICA_IP} && -n ${DOVEADM_REPLICA_PORT} ]]; then
cat <<EOF > /etc/dovecot/mail_replica.conf
# Autogenerated by mailcow
mail_replica = tcp:${MAILCOW_REPLICA_IP}:${DOVEADM_REPLICA_PORT}
EOF
fi
# Setting variables for indexer-worker inside fts.conf automatically according to mailcow.conf settings
if [[ "${SKIP_FTS}" =~ ^([nN][oO]|[nN])+$ ]]; then
echo -e "\e[94mConfiguring FTS Settings...\e[0m"
echo -e "\e[94mSetting FTS Memory Limit (per process) to ${FTS_HEAP} MB\e[0m"
sed -i "s/vsz_limit\s*=\s*[0-9]*\s*MB*/vsz_limit=${FTS_HEAP} MB/" /etc/dovecot/conf.d/fts.conf
echo -e "\e[94mSetting FTS Process Limit to ${FTS_PROCS}\e[0m"
sed -i "s/process_limit\s*=\s*[0-9]*/process_limit=${FTS_PROCS}/" /etc/dovecot/conf.d/fts.conf
fi
# 401 is user dovecot
if [[ ! -s /mail_crypt/ecprivkey.pem || ! -s /mail_crypt/ecpubkey.pem ]]; then
openssl ecparam -name prime256v1 -genkey | openssl pkey -out /mail_crypt/ecprivkey.pem
openssl pkey -in /mail_crypt/ecprivkey.pem -pubout -out /mail_crypt/ecpubkey.pem
chown 401 /mail_crypt/ecprivkey.pem /mail_crypt/ecpubkey.pem
else
chown 401 /mail_crypt/ecprivkey.pem /mail_crypt/ecpubkey.pem
fi
python3 -u /bootstrap/main.py
BOOTSTRAP_EXIT_CODE=$?
# Fix OpenSSL 3.X TLS1.0, 1.1 support (https://community.mailcow.email/d/4062-hi-all/20)
if grep -qE 'ssl_min_protocol\s*=\s*(TLSv1|TLSv1\.1)\s*$' /etc/dovecot/dovecot.conf /etc/dovecot/extra.conf; then
@@ -260,89 +22,10 @@ if grep -qE 'ssl_min_protocol\s*=\s*(TLSv1|TLSv1\.1)\s*$' /etc/dovecot/dovecot.c
echo "CipherString = DEFAULT@SECLEVEL=0" >> /etc/ssl/openssl.cnf
fi
# Compile sieve scripts
sievec /var/vmail/sieve/global_sieve_before.sieve
sievec /var/vmail/sieve/global_sieve_after.sieve
sievec /usr/lib/dovecot/sieve/report-spam.sieve
sievec /usr/lib/dovecot/sieve/report-ham.sieve
# Fix permissions
chown root:root /etc/dovecot/sql/*.conf
chown root:dovecot /etc/dovecot/sql/dovecot-dict-sql-sieve* /etc/dovecot/sql/dovecot-dict-sql-quota* /etc/dovecot/auth/passwd-verify.lua
chmod 640 /etc/dovecot/sql/*.conf /etc/dovecot/auth/passwd-verify.lua
chown -R vmail:vmail /var/vmail/sieve
chown -R vmail:vmail /var/volatile
chown -R vmail:vmail /var/vmail_index
adduser vmail tty
chmod g+rw /dev/console
chown root:tty /dev/console
chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
/usr/lib/dovecot/sieve/rspamd-pipe-spam \
/usr/local/bin/imapsync_runner.pl \
/usr/local/bin/imapsync \
/usr/local/bin/trim_logs.sh \
/usr/local/bin/sa-rules.sh \
/usr/local/bin/clean_q_aged.sh \
/usr/local/bin/maildir_gc.sh \
/usr/local/sbin/stop-supervisor.sh \
/usr/local/bin/quota_notify.py \
/usr/local/bin/repl_health.sh \
/usr/local/bin/optimize-fts.sh
# Prepare environment file for cronjobs
printenv | sed 's/^\(.*\)$/export \1/g' > /source_env.sh
# Clean old PID if any
[[ -f /var/run/dovecot/master.pid ]] && rm /var/run/dovecot/master.pid
# Clean stopped imapsync jobs
rm -f /tmp/imapsync_busy.lock
IMAPSYNC_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'imapsync'" -Bs)
[[ ! -z ${IMAPSYNC_TABLE} ]] && mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "UPDATE imapsync SET is_running='0'"
# Envsubst maildir_gc
echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh
# GUID generation
while [[ ${VERSIONS_OK} != 'OK' ]]; do
if [[ ! -z $(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = \"${DBNAME}\" AND TABLE_NAME = 'versions'") ]]; then
VERSIONS_OK=OK
else
echo "Waiting for versions table to be created..."
sleep 3
fi
done
PUBKEY_MCRYPT=$(doveconf -P 2> /dev/null | grep -i mail_crypt_global_public_key | cut -d '<' -f2)
if [ -f ${PUBKEY_MCRYPT} ]; then
GUID=$(cat <(echo ${MAILCOW_HOSTNAME}) /mail_crypt/ecpubkey.pem | sha256sum | cut -d ' ' -f1 | tr -cd "[a-fA-F0-9.:/] ")
if [ ${#GUID} -eq 64 ]; then
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
REPLACE INTO versions (application, version) VALUES ("GUID", "${GUID}");
EOF
else
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
REPLACE INTO versions (application, version) VALUES ("GUID", "INVALID");
EOF
fi
if [ $BOOTSTRAP_EXIT_CODE -ne 0 ]; then
echo "Bootstrap failed with exit code $BOOTSTRAP_EXIT_CODE. Not starting Dovecot."
exit $BOOTSTRAP_EXIT_CODE
fi
# Collect SA rules once now
/usr/local/bin/sa-rules.sh
# Run hooks
for file in /hooks/*; do
if [ -x "${file}" ]; then
echo "Running hook ${file}"
"${file}"
fi
done
# For some strange, unknown and stupid reason, Dovecot may run into a race condition, when this file is not touched before it is read by dovecot/auth
# May be related to something inside Docker, I seriously don't know
touch /etc/dovecot/auth/passwd-verify.lua
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
cp /etc/syslog-ng/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng.conf
fi
exec "$@"
echo "Bootstrap succeeded. Starting Dovecot..."
/usr/sbin/dovecot -F

View File

@@ -1,2 +0,0 @@
#!/bin/bash
[ -d /var/vmail/_garbage/ ] && /usr/bin/find /var/vmail/_garbage/ -mindepth 1 -maxdepth 1 -type d -cmin +${MAILDIR_GC_TIME} -exec rm -r {} \;

View File

@@ -14,6 +14,11 @@ import sys
import html2text
from subprocess import Popen, PIPE, STDOUT
# Don't run if role is not master
if os.getenv("MASTER").lower() in ["n", "no"]:
sys.exit()
if len(sys.argv) > 2:
percent = int(sys.argv[1])
username = str(sys.argv[2])

View File

@@ -11,8 +11,8 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autostart=true
[program:dovecot]
command=/usr/sbin/dovecot -F
[program:bootstrap]
command=/docker-entrypoint.sh
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr

View File

@@ -9,7 +9,8 @@ RUN apk add --no-cache nginx \
py3-pip && \
pip install --upgrade pip && \
pip install Jinja2 \
mysql-connector-python
mysql-connector-python \
redis
RUN mkdir -p /etc/nginx/includes

View File

@@ -42,7 +42,8 @@ RUN groupadd -g 102 postfix \
RUN pip install --break-system-packages \
mysql-connector-python \
jinja2
jinja2 \
redis
COPY data/Dockerfiles/bootstrap /bootstrap
COPY data/Dockerfiles/postfix/supervisord.conf /etc/supervisor/supervisord.conf
@@ -55,6 +56,7 @@ COPY data/Dockerfiles/postfix/stop-supervisor.sh /usr/local/sbin/stop-supervisor
COPY data/Dockerfiles/postfix/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /usr/local/bin/rspamd-pipe-ham \
/docker-entrypoint.sh \
/usr/local/bin/rspamd-pipe-spam \
/usr/local/bin/whitelist_forwardinghosts.sh \
/usr/local/sbin/stop-supervisor.sh

View File

@@ -45,7 +45,8 @@ RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
RUN pip install --break-system-packages \
mysql-connector-python \
jinja2
jinja2 \
redis
COPY data/Dockerfiles/bootstrap /bootstrap