diff --git a/.gitignore b/.gitignore index 0d081fc4..b945f8cf 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,3 @@ data/conf/nginx/*.conf data/conf/nginx/*.custom data/conf/nginx/*.bak data/conf/dovecot/extra.conf -data/conf/rspamd/custom/* diff --git a/data/Dockerfiles/acme/Dockerfile b/data/Dockerfiles/acme/Dockerfile index 44e7030b..5c99cbf9 100644 --- a/data/Dockerfiles/acme/Dockerfile +++ b/data/Dockerfiles/acme/Dockerfile @@ -3,20 +3,21 @@ FROM alpine:3.6 LABEL maintainer "Andre Peters " RUN apk add --update --no-cache \ - bash \ - curl \ - openssl \ - bind-tools \ - jq \ + bash \ + curl \ + openssl \ + bind-tools \ + jq \ libressl-dev \ libbsd-dev \ libseccomp-dev \ - mariadb-client \ + mariadb-client \ tini \ make \ gcc \ libressl \ libc-dev \ + redis \ linux-headers \ ca-certificates \ && curl -s https://kristaps.bsd.lv/acme-client/snapshots/acme-client-portable.tgz | tar xfvz - \ diff --git a/data/Dockerfiles/acme/docker-entrypoint.sh b/data/Dockerfiles/acme/docker-entrypoint.sh index d29d812e..ef466d36 100755 --- a/data/Dockerfiles/acme/docker-entrypoint.sh +++ b/data/Dockerfiles/acme/docker-entrypoint.sh @@ -2,16 +2,30 @@ set -o pipefail exec 5>&1 +log_f() { + if [[ ${2} == "no_nl" ]]; then + echo -n "$(date) - ${1}" + elif [[ ${2} == "no_date" ]]; then + echo "${1}" + elif [[ ${2} != "redis_only" ]]; then + echo "$(date) - ${1}" + fi + redis-cli -h redis LPUSH ACME_LOG "{\"time\":\"$(date +%s)\",\"message\":\"$(printf '%s' "${1}" | \ + tr '%&;$"_[]{}-\r\n' ' ')\"}" > /dev/null + redis-cli -h redis LTRIM ACME_LOG 0 9999 > /dev/null +} + if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..." sleep 365d exec $(readlink -f "$0") fi -echo "Waiting for Docker API..." +log_f "Waiting for Docker API..." no_nl until ping dockerapi -c1 > /dev/null; do sleep 1 done +log_f "Found Docker API" no_date ACME_BASE=/var/lib/acme SSL_EXAMPLE=/var/lib/ssl-example @@ -20,21 +34,12 @@ mkdir -p ${ACME_BASE}/acme/private restart_containers(){ for container in $*; do - echo "Restarting ${container}..." - curl -X POST http://dockerapi:8080/containers/${container}/restart + log_f "Restarting ${container}..." no_nl + C_REST_OUT=$(curl -X POST http://dockerapi:8080/containers/${container}/restart | jq -r '.msg') + log_f "${C_REST_OUT}" no_date done } -log_f() { - if [[ ${2} == "no_nl" ]]; then - echo -n "$(date) - ${1}" - elif [[ ${2} == "no_date" ]]; then - echo "${1}" - else - echo "$(date) - ${1}" - fi -} - array_diff() { # https://stackoverflow.com/questions/2312762, Alex Offshore eval local ARR1=\(\"\${$2[@]}\"\) @@ -123,16 +128,23 @@ while true; do declare -a VALIDATED_CONFIG_DOMAINS declare -a ADDITIONAL_VALIDATED_SAN IFS=',' read -r -a ADDITIONAL_SAN_ARR <<< "${ADDITIONAL_SAN}" - IPV4=$(get_ipv4) + until [[ ${IPV4} == ${EXTERNAL_IPV4} ]]; do + IPV4=$(get_ipv4) + if [[ ${IPV4} != ${EXTERNAL_IPV4} ]]; then + echo "Waiting for correct source ip..." + sleep 30s + fi + done # Container ids may have changed CONTAINERS_RESTART=($(curl --silent http://dockerapi:8080/containers/json | jq -r '.[] | {name: .Config.Labels["com.docker.compose.service"], id: .Id}' | jq -rc 'select( .name | tostring | contains("nginx-mailcow") or contains("postfix-mailcow") or contains("dovecot-mailcow")) | .id' | tr "\n" " ")) - log_f "Waiting for domain tables... " no_nl + log_f "Waiting for domain table... " no_nl while [[ -z ${DOMAIN_TABLE} ]]; do + curl --silent http://nginx/ >/dev/null 2>&1 DOMAIN_TABLE=$(mysql -h mysql-mailcow -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'domain'" -Bs) [[ -z ${DOMAIN_TABLE} ]] && sleep 10 done - log_f "OK" no_date + log_f "Found domain tables." no_date while read domains; do SQL_DOMAIN_ARR+=("${domains}") @@ -226,6 +238,7 @@ while true; do case "$?" in 0) # new certs + log_f "${ACME_RESPONSE}" redis_only # cp the new certificates and keys cp ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem cp ${ACME_BASE}/acme/private/privkey.pem ${ACME_BASE}/key.pem @@ -239,6 +252,7 @@ while true; do restart_containers ${CONTAINERS_RESTART[*]} ;; 1) # failure + log_f "${ACME_RESPONSE}" redis_only if [[ $ACME_RESPONSE =~ "No registration exists" ]]; then log_f "Registration keys are invalid, deleting old keys and restarting..." rm ${ACME_BASE}/acme/private/account.key @@ -268,6 +282,7 @@ while true; do exec $(readlink -f "$0") ;; 2) # no change + log_f "${ACME_RESPONSE}" redis_only if ! diff ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem; then log_f "Certificate was not changed, but active certificate does not match the verified certificate, fixing and restarting containers..." cp ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem @@ -280,9 +295,11 @@ while true; do cp ${ACME_BASE}/acme/private/privkey.pem ${ACME_BASE}/key.pem TRIGGER_RESTART=1 fi + log_f "Certificate was not changed" [[ ${TRIGGER_RESTART} == 1 ]] && restart_containers ${CONTAINERS_RESTART[*]} ;; *) # unspecified + log_f "${ACME_RESPONSE}" redis_only if [[ -f ${ACME_BASE}/acme/private/${DATE}.bak/fullchain.pem ]] && [[ -f ${ACME_BASE}/acme/private/${DATE}.bak/privkey.pem ]]; then log_f "Error requesting certificate, restoring previous certificate from backup and restarting containers...." cp ${ACME_BASE}/acme/private/${DATE}.bak/fullchain.pem ${ACME_BASE}/cert.pem diff --git a/data/Dockerfiles/dockerapi/server.py b/data/Dockerfiles/dockerapi/server.py index 63916eb8..00f7ebef 100644 --- a/data/Dockerfiles/dockerapi/server.py +++ b/data/Dockerfiles/dockerapi/server.py @@ -6,6 +6,9 @@ from threading import Thread import docker import signal import time +import os +import re +import sys docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto') app = Flask(__name__) @@ -15,7 +18,7 @@ class containers_get(Resource): def get(self): containers = {} try: - for container in docker_client.containers.list(all=True): + for container in docker_client.containers.list(all=True, filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME']}): containers.update({container.attrs['Id']: container.attrs}) return containers except Exception as e: @@ -25,19 +28,30 @@ class container_get(Resource): def get(self, container_id): if container_id and container_id.isalnum(): try: - for container in docker_client.containers.list(all=True, filters={"id": container_id}): + for container in docker_client.containers.list(all=True, filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME'], "id": container_id}): return container.attrs except Exception as e: return jsonify(type='danger', msg=e) else: return jsonify(type='danger', msg='no or invalid id defined') +class container_logs(Resource): + def get(self, container_id, lines): + if container_id and container_id.isalnum() and lines: + try: + for container in docker_client.containers.list(all=True, filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME'], "id": container_id}): + return container.logs(stdout=True, stderr=True, stream=False, tail=lines) + except Exception as e: + return jsonify(type='danger', msg=e) + else: + return jsonify(type='danger', msg='no or invalid id defined') + class container_post(Resource): def post(self, container_id, post_action): if container_id and container_id.isalnum() and post_action: if post_action == 'stop': try: - for container in docker_client.containers.list(all=True, filters={"id": container_id}): + for container in docker_client.containers.list(all=True, filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME'], "id": container_id}): container.stop() return jsonify(type='success', msg='command completed successfully') except Exception as e: @@ -45,7 +59,7 @@ class container_post(Resource): elif post_action == 'start': try: - for container in docker_client.containers.list(all=True, filters={"id": container_id}): + for container in docker_client.containers.list(all=True, filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME'], "id": container_id}): container.start() return jsonify(type='success', msg='command completed successfully') except Exception as e: @@ -53,7 +67,7 @@ class container_post(Resource): elif post_action == 'restart': try: - for container in docker_client.containers.list(all=True, filters={"id": container_id}): + for container in docker_client.containers.list(all=True, filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME'], "id": container_id}): container.restart() return jsonify(type='success', msg='command completed successfully') except Exception as e: @@ -66,16 +80,28 @@ class container_post(Resource): if request.json['cmd'] == 'sieve_list' and request.json['username']: try: - for container in docker_client.containers.list(filters={"id": container_id}): + for container in docker_client.containers.list(filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME'], "id": container_id}): return container.exec_run(["/bin/bash", "-c", "/usr/local/bin/doveadm sieve list -u '" + request.json['username'].replace("'", "'\\''") + "'"], user='vmail') except Exception as e: return jsonify(type='danger', msg=e) elif request.json['cmd'] == 'sieve_print' and request.json['script_name'] and request.json['username']: try: - for container in docker_client.containers.list(filters={"id": container_id}): + for container in docker_client.containers.list(filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME'], "id": container_id}): return container.exec_run(["/bin/bash", "-c", "/usr/local/bin/doveadm sieve get -u '" + request.json['username'].replace("'", "'\\''") + "' '" + request.json['script_name'].replace("'", "'\\''") + "'"], user='vmail') except Exception as e: return jsonify(type='danger', msg=e) + elif request.json['cmd'] == 'worker_password' and request.json['raw']: + try: + for container in docker_client.containers.list(filters={"label": "com.docker.compose.project=" + os.environ['COMPOSE_PROJECT_NAME'], "id": container_id}): + hash = container.exec_run(["/bin/bash", "-c", "/usr/bin/rspamadm pw -e -p '" + request.json['raw'].replace("'", "'\\''") + "'"], user='_rspamd') + f = open("/access.inc", "w") + f.write('enable_password = "' + re.sub('[^0-9a-zA-Z\$]+', '', hash.rstrip()) + '";\n') + f.close() + container.restart() + return jsonify(type='success', msg='command completed successfully') + except Exception as e: + return jsonify(type='danger', msg=e) + else: return jsonify(type='danger', msg='Unknown command') @@ -99,6 +125,7 @@ def startFlaskAPI(): api.add_resource(containers_get, '/containers/json') api.add_resource(container_get, '/containers//json') +api.add_resource(container_logs, '/containers//logs/') api.add_resource(container_post, '/containers//') if __name__ == '__main__': diff --git a/data/Dockerfiles/phpfpm/Dockerfile b/data/Dockerfiles/phpfpm/Dockerfile index 84a1f67b..5259e70d 100644 --- a/data/Dockerfiles/phpfpm/Dockerfile +++ b/data/Dockerfiles/phpfpm/Dockerfile @@ -41,8 +41,8 @@ RUN apk add -U --no-cache libxml2-dev \ Net_Sieve \ NET_SMTP \ Mail_mime \ - && pecl install redis-${REDIS_PECL} memcached-${MEMCACHED_PECL} APCu-${APCU_PECL} imagick-${IMAGICK_PECL} \ - && docker-php-ext-enable redis apcu memcached imagick \ + && pecl install redis-${REDIS_PECL} memcached-${MEMCACHED_PECL} APCu-${APCU_PECL} imagick-${IMAGICK_PECL} mailparse \ + && docker-php-ext-enable redis apcu memcached imagick mailparse \ && pecl clear-cache \ && docker-php-ext-configure intl \ && docker-php-ext-install -j 4 intl gettext ldap sockets soap pdo pdo_mysql xmlrpc gd zip pcntl opcache \ diff --git a/data/Dockerfiles/postfix/whitelist_forwardinghosts.sh b/data/Dockerfiles/postfix/whitelist_forwardinghosts.sh index ab066d89..4ad5ab32 100755 --- a/data/Dockerfiles/postfix/whitelist_forwardinghosts.sh +++ b/data/Dockerfiles/postfix/whitelist_forwardinghosts.sh @@ -6,7 +6,7 @@ while read QUERY; do echo "500 dunno" continue fi - result=$(curl -s http://172.22.1.251:8081/forwardinghosts.php?host=${QUERY[1]}) + result=$(curl -s http://nginx:8081/forwardinghosts.php?host=${QUERY[1]}) logger -t whitelist_forwardinghosts -p mail.info "Look up ${QUERY[1]} on whitelist, result $result" echo ${result} done diff --git a/data/Dockerfiles/watchdog/watchdog.sh b/data/Dockerfiles/watchdog/watchdog.sh index 902f66dc..ec7133e5 100755 --- a/data/Dockerfiles/watchdog/watchdog.sh +++ b/data/Dockerfiles/watchdog/watchdog.sh @@ -28,21 +28,19 @@ progress() { [[ ${CURRENT} -gt ${TOTAL} ]] && return [[ ${CURRENT} -lt 0 ]] && CURRENT=0 PERCENT=$(( 200 * ${CURRENT} / ${TOTAL} % 2 + 100 * ${CURRENT} / ${TOTAL} )) - log_msg "${SERVICE} health level: ${PERCENT}% (${CURRENT}/${TOTAL}), health trend: ${DIFF}" - log_data "$(printf "%d,%d,%d,%d" ${PERCENT} ${CURRENT} ${TOTAL} ${DIFF})" "${SERVICE}" + redis-cli -h redis LPUSH WATCHDOG_LOG "{\"time\":\"$(date +%s)\",\"service\":\"${SERVICE}\",\"lvl\":\"${PERCENT}\",\"hpnow\":\"${CURRENT}\",\"hptotal\":\"${TOTAL}\",\"hpdiff\":\"${DIFF}\"}" > /dev/null + log_msg "${SERVICE} health level: ${PERCENT}% (${CURRENT}/${TOTAL}), health trend: ${DIFF}" no_redis } log_msg() { - redis-cli -h redis LPUSH WATCHDOG_LOG "{\"time\":\"$(date +%s)\",\"message\":\"$(printf '%s' "${1}")\"}" > /dev/null + if [[ ${2} != "no_redis" ]]; then + redis-cli -h redis LPUSH WATCHDOG_LOG "{\"time\":\"$(date +%s)\",\"message\":\"$(printf '%s' "${1}" | \ + tr '%&;$"_[]{}-\r\n' ' ')\"}" > /dev/null + fi + redis-cli -h redis LTRIM WATCHDOG_LOG 0 9999 > /dev/null echo $(date) $(printf '%s\n' "${1}") } -log_data() { - [[ -z ${1} ]] && return 1 - [[ -z ${2} ]] && return 2 - redis-cli -h redis LPUSH WATCHDOG_DATA "{\"time\":\"$(date +%s)\",\"service\":\"data\",\"$(printf '%s' "${2}")\":\"$(printf '%s' "${1}")\"}" > /dev/null -} - function mail_error() { [[ -z ${1} ]] && return 1 [[ -z ${2} ]] && return 2 @@ -234,27 +232,6 @@ Empty return 1 } -dns_checks() { - err_count=0 - diff_c=0 - THRESHOLD=28 - # Reduce error count by 2 after restarting an unhealthy container - trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1 - while [ ${err_count} -lt ${THRESHOLD} ]; do - host_ip=$(get_container_ip unbound-mailcow) - err_c_cur=${err_count} - /usr/lib/nagios/plugins/check_dns -H google.com 1>&2; err_count=$(( ${err_count} + ($? * 2))) - /usr/lib/nagios/plugins/check_dns -s ${host_ip} -H google.com 1>&2; err_count=$(( ${err_count} + ($? * 2))) - dig +dnssec org. @${host_ip} | grep -E 'flags:.+ad' 1>&2; err_count=$(( ${err_count} + ($? * 2))) - [ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1 - [ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} )) - progress "Unbound" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c} - diff_c=0 - sleep $(( ( RANDOM % 30 ) + 10 )) - done - return 1 -} - # Create watchdog agents ( while true; do @@ -322,17 +299,6 @@ done ) & BACKGROUND_TASKS+=($!) -( -while true; do - if ! dns_checks; then - log_msg "Unbound hit error limit" - [[ ! -z ${WATCHDOG_NOTIFY_EMAIL} ]] && mail_error "${WATCHDOG_NOTIFY_EMAIL}" "unbound-mailcow" - #echo unbound-mailcow > /tmp/com_pipe - fi -done -) & -BACKGROUND_TASKS+=($!) - ( while true; do if ! rspamd_checks; then diff --git a/data/conf/mysql/my.cnf b/data/conf/mysql/my.cnf index 668e390a..772c8a62 100644 --- a/data/conf/mysql/my.cnf +++ b/data/conf/mysql/my.cnf @@ -6,6 +6,13 @@ innodb_file_per_table = TRUE innodb_file_format = barracuda innodb_large_prefix = TRUE #sql_mode=IGNORE_SPACE,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION +max_allowed_packet=192M +max-connections=1500 +innodb-strict-mode=0 +skip-host-cache +skip-name-resolve +log-warnings=0 +event_scheduler=1 [client] default-character-set = utf8mb4 diff --git a/data/conf/postfix/main.cf b/data/conf/postfix/main.cf index 5a8c0fa7..4e8c577f 100644 --- a/data/conf/postfix/main.cf +++ b/data/conf/postfix/main.cf @@ -9,7 +9,7 @@ smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_una alias_maps = hash:/etc/aliases alias_database = hash:/etc/aliases relayhost = -mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 [fd4d:6169:6c63:6f77::]/64 !172.22.1.1 +mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 [fd4d:6169:6c63:6f77::]/64 mailbox_size_limit = 0 recipient_delimiter = + inet_interfaces = all diff --git a/data/conf/postfix/master.cf b/data/conf/postfix/master.cf index 77790d56..4911271e 100644 --- a/data/conf/postfix/master.cf +++ b/data/conf/postfix/master.cf @@ -12,6 +12,11 @@ submission inet n - n - - smtpd 588 inet n - n - - smtpd -o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject -o smtpd_tls_auth_only=no +590 inet n - n - - smtpd + -o smtpd_client_restrictions=permit_mynetworks,reject + -o smtpd_tls_auth_only=no + -o smtpd_milters= + -o non_smtpd_milters= smtp_enforced_tls unix - - n - - smtp -o smtp_tls_security_level=encrypt -o syslog_name=enforced-tls-smtp diff --git a/data/conf/rspamd/custom/.empty b/data/conf/rspamd/custom/.empty deleted file mode 100644 index d00491fd..00000000 --- a/data/conf/rspamd/custom/.empty +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/data/conf/rspamd/dynmaps/settings.php b/data/conf/rspamd/dynmaps/settings.php index 335c0c66..fcc656a8 100644 --- a/data/conf/rspamd/dynmaps/settings.php +++ b/data/conf/rspamd/dynmaps/settings.php @@ -17,7 +17,7 @@ $opt = [ ]; try { $pdo = new PDO($dsn, $database_user, $database_pass, $opt); - $stmt = $pdo->query("SELECT * FROM `filterconf`"); + $stmt = $pdo->query("SELECT '1' FROM `filterconf`"); } catch (PDOException $e) { echo 'settings { }'; diff --git a/data/conf/rspamd/local.d/composites.conf b/data/conf/rspamd/local.d/composites.conf index e895fb0f..ea35dc92 100644 --- a/data/conf/rspamd/local.d/composites.conf +++ b/data/conf/rspamd/local.d/composites.conf @@ -1,4 +1,8 @@ MX_IMPLICIT { - expression = "MX_GOOD and MX_MISSING"; - score = -0.01; + expression = "MX_GOOD and MX_MISSING"; + score = -0.01; +} +VIRUS_FOUND { + expression = "CLAM_VIRUS & !MAILCOW_WHITE"; + score = 2000; } diff --git a/data/conf/rspamd/local.d/force_actions.conf b/data/conf/rspamd/local.d/force_actions.conf index 956402f5..a1b9899d 100644 --- a/data/conf/rspamd/local.d/force_actions.conf +++ b/data/conf/rspamd/local.d/force_actions.conf @@ -1,14 +1,4 @@ rules { - DKIM_FAIL { - action = "add header"; - expression = "R_DKIM_REJECT & !MAILLIST & !MAILCOW_WHITE & !MAILCOW_BLACK"; - require_action = ["no action", "greylist", "soft reject"]; - } - VIRUS_FOUND { - action = "reject"; - expression = "CLAM_VIRUS & !MAILCOW_WHITE"; - honor_action = ["reject"]; - } WHITELIST_FORWARDING_HOST_NO_REJECT { action = "add header"; expression = "WHITELISTED_FWD_HOST"; @@ -19,9 +9,4 @@ rules { expression = "WHITELISTED_FWD_HOST"; require_action = ["greylist", "soft reject"]; } - ADD_UNAUTH_SUBJ { - action = "rewrite subject"; - subject = "[Unauth] %s"; - expression = "SPOOFED_SENDER"; - } } diff --git a/data/conf/rspamd/local.d/statistic.conf b/data/conf/rspamd/local.d/statistic.conf index 4b410842..b66f5018 100644 --- a/data/conf/rspamd/local.d/statistic.conf +++ b/data/conf/rspamd/local.d/statistic.conf @@ -7,8 +7,7 @@ classifier "bayes" { servers = "redis:6379"; min_tokens = 11; min_learns = 20; - autolearn = true; - + autolearn = [-20, 50]; per_user = < substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1)); +} +if (!function_exists('getallheaders')) { + function getallheaders() { + if (!is_array($_SERVER)) { + return array(); + } + $headers = array(); + foreach ($_SERVER as $name => $value) { + if (substr($name, 0, 5) == 'HTTP_') { + $headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value; + } + } + return $headers; + } +} + +$raw_data = file_get_contents('php://input'); +$headers = getallheaders(); + +$qid = $headers['X-Rspamd-Qid']; +$score = $headers['X-Rspamd-Score']; +$rcpts = $headers['X-Rspamd-Rcpt']; +$user = $headers['X-Rspamd-User']; +$ip = $headers['X-Rspamd-Ip']; +$action = $headers['X-Rspamd-Action']; +$sender = $headers['X-Rspamd-From']; +$symbols = $headers['X-Rspamd-Symbols']; + +$raw_size = (int)$_SERVER['CONTENT_LENGTH']; + +try { + if ($max_size = $redis->Get('Q_MAX_SIZE')) { + if (!empty($max_size) && ($max_size * 1048576) < $raw_size) { + error_log(sprintf("Message too large: %d exceeds %d", $raw_size, ($max_size * 1048576))); + http_response_code(505); + exit; + } + } + if ($exclude_domains = $redis->Get('Q_EXCLUDE_DOMAINS')) { + $exclude_domains = json_decode($exclude_domains, true); + } + $retention_size = (int)$redis->Get('Q_RETENTION_SIZE'); +} +catch (RedisException $e) { + error_log($e); + http_response_code(504); + exit; +} + +$filtered_rcpts = array(); +foreach (json_decode($rcpts, true) as $rcpt) { + $parsed_mail = parse_email($rcpt); + if (in_array($parsed_mail['domain'], $exclude_domains)) { + error_log(sprintf("Skipped domain %s", $parsed_mail['domain'])); + continue; + } + try { + $stmt = $pdo->prepare("SELECT `goto` FROM `alias` + WHERE + ( + `address` = :rcpt + OR + `address` IN ( + SELECT username FROM mailbox, alias_domain + WHERE (alias_domain.alias_domain = :domain_part + AND mailbox.username = CONCAT(:local_part, '@', alias_domain.target_domain) + AND mailbox.active = '1' + AND alias_domain.active='1') + ) + ) + AND `active`= '1';"); + $stmt->execute(array( + ':rcpt' => $rcpt, + ':local_part' => $parsed_mail['local'], + ':domain_part' => $parsed_mail['domain'] + )); + $gotos = $stmt->fetch(PDO::FETCH_ASSOC)['goto']; + if (!empty($gotos)) { + $filtered_rcpts = array_unique(array_merge($filtered_rcpts, explode(',', $gotos))); + } + } + catch (PDOException $e) { + error_log($e->getMessage()); + http_response_code(502); + exit; + } +} +foreach ($filtered_rcpts as $rcpt) { + + try { + $stmt = $pdo->prepare("INSERT INTO `quarantaine` (`qid`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`) + VALUES (:qid, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action)"); + $stmt->execute(array( + ':qid' => $qid, + ':score' => $score, + ':sender' => $sender, + ':rcpt' => $rcpt, + ':symbols' => $symbols, + ':user' => $user, + ':ip' => $ip, + ':msg' => $raw_data, + ':action' => $action + )); + $stmt = $pdo->prepare('DELETE FROM `quarantaine` WHERE `id` NOT IN ( + SELECT `id` + FROM ( + SELECT `id` + FROM `quarantaine` + WHERE `rcpt` = :rcpt + ORDER BY id DESC + LIMIT :retention_size + ) x + );'); + $stmt->execute(array( + ':rcpt' => $rcpt, + ':retention_size' => $retention_size + )); + } + catch (PDOException $e) { + error_log($e->getMessage()); + http_response_code(503); + exit; + } +} + diff --git a/data/conf/rspamd/meta_exporter/vars.inc.php b/data/conf/rspamd/meta_exporter/vars.inc.php new file mode 100644 index 00000000..d47e9079 --- /dev/null +++ b/data/conf/rspamd/meta_exporter/vars.inc.php @@ -0,0 +1,3 @@ + diff --git a/data/conf/rspamd/override.d/logging.inc b/data/conf/rspamd/override.d/logging.inc index 64a2b7d4..23a9f3cf 100644 --- a/data/conf/rspamd/override.d/logging.inc +++ b/data/conf/rspamd/override.d/logging.inc @@ -1,3 +1,4 @@ type = "console"; systemd = false; .include "$CONFDIR/logging.inc" +.include(try=true; priority=20) "$CONFDIR/override.d/logging.custom.inc" diff --git a/data/conf/rspamd/override.d/ratelimit.conf b/data/conf/rspamd/override.d/ratelimit.conf index 3c0a55c3..f9e359bc 100644 --- a/data/conf/rspamd/override.d/ratelimit.conf +++ b/data/conf/rspamd/override.d/ratelimit.conf @@ -9,6 +9,6 @@ rates { } whitelisted_rcpts = "postmaster,mailer-daemon"; max_rcpt = 5; -custom_keywords = "/etc/rspamd/custom/ratelimit.lua"; +custom_keywords = "/etc/rspamd/lua/ratelimit.lua"; user_keywords = ["user", "customrl"]; dynamic_rates = { customrl = "customrl"} diff --git a/data/conf/rspamd/override.d/worker-controller.inc b/data/conf/rspamd/override.d/worker-controller.inc index ae136461..22d9a024 100644 --- a/data/conf/rspamd/override.d/worker-controller.inc +++ b/data/conf/rspamd/override.d/worker-controller.inc @@ -1,8 +1,9 @@ bind_socket = "*:11334"; -enable_password = "$2$pppq86q9uns51zd5ekfxecj7bxwaefo3$p7f9xdhamydjhtypcr639it3kqeiknx3dk9on7skjypyi8uwwcmy"; secure_ip = "192.168.0.0/16"; secure_ip = "172.16.0.0/12"; secure_ip = "10.0.0.0/8"; secure_ip = "127.0.0.1"; secure_ip = "::1"; secure_ip = "fd4d:6169:6c63:6f77::/64" +.include(try=true; priority=10) "$CONFDIR/override.d/worker-controller-password.inc" +.include(try=true; priority=20) "$CONFDIR/override.d/worker-controller.custom.inc" diff --git a/data/conf/rspamd/override.d/worker-normal.inc b/data/conf/rspamd/override.d/worker-normal.inc index 71569636..a7ab4baf 100644 --- a/data/conf/rspamd/override.d/worker-normal.inc +++ b/data/conf/rspamd/override.d/worker-normal.inc @@ -1,2 +1,3 @@ bind_socket = "*:11333"; task_timeout = 12s; +.include(try=true; priority=20) "$CONFDIR/override.d/worker-normal.custom.inc" diff --git a/data/conf/rspamd/override.d/worker-proxy.inc b/data/conf/rspamd/override.d/worker-proxy.inc index b87c7f29..0df926a7 100644 --- a/data/conf/rspamd/override.d/worker-proxy.inc +++ b/data/conf/rspamd/override.d/worker-proxy.inc @@ -5,3 +5,4 @@ upstream { default = true; hosts = "rspamd:11333" } +.include(try=true; priority=20) "$CONFDIR/override.d/worker-proxy.custom.inc" diff --git a/data/web/admin.php b/data/web/admin.php index 0d7c6466..e1fe74be 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -8,26 +8,8 @@ $tfa_data = get_tfa(); ?>
@@ -58,7 +40,7 @@ $tfa_data = get_tfa();
- +
@@ -96,6 +78,42 @@ $tfa_data = get_tfa();
+ +
+
API
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+
@@ -121,17 +139,15 @@ $tfa_data = get_tfa();
-
-
@@ -332,14 +347,13 @@ $tfa_data = get_tfa();
- +
-
@@ -381,6 +395,43 @@ $tfa_data = get_tfa();
+ +
+
Quarantäne
+
+ +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+ +
+
+
+
@@ -449,10 +500,29 @@ $tfa_data = get_tfa(); endforeach; ?> -
- - -
+

+ + +

+ + + +
+
+ + +
+
+ + +
+
+ + +
+
@@ -460,111 +530,6 @@ $tfa_data = get_tfa(); -
-
-
Postfix -
- - - -
-
-
-
-
-
-
-
-
- -
-
-
Dovecot -
- - - -
-
-
-
-
-
-
-
-
- -
-
-
SOGo -
- - - -
-
-
-
-
-
-
-
-
- - - -
-
-
Fail2ban -
- - - -
-
-
-
-
-
-
-
-
- - -
-
-
Rspamd history -
- - - -
-
-
-
-
-
-
-
-
- -
-
-
Autodiscover -
- - - -
-
-
-
-
-
-
-
-
- li{float:none}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px}.collapse.in{display:block!important}.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}} \ No newline at end of file diff --git a/data/web/css/debug.css b/data/web/css/debug.css new file mode 100644 index 00000000..585d1905 --- /dev/null +++ b/data/web/css/debug.css @@ -0,0 +1,37 @@ +table.footable>tbody>tr.footable-empty>td { + font-size:15px !important; + font-style:italic; +} +.pagination a { + text-decoration: none !important; +} +.panel panel-default { + overflow: visible !important; +} +.table-responsive { + overflow: visible !important; +} +@media screen and (max-width: 767px) { + .table-responsive { + overflow-x: scroll !important; + } +} +.footer-add-item { + display:block; + text-align: center; + font-style: italic; + padding: 10px; + background: #F5F5F5; +} +@media (min-width: 992px) { + .container { + width: 80%; + } +} +.mass-actions-debug { + user-select: none; + padding:10px 0 10px 10px; +} +.inputMissingAttr { + border-color: #FF4136; +} \ No newline at end of file diff --git a/data/web/css/quarantaine.css b/data/web/css/quarantaine.css new file mode 100644 index 00000000..7a5ee761 --- /dev/null +++ b/data/web/css/quarantaine.css @@ -0,0 +1,37 @@ +table.footable>tbody>tr.footable-empty>td { + font-size:15px !important; + font-style:italic; +} +.pagination a { + text-decoration: none !important; +} +.panel panel-default { + overflow: visible !important; +} +.table-responsive { + overflow: visible !important; +} +@media screen and (max-width: 767px) { + .table-responsive { + overflow-x: scroll !important; + } +} +.footer-add-item { + display:block; + text-align: center; + font-style: italic; + padding: 10px; + background: #F5F5F5; +} +@media (min-width: 992px) { + .container { + width: 80%; + } +} +.mass-actions-quarantaine { + user-select: none; + padding:10px 0 10px 10px; +} +.inputMissingAttr { + border-color: #FF4136; +} \ No newline at end of file diff --git a/data/web/debug.php b/data/web/debug.php new file mode 100644 index 00000000..289aa84a --- /dev/null +++ b/data/web/debug.php @@ -0,0 +1,328 @@ + +
+ + + +
+
+
+ +
+
+
+

Rspamd UI

+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ Rspamd UI +
+
+
+
+
+ +
+
+
+

Rspamd settings map

+
+
+ +
+
+
+ +
+
+
+

Container information

+
+
+
    + +
  • + + setTimestamp(mktime( + $StartedAt['hour'], + $StartedAt['minute'], + $StartedAt['second'], + $StartedAt['month'], + $StartedAt['day'], + $StartedAt['year'])); + $user_tz = new DateTimeZone(getenv('TZ')); + $date->setTimezone($user_tz); + $started = $date->format('r'); + ?> + (Started on ), + Restart +     +
  • + +
+
+
+
+ +
+
+
Postfix +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
Dovecot +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
SOGo +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
Fail2ban +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
Rspamd history +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
Autodiscover +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
Watchdog +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
ACME +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
API +
+ + + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+ + + + + diff --git a/data/web/img/rspamd_logo.png b/data/web/img/rspamd_logo.png new file mode 100644 index 00000000..0e97426d Binary files /dev/null and b/data/web/img/rspamd_logo.png differ diff --git a/data/web/inc/ajax/container_ctrl.php b/data/web/inc/ajax/container_ctrl.php new file mode 100644 index 00000000..d12f5767 --- /dev/null +++ b/data/web/inc/ajax/container_ctrl.php @@ -0,0 +1,53 @@ +OK' : 'Error: ' . $response['msg'] . ''; + if ($response['type'] == "success") { + break; + } + usleep(1500000); + $retry++; + } + echo (!isset($last_response)) ? 'Already running' : $last_response; + } + if ($_GET['action'] == "stop") { + header('Content-Type: text/html; charset=utf-8'); + $retry = 0; + while (docker($_GET['service'], 'info')['State']['Running'] == 1 && $retry <= 3) { + $response = docker($_GET['service'], 'post', 'stop'); + $response = json_decode($response, true); + $last_response = ($response['type'] == "success") ? 'OK' : 'Error: ' . $response['msg'] . ''; + if ($response['type'] == "success") { + break; + } + usleep(1500000); + $retry++; + } + echo (!isset($last_response)) ? 'Not running' : $last_response; + } + if ($_GET['action'] == "restart") { + header('Content-Type: text/html; charset=utf-8'); + $response = docker($_GET['service'], 'post', 'restart'); + $response = json_decode($response, true); + $last_response = ($response['type'] == "success") ? 'OK' : 'Error: ' . $response['msg'] . ''; + echo (!isset($last_response)) ? 'Cannot restart container' : $last_response; + } + if ($_GET['action'] == "logs") { + $lines = (empty($_GET['lines']) || !is_numeric($_GET['lines'])) ? 1000 : $_GET['lines']; + header('Content-Type: text/plain; charset=utf-8'); + print_r(preg_split('/\n/', docker($_GET['service'], 'logs', $lines))); + } +} + +?> diff --git a/data/web/inc/ajax/log_driver.php b/data/web/inc/ajax/log_driver.php new file mode 100644 index 00000000..319f672d --- /dev/null +++ b/data/web/inc/ajax/log_driver.php @@ -0,0 +1,12 @@ + diff --git a/data/web/inc/ajax/qitem_details.php b/data/web/inc/ajax/qitem_details.php new file mode 100644 index 00000000..a4b80be1 --- /dev/null +++ b/data/web/inc/ajax/qitem_details.php @@ -0,0 +1,83 @@ + 10485760) { + echo json_encode(array('error' => 'Message size exceeds 10 MiB.')); + exit; + } + if (!empty($mailc['msg'])) { + // Init message array + $data = array(); + // Init parser + $mail_parser = new PhpMimeMailParser\Parser(); + // Load msg to parser + $mail_parser->setText($mailc['msg']); + // Get text/plain content + $data['text_plain'] = $mail_parser->getMessageBody('text'); + // Get subject + $data['subject'] = $mail_parser->getHeader('subject'); + // Get attachments + if (is_dir($tmpdir)) { + rrmdir($tmpdir); + } + mkdir('/tmp/' . $_GET['id']); + $mail_parser->saveAttachments($tmpdir, true); + $atts = $mail_parser->getAttachments(true); + if (count($atts) > 0) { + foreach ($atts as $key => $val) { + $data['attachments'][$key] = array( + // Index + // 0 => file name + // 1 => mime type + // 2 => file size + // 3 => vt link by sha256 + $val->getFilename(), + $val->getContentType(), + filesize($tmpdir . $val->getFilename()), + 'https://www.virustotal.com/file/' . hash_file('SHA256', $tmpdir . $val->getFilename()) . '/analysis/' + ); + } + } + if (isset($_GET['att'])) { + $dl_id = intval($_GET['att']); + $dl_filename = $data['attachments'][$dl_id][0]; + if (!is_dir($tmpdir . $dl_filename) && file_exists($tmpdir . $dl_filename)) { + header('Pragma: public'); + header('Expires: 0'); + header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); + header('Cache-Control: private', false); + header('Content-Type: ' . $data['attachments'][$dl_id][1]); + header('Content-Disposition: attachment; filename="'. $dl_filename . '";'); + header('Content-Transfer-Encoding: binary'); + header('Content-Length: ' . $data['attachments'][$dl_id][2]); + readfile($tmpdir . $dl_filename); + exit; + } + } + echo json_encode($data); + } +} +?> diff --git a/data/web/inc/ajax/sogo_ctrl.php b/data/web/inc/ajax/sogo_ctrl.php deleted file mode 100644 index e238d9c0..00000000 --- a/data/web/inc/ajax/sogo_ctrl.php +++ /dev/null @@ -1,39 +0,0 @@ -OK' : 'Error: ' . $response['msg'] . ''; - if ($response['type'] == "success") { - break; - } - usleep(1500000); - $retry++; - } - echo (!isset($last_response)) ? 'Already running' : $last_response; -} - -if ($_GET['ACTION'] == "stop") { - $retry = 0; - while (docker('sogo-mailcow', 'info')['State']['Running'] == 1 && $retry <= 3) { - $response = docker('sogo-mailcow', 'post', 'stop'); - $response = json_decode($response, true); - $last_response = ($response['type'] == "success") ? 'OK' : 'Error: ' . $response['msg'] . ''; - if ($response['type'] == "success") { - break; - } - usleep(1500000); - $retry++; - } - echo (!isset($last_response)) ? 'Not running' : $last_response; -} - -?> diff --git a/data/web/inc/footer.inc.php b/data/web/inc/footer.inc.php index 3ba758be..a082211b 100644 --- a/data/web/inc/footer.inc.php +++ b/data/web/inc/footer.inc.php @@ -8,6 +8,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/modals/footer.php'; + @@ -26,11 +27,19 @@ $(document).ready(function() { msg = $('').html(message).text(); if (type == 'danger') { auto_hide = 0; + $('#' + localStorage.getItem("add_modal")).modal('show'); + localStorage.removeItem("add_modal"); } else { auto_hide = 5000; } + $.ajax({ + url: '/inc/ajax/log_driver.php', + data: {"type": type,"msg": msg}, + type: "GET" + }); $.notify({message: msg},{z_index: 20000, delay: auto_hide, type: type,placement: {from: "bottom",align: "right"},animate: {enter: 'animated fadeInUp',exit: 'animated fadeOutDown'}}); } + $('[data-cached-form="true"]').formcache({key: $(this).data('id')}); mailcow_alert_box(, ""); @@ -118,13 +127,8 @@ $(document).ready(function() { } }); - // Activate tooltips $(function () { $('[data-toggle="tooltip"]').tooltip() - }) - // Hide alerts after n seconds - $("#alert-fade").fadeTo(7000, 500).slideUp(500, function(){ - $("#alert-fade").alert('close'); }); // Remember last navigation pill @@ -173,36 +177,32 @@ $(document).ready(function() { // Init Bootstrap Selectpicker $('select').selectpicker(); - // Trigger SOGo restart - $('#triggerRestartSogo').click(function(){ - $(this).prop("disabled",true); - $(this).html(' '); - $('#statusTriggerRestartSogo').text('Stopping SOGo workers, this may take a while... '); - $.ajax({ - method: 'get', - url: '/inc/ajax/sogo_ctrl.php', - data: { - 'ajax': true, - 'ACTION': 'stop' - }, - success: function(data) { - $('#statusTriggerRestartSogo').append(data); - $('#statusTriggerRestartSogo').append('
Starting SOGo...'); - $.ajax({ - method: 'get', - url: '/inc/ajax/sogo_ctrl.php', - data: { - 'ajax': true, - 'ACTION': 'start' - }, - success: function(data) { - $('#statusTriggerRestartSogo').append(data); - $('#triggerRestartSogo').html(' '); - } - }); - } + // Trigger container restart + $('#RestartContainer').on('show.bs.modal', function(e) { + var container = $(e.relatedTarget).data('container'); + $('#containerName').text(container); + $('#triggerRestartContainer').click(function(){ + $(this).prop("disabled",true); + $(this).html(' '); + $('#statusTriggerRestartContainer').text('Restarting container, this may take a while... '); + $.ajax({ + method: 'get', + url: '/inc/ajax/container_ctrl.php', + timeout: 3000, + data: { + 'service': container, + 'action': 'restart' + }, + error: function() { + window.location = window.location.href.split("#")[0]; + }, + success: function(data) { + $('#statusTriggerRestartContainer').append(data); + $('#triggerRestartContainer').html(' '); + } + }); }); - }); + }) // CSRF $('').attr('id', 'csrf_token').attr('name', 'csrf_token').appendTo('form'); @@ -216,4 +216,4 @@ $(document).ready(function() { 'danger', - 'msg' => 'Cannot validate image file: Temporary file not found' + 'msg' => $lang['danger']['img_tmp_missing'] ); return false; } @@ -26,7 +26,7 @@ function customize($_action, $_item, $_data = null) { if ($image->valid() !== true) { $_SESSION['return'] = array( 'type' => 'danger', - 'msg' => 'Cannot validate image file' + 'msg' => $lang['danger']['img_invalid'] ); return false; } @@ -35,7 +35,7 @@ function customize($_action, $_item, $_data = null) { catch (ImagickException $e) { $_SESSION['return'] = array( 'type' => 'danger', - 'msg' => 'Cannot validate image file' + 'msg' => $lang['danger']['img_invalid'] ); return false; } @@ -43,7 +43,7 @@ function customize($_action, $_item, $_data = null) { else { $_SESSION['return'] = array( 'type' => 'danger', - 'msg' => 'Invalid mime type' + 'msg' => $lang['danger']['invalid_mime_type'] ); return false; } @@ -59,7 +59,7 @@ function customize($_action, $_item, $_data = null) { } $_SESSION['return'] = array( 'type' => 'success', - 'msg' => 'File uploaded successfully' + 'msg' => $lang['success']['upload_success'] ); break; } @@ -77,7 +77,7 @@ function customize($_action, $_item, $_data = null) { $apps = (array)$_data['app']; $links = (array)$_data['href']; $out = array(); - if (count($apps) == count($links)) {; + if (count($apps) == count($links)) { for ($i = 0; $i < count($apps); $i++) { $out[] = array($apps[$i] => $links[$i]); } @@ -94,7 +94,28 @@ function customize($_action, $_item, $_data = null) { } $_SESSION['return'] = array( 'type' => 'success', - 'msg' => 'Saved changes to app links' + 'msg' => $lang['success']['app_links'] + ); + break; + case 'ui_texts': + $main_name = $_data['main_name']; + $apps_name = $_data['apps_name']; + $help_text = $_data['help_text']; + try { + $redis->set('MAIN_NAME', htmlspecialchars($main_name)); + $redis->set('APPS_NAME', htmlspecialchars($apps_name)); + $redis->set('HELP_TEXT', $help_text); + } + catch (RedisException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Redis: '.$e + ); + return false; + } + $_SESSION['return'] = array( + 'type' => 'success', + 'msg' => $lang['success']['ui_texts'] ); break; } @@ -113,7 +134,7 @@ function customize($_action, $_item, $_data = null) { if ($redis->del('MAIN_LOGO')) { $_SESSION['return'] = array( 'type' => 'success', - 'msg' => 'Reset default logo' + 'msg' => $lang['success']['reset_main_logo'] ); return true; } @@ -155,6 +176,21 @@ function customize($_action, $_item, $_data = null) { return false; } break; + case 'ui_texts': + try { + $data['main_name'] = ($main_name = $redis->get('MAIN_NAME')) ? $main_name : 'mailcow UI'; + $data['apps_name'] = ($apps_name = $redis->get('APPS_NAME')) ? $apps_name : 'mailcow Apps'; + $data['help_text'] = ($help_text = $redis->get('HELP_TEXT')) ? $help_text : false; + return $data; + } + catch (RedisException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Redis: '.$e + ); + return false; + } + break; case 'main_logo_specs': try { $image = new Imagick(); @@ -167,7 +203,7 @@ function customize($_action, $_item, $_data = null) { catch (ImagickException $e) { $_SESSION['return'] = array( 'type' => 'danger', - 'msg' => 'Error: Imagick exception while reading image' + 'msg' => $lang['danger']['imagick_exception'] ); return false; } diff --git a/data/web/inc/functions.docker.inc.php b/data/web/inc/functions.docker.inc.php index a5f2581c..7cd5ed4e 100644 --- a/data/web/inc/functions.docker.inc.php +++ b/data/web/inc/functions.docker.inc.php @@ -1,5 +1,12 @@ 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } $curl = curl_init(); curl_setopt($curl, CURLOPT_HTTPHEADER,array( 'Content-Type: application/json' )); switch($action) { @@ -52,14 +59,44 @@ function docker($service_name, $action, $post_action = null, $post_fields = null return false; } break; + case 'logs': + $container_id = docker($service_name, 'get_id'); + if (ctype_xdigit($container_id)) { + $lines = (empty($attr1) || !is_numeric($attr1)) ? 100 : $attr1; + curl_setopt($curl, CURLOPT_URL, 'http://dockerapi:8080/containers/' . $container_id . '/logs/' . $lines); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); + curl_setopt($curl, CURLOPT_POST, 0); + $response = curl_exec($curl); + if ($response === false) { + $err = curl_error($curl); + curl_close($curl); + return $err; + } + else { + curl_close($curl); + if (empty($response)) { + return true; + } + else { + return json_decode($response, true); + } + } + } + else { + return false; + } + break; case 'post': - if (!empty($post_action)) { + if (!empty($attr1)) { $container_id = docker($service_name, 'get_id'); - if (ctype_xdigit($container_id) && ctype_alnum($post_action)) { - curl_setopt($curl, CURLOPT_URL, 'http://dockerapi:8080/containers/' . $container_id . '/' . $post_action); + if (ctype_xdigit($container_id) && ctype_alnum($attr1)) { + curl_setopt($curl, CURLOPT_URL, 'http://dockerapi:8080/containers/' . $container_id . '/' . $attr1); curl_setopt($curl, CURLOPT_POST, 1); - if (!empty($post_fields)) { - curl_setopt( $curl, CURLOPT_POSTFIELDS, json_encode($post_fields)); + if (!empty($attr2)) { + curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($attr2)); + } + if (!empty($extra_headers) && is_array($extra_headers)) { + curl_setopt($curl, CURLOPT_HTTPHEADER, $extra_headers); } curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1); $response = curl_exec($curl); diff --git a/data/web/inc/functions.fail2ban.inc.php b/data/web/inc/functions.fail2ban.inc.php index 6c9e1692..e1801be7 100644 --- a/data/web/inc/functions.fail2ban.inc.php +++ b/data/web/inc/functions.fail2ban.inc.php @@ -1,5 +1,4 @@ prepare("SELECT IFNULL(GROUP_CONCAT(`address` SEPARATOR ', '), '✘') AS `aliases` FROM `alias` + $stmt = $pdo->prepare("SELECT IFNULL(GROUP_CONCAT(`address` SEPARATOR ', '), '✘') AS `shared_aliases` FROM `alias` WHERE `goto` REGEXP :username_goto AND `address` NOT LIKE '@%' + AND `goto` != :username_goto2 AND `address` != :username_address"); - $stmt->execute(array(':username_goto' => '(^|,)'.$username.'($|,)', ':username_address' => $username)); + $stmt->execute(array( + ':username_goto' => '(^|,)'.$username.'($|,)', + ':username_goto2' => $username, + ':username_address' => $username + )); $run = $stmt->fetchAll(PDO::FETCH_ASSOC); while ($row = array_shift($run)) { - $data['aliases'] = $row['aliases']; + $data['shared_aliases'] = $row['shared_aliases']; + } + $stmt = $pdo->prepare("SELECT IFNULL(GROUP_CONCAT(`address` SEPARATOR ', '), '✘') AS `direct_aliases` FROM `alias` + WHERE `goto` = :username_goto + AND `address` != :username_address"); + $stmt->execute( + array( + ':username_goto' => $username, + ':username_address' => $username + )); + $run = $stmt->fetchAll(PDO::FETCH_ASSOC); + while ($row = array_shift($run)) { + $data['direct_aliases'] = $row['direct_aliases']; } $stmt = $pdo->prepare("SELECT IFNULL(GROUP_CONCAT(local_part, '@', alias_domain SEPARATOR ', '), '✘') AS `ad_alias` FROM `mailbox` LEFT OUTER JOIN `alias_domain` on `target_domain` = `domain` @@ -851,6 +868,135 @@ function verify_tfa_login($username, $token) { } return false; } +function admin_api($action, $data = null) { + global $pdo; + global $lang; + if ($_SESSION['mailcow_cc_role'] != "admin") { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + switch ($action) { + case "edit": + $regen_key = $data['admin_api_regen_key']; + $active = (isset($data['active'])) ? 1 : 0; + $allow_from = array_map('trim', preg_split( "/( |,|;|\n)/", $data['allow_from'])); + foreach ($allow_from as $key => $val) { + if (!filter_var($val, FILTER_VALIDATE_IP)) { + unset($allow_from[$key]); + continue; + } + } + $allow_from = implode(',', array_unique(array_filter($allow_from))); + if (empty($allow_from)) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'List of allowed IPs cannot be empty' + ); + return false; + } + $api_key = implode('-', array( + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))) + )); + $stmt = $pdo->prepare("INSERT INTO `api` (`username`, `api_key`, `active`, `allow_from`) + SELECT `username`, :api_key, :active, :allow_from FROM `admin` WHERE `superadmin`='1' AND `active`='1' + ON DUPLICATE KEY UPDATE `active` = :active_u, `allow_from` = :allow_from_u ;"); + $stmt->execute(array( + ':api_key' => $api_key, + ':active' => $active, + ':active_u' => $active, + ':allow_from' => $allow_from, + ':allow_from_u' => $allow_from + )); + break; + case "regen_key": + $api_key = implode('-', array( + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))), + strtoupper(bin2hex(random_bytes(3))) + )); + $stmt = $pdo->prepare("UPDATE `api` SET `api_key` = :api_key WHERE `username` IN + (SELECT `username` FROM `admin` WHERE `superadmin`='1' AND `active`='1')"); + $stmt->execute(array( + ':api_key' => $api_key + )); + break; + } + $_SESSION['return'] = array( + 'type' => 'success', + 'msg' => sprintf($lang['success']['admin_modified']) + ); +} +function rspamd_ui($action, $data = null) { + global $lang; + if ($_SESSION['mailcow_cc_role'] != "admin") { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + switch ($action) { + case "edit": + $rspamd_ui_pass = $data['rspamd_ui_pass']; + $rspamd_ui_pass2 = $data['rspamd_ui_pass2']; + if (empty($rspamd_ui_pass) || empty($rspamd_ui_pass2)) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Password cannot be empty' + ); + return false; + } + if ($rspamd_ui_pass != $rspamd_ui_pass2) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Passwords do not match' + ); + return false; + } + if (strlen($rspamd_ui_pass) < 6) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Please use at least 6 characters for your password' + ); + return false; + } + $docker_return = docker('rspamd-mailcow', 'post', 'exec', array('cmd' => 'worker_password', 'raw' => $rspamd_ui_pass), array('Content-Type: application/json')); + if ($docker_return_array = json_decode($docker_return, true)) { + if ($docker_return_array['type'] == 'success') { + $_SESSION['return'] = array( + 'type' => 'success', + 'msg' => 'Rspamd UI password set successfully' + ); + return true; + } + else { + $_SESSION['return'] = array( + 'type' => $docker_return_array['type'], + 'msg' => $docker_return_array['msg'] + ); + return false; + } + } + else { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Unknown error' + ); + return false; + } + break; + } + +} function get_admin_details() { // No parameter to be given, only one admin should exist global $pdo; @@ -860,8 +1006,10 @@ function get_admin_details() { return false; } try { - $stmt = $pdo->prepare("SELECT `username`, `modified`, `created` FROM `admin` WHERE `superadmin`='1' AND active='1'"); - $stmt->execute(); + $stmt = $pdo->query("SELECT `admin`.`username`, `api`.`active` AS `api_active`, `api`.`api_key`, `api`.`allow_from` FROM `admin` + INNER JOIN `api` ON `admin`.`username` = `api`.`username` + WHERE `admin`.`superadmin`='1' + AND `admin`.`active`='1'"); $data = $stmt->fetch(PDO::FETCH_ASSOC); } catch(PDOException $e) { @@ -932,6 +1080,51 @@ function get_logs($container, $lines = false) { return $data_array; } } + if ($container == "watchdog-mailcow") { + if (!is_numeric($lines)) { + list ($from, $to) = explode('-', $lines); + $data = $redis->lRange('WATCHDOG_LOG', intval($from), intval($to)); + } + else { + $data = $redis->lRange('WATCHDOG_LOG', 0, intval($lines)); + } + if ($data) { + foreach ($data as $json_line) { + $data_array[] = json_decode($json_line, true); + } + return $data_array; + } + } + if ($container == "acme-mailcow") { + if (!is_numeric($lines)) { + list ($from, $to) = explode('-', $lines); + $data = $redis->lRange('ACME_LOG', intval($from), intval($to)); + } + else { + $data = $redis->lRange('ACME_LOG', 0, intval($lines)); + } + if ($data) { + foreach ($data as $json_line) { + $data_array[] = json_decode($json_line, true); + } + return $data_array; + } + } + if ($container == "api-mailcow") { + if (!is_numeric($lines)) { + list ($from, $to) = explode('-', $lines); + $data = $redis->lRange('API_LOG', intval($from), intval($to)); + } + else { + $data = $redis->lRange('API_LOG', 0, intval($lines)); + } + if ($data) { + foreach ($data as $json_line) { + $data_array[] = json_decode($json_line, true); + } + return $data_array; + } + } if ($container == "fail2ban-mailcow") { if (!is_numeric($lines)) { list ($from, $to) = explode('-', $lines); diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index d1410f97..054a9499 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -490,9 +490,20 @@ function mailbox($_action, $_type, $_data = null, $attr = null) { if (in_array($address, $gotos)) { continue; } + $domain = idn_to_ascii(substr(strstr($address, '@'), 1)); + $local_part = strstr($address, '@', true); + $address = $local_part.'@'.$domain; $stmt = $pdo->prepare("SELECT `address` FROM `alias` - WHERE `address`= :address"); - $stmt->execute(array(':address' => $address)); + WHERE `address`= :address OR `address` IN ( + SELECT `username` FROM `mailbox`, `alias_domain` + WHERE ( + `alias_domain`.`alias_domain` = :address_d + AND `mailbox`.`username` = CONCAT(:address_l, '@', alias_domain.target_domain)))"); + $stmt->execute(array( + ':address' => $address, + ':address_l' => $local_part, + ':address_d' => $domain + )); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); if ($num_results != 0) { $_SESSION['return'] = array( @@ -501,9 +512,6 @@ function mailbox($_action, $_type, $_data = null, $attr = null) { ); return false; } - $domain = idn_to_ascii(substr(strstr($address, '@'), 1)); - $local_part = strstr($address, '@', true); - $address = $local_part.'@'.$domain; $domaindata = mailbox('get', 'domain_details', $domain); if (is_array($domaindata) && $domaindata['aliases_left'] == "0") { $_SESSION['return'] = array( @@ -722,7 +730,7 @@ function mailbox($_action, $_type, $_data = null, $attr = null) { } $active = intval($_data['active']); $quota_b = ($quota_m * 1048576); - $maildir = $domain . "/" . $local_part . "/mail-" . time() . "/"; + $maildir = $domain . "/" . $local_part . "/mails/"; if (!is_valid_domain_name($domain)) { $_SESSION['return'] = array( 'type' => 'danger', @@ -2302,7 +2310,7 @@ function mailbox($_action, $_type, $_data = null, $attr = null) { } else { try { - $stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `kind` NOT REGEXP 'location|thing|group' AND `domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :username) OR 'admin' = :role"); + $stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `kind` NOT REGEXP 'location|thing|group' AND (`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :username) OR 'admin' = :role)"); $stmt->execute(array( ':username' => $_SESSION['mailcow_cc_username'], ':role' => $_SESSION['mailcow_cc_role'], @@ -3360,7 +3368,7 @@ function mailbox($_action, $_type, $_data = null, $attr = null) { )); $stmt = $pdo->prepare("DELETE FROM `bcc_maps` WHERE `local_dest` = :domain"); $stmt->execute(array( - ':domain' => '%@'.$domain, + ':domain' => $domain, )); } catch (PDOException $e) { @@ -3484,7 +3492,7 @@ function mailbox($_action, $_type, $_data = null, $attr = null) { )); $stmt = $pdo->prepare("DELETE FROM `bcc_maps` WHERE `local_dest` = :alias_domain"); $stmt->execute(array( - ':domain' => '%@'.$alias_domain, + ':domain' => $alias_domain, )); } catch (PDOException $e) { diff --git a/data/web/inc/functions.quarantaine.inc.php b/data/web/inc/functions.quarantaine.inc.php new file mode 100644 index 00000000..4b9e6b00 --- /dev/null +++ b/data/web/inc/functions.quarantaine.inc.php @@ -0,0 +1,282 @@ + 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + foreach ($ids as $id) { + if (!is_numeric($id)) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + try { + $stmt = $pdo->prepare('SELECT `rcpt` FROM `quarantaine` WHERE `id` = :id'); + $stmt->execute(array(':id' => $id)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $row['rcpt'])) { + try { + $stmt = $pdo->prepare("DELETE FROM `quarantaine` WHERE `id` = :id"); + $stmt->execute(array( + ':id' => $id + )); + } + catch (PDOException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'MySQL: '.$e + ); + return false; + } + } + else { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + } + catch(PDOException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'MySQL: '.$e + ); + } + } + $_SESSION['return'] = array( + 'type' => 'success', + 'msg' => sprintf($lang['success']['items_deleted'], implode(', ', $ids)) + ); + break; + case 'edit': + if (!isset($_SESSION['acl']['quarantaine']) || $_SESSION['acl']['quarantaine'] != "1" ) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + // Edit settings + if ($_data['action'] == 'settings') { + if ($_SESSION['mailcow_cc_role'] != "admin") { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + $retention_size = $_data['retention_size']; + $max_size = $_data['max_size']; + $exclude_domains = (array)$_data['exclude_domains']; + try { + $redis->Set('Q_RETENTION_SIZE', intval($retention_size)); + $redis->Set('Q_MAX_SIZE', intval($max_size)); + $redis->Set('Q_EXCLUDE_DOMAINS', json_encode($exclude_domains)); + } + catch (RedisException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Redis: '.$e + ); + return false; + } + $_SESSION['return'] = array( + 'type' => 'success', + 'msg' => 'Saved settings' + ); + } + // Release item + elseif ($_data['action'] == 'release') { + if (!is_array($_data['id'])) { + $ids = array(); + $ids[] = $_data['id']; + } + else { + $ids = $_data['id']; + } + foreach ($ids as $id) { + if (!is_numeric($id)) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + try { + $stmt = $pdo->prepare('SELECT `msg`, `qid`, `sender`, `rcpt` FROM `quarantaine` WHERE `id` = :id'); + $stmt->execute(array(':id' => $id)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $row['rcpt'])) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + } + catch(PDOException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'MySQL: '.$e + ); + } + $sender = (isset($row['sender'])) ? $row['sender'] : 'sender-unknown@rspamd'; + try { + $mail = new PHPMailer(true); + $mail->isSMTP(); + $mail->SMTPDebug = 0; + $mail->SMTPOptions = array( + 'ssl' => array( + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true + ) + ); + if (!empty(gethostbynamel('postfix-mailcow'))) { + $postfix = 'apostfix-mailcow'; + } + if (!empty(gethostbynamel('postfix'))) { + $postfix = 'postfix'; + } + else { + $_SESSION['return'] = array( + 'type' => 'warning', + 'msg' => sprintf($lang['danger']['release_send_failed'], 'Cannot determine Postfix host') + ); + return false; + } + $mail->Host = $postfix; + $mail->Port = 590; + $mail->setFrom($sender); + $mail->CharSet = 'UTF-8'; + $mail->Subject = sprintf($lang['quarantaine']['release_subject'], $row['qid']); + $mail->addAddress($row['rcpt']); + $mail->IsHTML(false); + $msg_tmpf = tempnam("/tmp", $row['qid']); + file_put_contents($msg_tmpf, $row['msg']); + $mail->addAttachment($msg_tmpf, $row['qid'] . '.eml'); + $mail->Body = sprintf($lang['quarantaine']['release_body']); + $mail->send(); + unlink($msg_tmpf); + } + catch (phpmailerException $e) { + unlink($msg_tmpf); + $_SESSION['return'] = array( + 'type' => 'warning', + 'msg' => sprintf($lang['danger']['release_send_failed'], $e->errorMessage()) + ); + return false; + } + try { + $stmt = $pdo->prepare("DELETE FROM `quarantaine` WHERE `id` = :id"); + $stmt->execute(array( + ':id' => $id + )); + } + catch (PDOException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'MySQL: '.$e + ); + return false; + } + } + $_SESSION['return'] = array( + 'type' => 'success', + 'msg' => $lang['success']['items_released'] + ); + } + return true; + break; + case 'get': + try { + if ($_SESSION['mailcow_cc_role'] == "user") { + $stmt = $pdo->prepare('SELECT `id`, `qid`, `rcpt`, `sender`, UNIX_TIMESTAMP(`created`) AS `created` FROM `quarantaine` WHERE `rcpt` = :mbox'); + $stmt->execute(array(':mbox' => $_SESSION['mailcow_cc_username'])); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + while($row = array_shift($rows)) { + $q_meta[] = $row; + } + } + else { + foreach (mailbox('get', 'mailboxes') as $mbox) { + $stmt = $pdo->prepare('SELECT `id`, `qid`, `rcpt`, `sender`, UNIX_TIMESTAMP(`created`) AS `created` FROM `quarantaine` WHERE `rcpt` = :mbox'); + $stmt->execute(array(':mbox' => $mbox)); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + while($row = array_shift($rows)) { + $q_meta[] = $row; + } + } + } + } + catch(PDOException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'MySQL: '.$e + ); + } + return $q_meta; + break; + case 'settings': + if ($_SESSION['mailcow_cc_role'] != "admin") { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + try { + $settings['exclude_domains'] = json_decode($redis->Get('Q_EXCLUDE_DOMAINS'), true); + $settings['max_size'] = $redis->Get('Q_MAX_SIZE'); + $settings['retention_size'] = $redis->Get('Q_RETENTION_SIZE'); + } + catch (RedisException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Redis: '.$e + ); + return false; + } + return $settings; + break; + case 'details': + if (!is_numeric($_data) || empty($_data)) { + return false; + } + try { + $stmt = $pdo->prepare('SELECT `rcpt`, `symbols`, `msg`, `domain` FROM `quarantaine` WHERE `id`= :id'); + $stmt->execute(array(':id' => $_data)); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $row['rcpt'])) { + return $row; + } + return false; + } + catch(PDOException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'MySQL: '.$e + ); + } + return false; + break; + } +} \ No newline at end of file diff --git a/data/web/inc/header.inc.php b/data/web/inc/header.inc.php index cedd07ca..c8dbe4b1 100644 --- a/data/web/inc/header.inc.php +++ b/data/web/inc/header.inc.php @@ -15,6 +15,7 @@ + @@ -27,6 +28,8 @@ ' : null; ?> ' : null; ?> ' : null; ?> +' : null; ?> +' : null; ?> @@ -35,7 +38,6 @@
diff --git a/data/web/modals/admin.php b/data/web/modals/admin.php index bf17296c..3a387540 100644 --- a/data/web/modals/admin.php +++ b/data/web/modals/admin.php @@ -13,7 +13,7 @@ if (!isset($_SESSION['mailcow_cc_role'])) {