From 17e858d51bb1662f69d7a420d5519edd3d7a1627 Mon Sep 17 00:00:00 2001 From: Christoph Date: Thu, 2 Jul 2026 02:11:55 +0200 Subject: [PATCH] install_update_dovecot-2.4.sh: enhance quota_clone plugin support for MySQL and update documentation --- install_quota_clone.sh | 464 ++++++++++++++++++++++++---------- install_update_dovecot-2.4.sh | 247 +++++++++++++++++- 2 files changed, 570 insertions(+), 141 deletions(-) diff --git a/install_quota_clone.sh b/install_quota_clone.sh index e296b4e..17312f8 100755 --- a/install_quota_clone.sh +++ b/install_quota_clone.sh @@ -3,13 +3,15 @@ ## install_quota_clone.sh ## ## Configures Dovecot 2.4 quota_clone plugin and enables PostfixAdmin -## quota display by writing real-time usage to the quota2 PostgreSQL table. +## quota display by writing real-time usage to the quota2 table. ## +## Supports PostgreSQL and MySQL/MariaDB backends. ## Must be run as root on the target mail server. ## ## What this script does: ## - Backs up the Dovecot and PostfixAdmin directories -## - Drops the old ADD-semantics mergequota2 PostgreSQL trigger +## - Creates the quota2 table if it does not exist (MySQL only) +## - Drops the old ADD-semantics mergequota2 trigger (if present) ## - Creates a new SET-semantics upsertquota2 trigger (validation only) ## - Creates /etc/dovecot/conf.d/91-quota-clone.conf (dict_server) ## - Appends the quota_clone plugin block to 90-quota.conf @@ -52,7 +54,6 @@ SQL_CONNECT="${DOVECOT_REAL}/etc/dovecot/sql-connect.conf.ext" info "Dovecot : $DOVECOT_REAL" # ── PostfixAdmin path (interactive) ────────────────────────────────────────── -# Auto-detect: look for a 'postfixadmin' symlink under /var/www/*/htdocs/ _pfa_default="" mapfile -t _pfa_candidates < <(find /var/www -maxdepth 3 -name "postfixadmin" -type l 2>/dev/null | sort) if [[ ${#_pfa_candidates[@]} -eq 1 ]]; then @@ -90,15 +91,50 @@ else fi info "Config : $PFA_CONFIG" -# ── read DB credentials from Dovecot's sql-connect.conf.ext ────────────────── +# ── DB credentials from sql-connect.conf.ext ───────────────────────────────── [[ -f "$SQL_CONNECT" ]] || die "Not found: $SQL_CONNECT" + +sql_driver=$(grep -E '^\s*sql_driver\s*=' "$SQL_CONNECT" | head -1 | sed 's/.*=\s*//' | tr -d '[:space:]') dbuser=$(grep -E '^\s*user\s*=' "$SQL_CONNECT" | head -1 | sed 's/.*=\s*//' | tr -d '[:space:]') dbpass=$(grep -E '^\s*password\s*=' "$SQL_CONNECT" | head -1 | sed 's/.*=\s*//' | tr -d '[:space:]') dbname=$(grep -E '^\s*dbname\s*=' "$SQL_CONNECT" | head -1 | sed 's/.*=\s*//' | tr -d '[:space:]') -[[ -n "$dbuser" ]] || die "Could not parse 'user' from $SQL_CONNECT" -[[ -n "$dbpass" ]] || die "Could not parse 'password' from $SQL_CONNECT" -[[ -n "$dbname" ]] || die "Could not parse 'dbname' from $SQL_CONNECT" -info "DB : $dbuser@$dbname" + +[[ -n "$sql_driver" ]] || die "Could not parse 'sql_driver' from $SQL_CONNECT" +[[ -n "$dbuser" ]] || die "Could not parse 'user' from $SQL_CONNECT" +[[ -n "$dbpass" ]] || die "Could not parse 'password' from $SQL_CONNECT" +[[ -n "$dbname" ]] || die "Could not parse 'dbname' from $SQL_CONNECT" + +case "$sql_driver" in + pgsql) + info "DB : PostgreSQL $dbuser@$dbname" + dbsocket="" + ;; + mysql) + # socket path: line looks like mysql /run/mysqld/mysqld.sock { + dbsocket=$(grep -E '^\s*mysql\s+/' "$SQL_CONNECT" | head -1 | awk '{print $2}') + [[ -n "$dbsocket" ]] || die "Could not parse MySQL socket path from $SQL_CONNECT" + info "DB : MariaDB/MySQL $dbuser@$dbname (socket: $dbsocket)" + ;; + *) + die "Unsupported sql_driver '$sql_driver' in $SQL_CONNECT (expected: pgsql or mysql)" + ;; +esac + +# ── DB helper: run SQL (DDL: CREATE TABLE, triggers) ───────────────────────── +# pgsql: postfix user via psql. +# mysql: OS root → MariaDB root via unix_socket auth (Debian default); +# falls back to postfix user credentials if that fails. +db_exec() { + if [[ "$sql_driver" == "pgsql" ]]; then + psql -U"$dbuser" "$dbname" "$@" + else + if mysql --socket="$dbsocket" "$dbname" "$@" 2>/dev/null; then + return 0 + else + mysql -u"$dbuser" -p"$dbpass" --socket="$dbsocket" "$dbname" "$@" + fi + fi +} # ── backup ──────────────────────────────────────────────────────────────────── heading "Backup" @@ -107,30 +143,70 @@ BACKUP_DIR="${BACKUP_BASE}/quota_clone_$(date +%Y%m%d_%H%M%S)" mkdir -p "$BACKUP_DIR" step "$(basename "$DOVECOT_REAL") ..." -tar -czf "${BACKUP_DIR}/dovecot.tar.gz" \ - -C "$(dirname "$DOVECOT_REAL")" "$(basename "$DOVECOT_REAL")" \ - && ok || { failed; die "Backup of $DOVECOT_REAL failed."; } +if tar -czf "${BACKUP_DIR}/dovecot.tar.gz" \ + -C "$(dirname "$DOVECOT_REAL")" "$(basename "$DOVECOT_REAL")"; then + ok +else + failed; die "Backup of $DOVECOT_REAL failed." +fi -step "$(basename "$PFA_REAL") ..." -tar -czf "${BACKUP_DIR}/postfixadmin.tar.gz" \ - -C "$(dirname "$PFA_REAL")" "$(basename "$PFA_REAL")" \ - && ok || { failed; die "Backup of $PFA_REAL failed."; } +_pfa_backup_src=$(dirname "$PFA_REAL") +_pfa_backup_name=$(basename "$PFA_REAL") +step "${_pfa_backup_name} ..." +if tar -czf "${BACKUP_DIR}/postfixadmin.tar.gz" \ + -C "$_pfa_backup_src" "$_pfa_backup_name"; then + ok +else + failed; die "Backup of $PFA_REAL failed." +fi info "Saved to: $BACKUP_DIR" -# ── PostgreSQL ──────────────────────────────────────────────────────────────── -heading "PostgreSQL" +# ── Database ────────────────────────────────────────────────────────────────── +if [[ "$sql_driver" == "pgsql" ]]; then + heading "PostgreSQL" +else + heading "MariaDB / MySQL" +fi -step "Drop old mergequota2 trigger + function ..." -psql -U"$dbuser" "$dbname" -c " - DROP TRIGGER IF EXISTS mergequota2 ON quota2; - DROP FUNCTION IF EXISTS merge_quota2(); -" > /dev/null 2>&1 \ - && ok || { failed; die "Could not drop old mergequota2 trigger."; } +if [[ "$sql_driver" == "mysql" ]]; then + step "Create quota2 table if not exists ..." + if db_exec -e " + CREATE TABLE IF NOT EXISTS quota2 ( + username varchar(100) NOT NULL, + bytes bigint NOT NULL DEFAULT 0, + messages int NOT NULL DEFAULT 0, + PRIMARY KEY (username) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + " > /dev/null 2>&1; then + ok + else + failed; die "Could not create quota2 table." + fi +fi -# Single-quoted heredoc: $$ is passed literally to psql (no shell expansion). -step "Create upsertquota2 trigger + function ..." -psql -U"$dbuser" "$dbname" <<'SQL' > /dev/null 2>&1 +step "Drop old mergequota2 trigger ..." +if [[ "$sql_driver" == "pgsql" ]]; then + if db_exec -c " + DROP TRIGGER IF EXISTS mergequota2 ON quota2; + DROP FUNCTION IF EXISTS merge_quota2(); + " > /dev/null 2>&1; then + ok + else + failed; die "Could not drop old mergequota2 trigger." + fi +else + if db_exec -e "DROP TRIGGER IF EXISTS mergequota2;" > /dev/null 2>&1; then + ok + else + failed; die "Could not drop old mergequota2 trigger." + fi +fi + +step "Create upsertquota2 trigger ..." +if [[ "$sql_driver" == "pgsql" ]]; then + # Single-quoted heredoc: $$ reaches psql literally (no shell expansion). + if db_exec <<'SQL' > /dev/null 2>&1 CREATE OR REPLACE FUNCTION upsert_quota2() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN @@ -152,20 +228,42 @@ BEGIN END; $$; SQL -[[ $? -eq 0 ]] && ok || { failed; die "Could not create upsertquota2 trigger."; } + then + ok + else + failed; die "Could not create upsertquota2 trigger." + fi +else + # DELIMITER lets the MySQL CLI handle ; inside BEGIN...END in batch mode. + if db_exec <<'SQL' > /dev/null 2>&1 +DROP TRIGGER IF EXISTS upsertquota2; +DELIMITER // +CREATE TRIGGER upsertquota2 + BEFORE INSERT ON quota2 + FOR EACH ROW + BEGIN + IF NEW.bytes IS NULL OR NEW.bytes < 0 THEN SET NEW.bytes = 0; END IF; + IF NEW.messages IS NULL OR NEW.messages < 0 THEN SET NEW.messages = 0; END IF; + END// +DELIMITER ; +SQL + then + ok + else + failed; die "Could not create upsertquota2 trigger." + fi +fi # ── Dovecot configuration ───────────────────────────────────────────────────── heading "Dovecot configuration" -# 91-quota-clone.conf — always (re)written, content is idempotent QUOTA_CLONE_CONF="${DOVECOT_CONF_D}/91-quota-clone.conf" -step "Write 91-quota-clone.conf ..." -cat > "$QUOTA_CLONE_CONF" < "$QUOTA_CLONE_CONF" < "$QUOTA_CLONE_CONF" </dev/null; then +if grep -q "$_dict_name" "$QUOTA_CONF" 2>/dev/null; then skip else - cat >> "$QUOTA_CONF" <<'EOF' + if cat >> "$QUOTA_CONF" <> "$PFA_CONFIG" \ - && ok || { failed; die "Could not append \$CONF['${key}'] to $PFA_CONFIG"; } + if { echo ""; echo "\$CONF['${key}'] = '${val}';"; } >> "$PFA_CONFIG"; then + ok + else + failed; die "Could not append \$CONF['${key}'] to $PFA_CONFIG" + fi fi } -pfa_set used_quotas YES +pfa_set used_quotas YES pfa_set new_quota_table YES # ── /usr/local/bin/update_quota2.sh ────────────────────────────────────────── heading "Batch update script" -# Single-quoted PYEOF: no shell expansion inside the Python source. step "Install /usr/local/bin/update_quota2.sh ..." + +if [[ "$sql_driver" == "pgsql" ]]; then + cat > /usr/local/bin/update_quota2.sh <<'PYEOF' #!/usr/bin/env python3 """ -Batch-update the quota2 table for all active mailboxes. +Batch-update the quota2 table for all active mailboxes (PostgreSQL). -Uses 'doveadm quota get' (must run as root) to read current usage and -writes it to quota2 via INSERT ... ON CONFLICT DO UPDATE. +Uses 'doveadm quota get' (must run as root) and writes to quota2 via +INSERT ... ON CONFLICT DO UPDATE. -Usage: - update_quota2.sh [--dry-run] [--limit N] - -Options: - --dry-run Print what would be done without touching the database. - --limit N Process only the first N mailboxes (useful for testing). +Usage: update_quota2.sh [--dry-run] [--limit N] """ - -import subprocess -import sys +import subprocess, sys DOVEADM = '/usr/local/dovecot/bin/doveadm' PSQL = 'psql' DB_USER = 'postfix' DB_NAME = 'postfix' - def get_users(limit=None): - query = 'SELECT username FROM mailbox WHERE active = true ORDER BY username;' + q = 'SELECT username FROM mailbox WHERE active = true ORDER BY username;' if limit: - query = ('SELECT username FROM mailbox WHERE active = true ' - 'ORDER BY username LIMIT %d;' % limit) - result = subprocess.run( - [PSQL, '-U', DB_USER, DB_NAME, '-tAF', '|', '-c', query], - capture_output=True, text=True - ) - return [u.strip() for u in result.stdout.strip().split('\n') if u.strip()] - + q = 'SELECT username FROM mailbox WHERE active = true ORDER BY username LIMIT %d;' % limit + r = subprocess.run([PSQL, '-U', DB_USER, DB_NAME, '-tAF', '|', '-c', q], + capture_output=True, text=True) + return [u.strip() for u in r.stdout.strip().split('\n') if u.strip()] def get_quota(user): - """Returns (bytes, messages) or (None, None) if not available.""" - result = subprocess.run( - [DOVEADM, 'quota', 'get', '-u', user], - capture_output=True, text=True - ) - bytes_val = messages_val = None - for line in result.stdout.splitlines(): - parts = line.split() - if len(parts) < 4: - continue - if parts[2] == 'STORAGE' and parts[1] != 'Type': - try: - bytes_val = int(parts[3]) * 1024 # KiB → bytes - except ValueError: - bytes_val = 0 - elif parts[2] == 'MESSAGE' and parts[1] != 'Type': - try: - messages_val = int(parts[3]) - except ValueError: - messages_val = 0 - return bytes_val, messages_val - - -def upsert_quota2(user, bytes_val, messages_val): - sql = ( - "INSERT INTO quota2 (username, bytes, messages) VALUES ('" - + user.replace("'", "''") - + "', " + str(bytes_val) + ", " + str(messages_val) + ") " - "ON CONFLICT (username) DO UPDATE " - "SET bytes = EXCLUDED.bytes, messages = EXCLUDED.messages;" - ) - subprocess.run([PSQL, '-U', DB_USER, DB_NAME, '-c', sql], - capture_output=True) + r = subprocess.run([DOVEADM, 'quota', 'get', '-u', user], + capture_output=True, text=True) + bv = mv = None + for line in r.stdout.splitlines(): + p = line.split() + if len(p) < 4: continue + if p[2] == 'STORAGE' and p[1] != 'Type': + try: bv = int(p[3]) * 1024 + except ValueError: bv = 0 + elif p[2] == 'MESSAGE' and p[1] != 'Type': + try: mv = int(p[3]) + except ValueError: mv = 0 + return bv, mv +def upsert(user, bv, mv): + sql = ("INSERT INTO quota2 (username,bytes,messages) VALUES ('" + + user.replace("'","''") + "'," + str(bv) + "," + str(mv) + ") " + "ON CONFLICT (username) DO UPDATE " + "SET bytes=EXCLUDED.bytes, messages=EXCLUDED.messages;") + subprocess.run([PSQL, '-U', DB_USER, DB_NAME, '-c', sql], capture_output=True) def main(): - dry_run = '--dry-run' in sys.argv + dry = '--dry-run' in sys.argv limit = None if '--limit' in sys.argv: - idx = sys.argv.index('--limit') - try: - limit = int(sys.argv[idx + 1]) + try: limit = int(sys.argv[sys.argv.index('--limit') + 1]) except (IndexError, ValueError): - print('Usage: update_quota2.sh [--dry-run] [--limit N]') - sys.exit(1) - + print('Usage: update_quota2.sh [--dry-run] [--limit N]'); sys.exit(1) users = get_users(limit) total = len(users) print('Processing %d mailboxes...' % total) - - updated = skipped = 0 + ok = skip = 0 for i, user in enumerate(users, 1): - bytes_val, messages_val = get_quota(user) - if bytes_val is None or messages_val is None: - print(' [%d/%d] SKIP %s (no quota data)' % (i, total, user)) - skipped += 1 - continue - if dry_run: - print(' [%d/%d] DRY %s: %d bytes, %d messages' - % (i, total, user, bytes_val, messages_val)) + bv, mv = get_quota(user) + if bv is None or mv is None: + print(' [%d/%d] SKIP %s' % (i, total, user)); skip += 1; continue + if dry: + print(' [%d/%d] DRY %s: %d bytes, %d msgs' % (i, total, user, bv, mv)) else: - upsert_quota2(user, bytes_val, messages_val) - if i % 50 == 0 or i == total: - print(' [%d/%d] done' % (i, total)) - updated += 1 + upsert(user, bv, mv) + if i % 50 == 0 or i == total: print(' [%d/%d] done' % (i, total)) + ok += 1 + print('Done: %d updated, %d skipped.' % (ok, skip)) - print('Done: %d updated, %d skipped.' % (updated, skipped)) - - -if __name__ == '__main__': - main() +if __name__ == '__main__': main() PYEOF -chmod +x /usr/local/bin/update_quota2.sh -[[ $? -eq 0 ]] && ok || { failed; die "Could not install update_quota2.sh"; } + +else # mysql — shell expands DB_USER, DB_PASS, DB_SOCKET, DB_NAME into the script + +cat > /usr/local/bin/update_quota2.sh < /dev/null 2>&1 +CREATE TABLE IF NOT EXISTS quota2 ( + username varchar(100) NOT NULL, + bytes bigint NOT NULL DEFAULT 0, + messages integer NOT NULL DEFAULT 0, + PRIMARY KEY (username) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Creating quota2 table failed" + fi + + ## - Drop old ADD-semantics trigger if present + + echononl " Drop old mergequota2 trigger (if present).." + cat << EOF | mysql -u$dbuser -p$dbpassword $dbname > /dev/null 2>&1 +DROP TRIGGER IF EXISTS mergequota2; +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Dropping mergequota2 trigger failed" + fi + + ## - Create upsertquota2 trigger (DELIMITER needed in MySQL batch mode) + + echononl " Create upsertquota2 trigger.." + cat << 'EOF' | mysql -u$dbuser -p$dbpassword $dbname > /dev/null 2>&1 +DROP TRIGGER IF EXISTS upsertquota2; +DELIMITER // +CREATE TRIGGER upsertquota2 + BEFORE INSERT ON quota2 + FOR EACH ROW + BEGIN + IF NEW.bytes IS NULL OR NEW.bytes < 0 THEN SET NEW.bytes = 0; END IF; + IF NEW.messages IS NULL OR NEW.messages < 0 THEN SET NEW.messages = 0; END IF; + END// +DELIMITER ; +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Creating upsertquota2 trigger failed" + fi + + ## - Create 91-quota-clone.conf + + _conf_file="/usr/local/dovecot-${_version}/etc/dovecot/conf.d/91-quota-clone.conf" + echononl " Create '$(basename "${_conf_file}")' (dict_server for quota_clone).." + cat < "${_conf_file}" +## +## Quota Clone Configuration (Dovecot 2.4+ / MariaDB / MySQL) +## +## Writes quota usage to the quota2 table in real time so +## PostfixAdmin can display mailbox fill levels. +## +dict_server { + dict quota_clone_mysql { + driver = sql + sql_driver = mysql + mysql $dbhost { + user = $dbuser + password = $dbpassword + dbname = $dbname + } + dict_map priv/quota/storage { + sql_table = quota2 + username_field = username + value_field bytes { + } + } + dict_map priv/quota/messages { + sql_table = quota2 + username_field = username + value_field messages { + } + } + } +} +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Creating file '${_conf_file}' failed!" + fi + + ## - Append quota_clone plugin block to 90-quota.conf + + _conf_file="/usr/local/dovecot-${_version}/etc/dovecot/conf.d/90-quota.conf" + echononl " Append quota_clone block to '$(basename "${_conf_file}")'.." + if ! grep -q "quota_clone_mysql" "${_conf_file}" 2>/dev/null; then + cat <<'EOF' >> "${_conf_file}" + +## +## Quota Clone - write quota usage to quota2 table for PostfixAdmin +## +mail_plugins { + quota_clone = yes +} + +quota_clone { + dict proxy { + name = quota_clone_mysql + } +} +EOF + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Appending quota_clone block to '${_conf_file}' failed!" + fi + else + echo -e "${rc_already_done}" + fi + + ## - Install /usr/local/bin/update_quota2.sh (initial batch fill) + ## - Variables $dbuser, $dbpassword, $dbhost, $dbname are expanded + ## - into the Python script by the unquoted heredoc. + + echononl " Install /usr/local/bin/update_quota2.sh.." + cat < /usr/local/bin/update_quota2.sh +#!/usr/bin/env python3 +"""Batch-update quota2 table for all active mailboxes from doveadm quota get.""" + +import subprocess +import sys + +DOVEADM = '/usr/local/dovecot/bin/doveadm' +MYSQL = 'mysql' +DB_USER = '$dbuser' +DB_PASS = '$dbpassword' +DB_SOCKET = '$dbhost' +DB_NAME = '$dbname' + + +def mysql_query(sql): + return subprocess.run( + [MYSQL, '-u', DB_USER, '-p' + DB_PASS, '--socket', DB_SOCKET, + DB_NAME, '-sNe', sql], + capture_output=True, text=True) + + +def get_users(limit=None): + query = 'SELECT username FROM mailbox WHERE active = 1 ORDER BY username;' + if limit: + query = 'SELECT username FROM mailbox WHERE active = 1 ORDER BY username LIMIT %d;' % limit + r = mysql_query(query) + return [u.strip() for u in r.stdout.strip().split('\n') if u.strip()] + + +def get_quota(user): + result = subprocess.run( + [DOVEADM, 'quota', 'get', '-u', user], + capture_output=True, text=True + ) + bytes_val = None + messages_val = None + for line in result.stdout.splitlines(): + parts = line.split() + if len(parts) < 4: + continue + if parts[2] == 'STORAGE' and parts[1] != 'Type': + try: + bytes_val = int(parts[3]) * 1024 + except ValueError: + bytes_val = 0 + elif parts[2] == 'MESSAGE' and parts[1] != 'Type': + try: + messages_val = int(parts[3]) + except ValueError: + messages_val = 0 + return bytes_val, messages_val + + +def upsert_quota2(user, bytes_val, messages_val): + sql = ("REPLACE INTO quota2 (username, bytes, messages) VALUES ('" + + user.replace("'", "\\'") + "', " + + str(bytes_val) + ", " + str(messages_val) + ");") + mysql_query(sql) + + +def main(): + dry_run = '--dry-run' in sys.argv + limit = None + if '--limit' in sys.argv: + idx = sys.argv.index('--limit') + try: + limit = int(sys.argv[idx + 1]) + except (IndexError, ValueError): + print('Usage: update_quota2.sh [--dry-run] [--limit N]') + sys.exit(1) + + users = get_users(limit) + total = len(users) + print('Processing %d mailboxes...' % total) + + ok = 0 + skip = 0 + for i, user in enumerate(users, 1): + bytes_val, messages_val = get_quota(user) + if bytes_val is None or messages_val is None: + print(' [%d/%d] SKIP %s (no quota data)' % (i, total, user)) + skip += 1 + continue + if dry_run: + print(' [%d/%d] DRY %s: %d bytes, %d messages' % (i, total, user, bytes_val, messages_val)) + else: + upsert_quota2(user, bytes_val, messages_val) + if i % 50 == 0 or i == total: + print(' [%d/%d] done' % (i, total)) + ok += 1 + + print('Done: %d updated, %d skipped.' % (ok, skip)) + + +if __name__ == '__main__': + main() +PYEOF + chmod +x /usr/local/bin/update_quota2.sh + if [ "$?" = 0 ]; then + echo -e "$rc_done" + else + echo -e "$rc_failed" + error "Installing /usr/local/bin/update_quota2.sh failed!" + fi + + fi # db_driver fi # Dovecot 2.4+ quota_clone