1
0
mirror of https://github.com/mailcow/mailcow-dockerized.git synced 2026-06-14 18:40:23 +00:00

Compare commits

..

5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] 5d371293e0 Restore informative messages about manual Docker daemon configuration
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-10 08:32:50 +00:00
copilot-swe-agent[bot] 215a8addff Add clarifying comment about MAILCOW_CONF usage in configure_ipv6
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-10 08:24:45 +00:00
copilot-swe-agent[bot] 8f58ba8bc8 Fix generate_config.sh termination when user declines IPv6 Docker configuration
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-10 08:22:23 +00:00
copilot-swe-agent[bot] 70affa0f69 Initial plan 2025-12-10 08:18:16 +00:00
DerLinkman 4ef65fc382 Merge pull request #6948 from mailcow/staging
2025-12
2025-12-09 13:29:15 +01:00
502 changed files with 22099 additions and 25454 deletions
@@ -14,7 +14,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Mark/Close Stale Issues and Pull Requests 🗑️ - name: Mark/Close Stale Issues and Pull Requests 🗑️
uses: actions/stale@v10.2.0 uses: actions/stale@v10.1.1
with: with:
repo-token: ${{ secrets.STALE_ACTION_PAT }} repo-token: ${{ secrets.STALE_ACTION_PAT }}
days-before-stale: 60 days-before-stale: 60
+4 -4
View File
@@ -16,21 +16,21 @@ jobs:
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v3
- name: Login to GHCR - name: Login to GHCR
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v4 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v7 uses: docker/build-push-action@v6
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
@@ -22,7 +22,7 @@ jobs:
bash helper-scripts/update_postscreen_whitelist.sh bash helper-scripts/update_postscreen_whitelist.sh
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v8 uses: peter-evans/create-pull-request@v7
with: with:
token: ${{ secrets.mailcow_action_Update_postscreen_access_cidr_pat }} token: ${{ secrets.mailcow_action_Update_postscreen_access_cidr_pat }}
commit-message: update postscreen_access.cidr commit-message: update postscreen_access.cidr
-1
View File
@@ -51,7 +51,6 @@ data/conf/sogo/cron.creds
data/conf/sogo/custom-fulllogo.svg data/conf/sogo/custom-fulllogo.svg
data/conf/sogo/custom-shortlogo.svg data/conf/sogo/custom-shortlogo.svg
data/conf/sogo/custom-fulllogo.png data/conf/sogo/custom-fulllogo.png
data/conf/acme/dns-01.conf
data/gitea/ data/gitea/
data/gogs/ data/gogs/
data/hooks/dovecot/* data/hooks/dovecot/*
+39 -6
View File
@@ -139,9 +139,13 @@ docker_daemon_edit(){
exit 1 exit 1
fi fi
else else
echo -e "${YELLOW}User declined Docker update please insert these changes manually:${NC}" echo -e "${YELLOW}User declined Docker update skipping Docker daemon configuration.${NC}"
echo -e "${YELLOW}IPv6 will be disabled for mailcow.${NC}"
echo ""
echo -e "${YELLOW}If you change your mind later, please insert these changes manually to $DOCKER_DAEMON_CONFIG:${NC}"
echo "${MISSING[*]}" echo "${MISSING[*]}"
exit 1 echo ""
return 1
fi fi
fi fi
@@ -185,9 +189,23 @@ EOF
(command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart (command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart
echo "Docker restarted." echo "Docker restarted."
else else
echo "User declined to create daemon.json please manually merge the docker daemon with these configs:" echo -e "${YELLOW}User declined to create daemon.json skipping Docker daemon configuration.${NC}"
echo "${MISSING[*]}" echo -e "${YELLOW}IPv6 will be disabled for mailcow.${NC}"
exit 1 echo ""
echo -e "${YELLOW}If you change your mind later, please create $DOCKER_DAEMON_CONFIG with these settings:${NC}"
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
echo ' "ipv6": true,'
echo ' "fixed-cidr-v6": "fd00:dead:beef:c0::/80",'
echo ' "ip6tables": true,'
echo ' "experimental": true'
elif [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
echo ' "ipv6": true,'
echo ' "fixed-cidr-v6": "fd00:dead:beef:c0::/80"'
else
echo ' "ipv6": true'
fi
echo ""
return 1
fi fi
fi fi
} }
@@ -223,7 +241,22 @@ configure_ipv6() {
return return
fi fi
docker_daemon_edit if ! docker_daemon_edit; then
# User declined Docker daemon configuration
# When called from update.sh, MAILCOW_CONF is set and we modify the existing file
# When called from generate_config.sh, MAILCOW_CONF is not set and we export IPV6_BOOL
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=false
fi
echo "IPv6 configuration complete: ENABLE_IPV6=false"
return 0
fi
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
+1 -18
View File
@@ -57,14 +57,11 @@ adapt_new_options() {
"DISABLE_NETFILTER_ISOLATION_RULE" "DISABLE_NETFILTER_ISOLATION_RULE"
"HTTP_REDIRECT" "HTTP_REDIRECT"
"ENABLE_IPV6" "ENABLE_IPV6"
"ACME_DNS_CHALLENGE"
"ACME_DNS_PROVIDER"
"ACME_ACCOUNT_EMAIL"
) )
sed -i --follow-symlinks '$a\' mailcow.conf sed -i --follow-symlinks '$a\' mailcow.conf
for option in ${CONFIG_ARRAY[@]}; do for option in ${CONFIG_ARRAY[@]}; do
if grep -q "^#\?${option}=" mailcow.conf; then if grep -q "${option}" mailcow.conf; then
continue continue
fi fi
@@ -295,20 +292,6 @@ adapt_new_options() {
echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
;; ;;
ACME_DNS_CHALLENGE)
echo '# Enable DNS-01 challenge for ACME (acme-mailcow) - y/n' >> mailcow.conf
echo '# This requires you to set ACME_DNS_PROVIDER and ACME_ACCOUNT_EMAIL below' >> mailcow.conf
echo 'ACME_DNS_CHALLENGE=n' >> mailcow.conf
;;
ACME_DNS_PROVIDER)
echo '# DNS provider for DNS-01 challenge (e.g. dns_cf, dns_azure, dns_gd, etc.)' >> mailcow.conf
echo '# See the dns-01 provider documentation for more information.' >> mailcow.conf
echo 'ACME_DNS_PROVIDER=dns_xxx' >> mailcow.conf
;;
ACME_ACCOUNT_EMAIL)
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
;;
*) *)
echo "${option}=" >> mailcow.conf echo "${option}=" >> mailcow.conf
;; ;;
+3 -28
View File
@@ -1,8 +1,4 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00 FROM alpine:3.21
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM alpine:3.23
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -18,35 +14,14 @@ RUN apk upgrade --no-cache \
tini \ tini \
tzdata \ tzdata \
python3 \ python3 \
acme-tiny \ acme-tiny
git \
socat \
&& git clone --depth 1 https://github.com/acmesh-official/acme.sh.git /opt/acme.sh \
&& chmod +x /opt/acme.sh/acme.sh \
&& mkdir -p /var/lib/acme/acme-sh
ENV ACME_SH_BIN=/opt/acme.sh/acme.sh \
ACME_SH_HOME=/opt/acme.sh \
ACME_SH_CONFIG_HOME=/var/lib/acme/acme-sh
COPY acme.sh /srv/acme.sh COPY acme.sh /srv/acme.sh
COPY functions.sh /srv/functions.sh COPY functions.sh /srv/functions.sh
COPY obtain-certificate.sh /srv/obtain-certificate.sh COPY obtain-certificate.sh /srv/obtain-certificate.sh
COPY obtain-certificate-dns.sh /srv/obtain-certificate-dns.sh
COPY load-dns-config.sh /srv/load-dns-config.sh
COPY reload-configurations.sh /srv/reload-configurations.sh COPY reload-configurations.sh /srv/reload-configurations.sh
COPY expand6.sh /srv/expand6.sh COPY expand6.sh /srv/expand6.sh
RUN chmod +x /srv/*.sh RUN chmod +x /srv/*.sh
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent CMD ["/sbin/tini", "-g", "--", "/srv/acme.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=acme \
MAILCOW_AGENT_MAIN_CMD="/sbin/tini -g -- /srv/acme.sh"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+6 -71
View File
@@ -14,17 +14,6 @@ until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
sleep 2 sleep 2
done done
# Create DNS-01 configuration template if it doesn't exist
if [[ ! -f /etc/acme/dns-01.conf ]]; then
mkdir -p /etc/acme
cat > /etc/acme/dns-01.conf <<'EOF'
# Add here your DNS-01 challenge configuration
# For more information, visit the acme.sh documentation:
# https://github.com/acmesh-official/acme.sh/wiki/dnsapi
EOF
echo "Created DNS-01 configuration template at /etc/acme/dns-01.conf"
fi
source /srv/functions.sh source /srv/functions.sh
# Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6 # Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6
source /srv/expand6.sh source /srv/expand6.sh
@@ -53,21 +42,17 @@ if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
ENABLE_SSL_SNI=y ENABLE_SSL_SNI=y
fi fi
if [[ "${ACME_DNS_CHALLENGE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
ACME_DNS_CHALLENGE=y
fi
if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..." log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..."
sleep 365d sleep 365d
exec $(readlink -f "$0") exec $(readlink -f "$0")
fi fi
log_f "Waiting for Redis control bus..." log_f "Waiting for Docker API..."
until redis-cli -h "${REDIS_SLAVEOF_IP:-redis-mailcow}" -p "${REDIS_SLAVEOF_PORT:-6379}" -a "${REDISPASS}" --no-auth-warning ping > /dev/null 2>&1; do until ping dockerapi -c1 > /dev/null; do
sleep 1 sleep 1
done done
log_f "Redis control bus OK" log_f "Docker API OK"
log_f "Waiting for Postfix..." log_f "Waiting for Postfix..."
until ping postfix -c1 > /dev/null; do until ping postfix -c1 > /dev/null; do
@@ -253,46 +238,14 @@ while true; do
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}" if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
# Skip if subdomain matches MAILCOW_HOSTNAME VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}")
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
continue
fi fi
# Skip if subdomain is covered by a wildcard in ADDITIONAL_SAN
if is_covered_by_wildcard "${FULL_SUBDOMAIN}"; then
log_f "Subdomain '${FULL_SUBDOMAIN}' is covered by wildcard - skipping explicit subdomain"
continue
fi
# Validate and add subdomain
if check_domain "${FULL_SUBDOMAIN}"; then
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${FULL_SUBDOMAIN}")
fi fi
done done
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}") VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
done done
# Fetch alias domains where target domain has MTA-STS enabled
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
SQL_ALIAS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT ad.alias_domain FROM alias_domain ad INNER JOIN mta_sts m ON ad.target_domain = m.domain WHERE ad.active = 1 AND m.active = 1" -Bs)
if [[ $? -eq 0 ]]; then
while read alias_domain; do
if [[ -z "${alias_domain}" ]]; then
# ignore empty lines
continue
fi
# Only add mta-sts subdomain for alias domains
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
# Skip if mta-sts subdomain is covered by a wildcard
if is_covered_by_wildcard "mta-sts.${alias_domain}"; then
log_f "Alias domain mta-sts subdomain 'mta-sts.${alias_domain}' is covered by wildcard - skipping"
elif check_domain "mta-sts.${alias_domain}"; then
VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}")
fi
fi
done <<< "${SQL_ALIAS_DOMAINS}"
fi
fi
fi fi
if check_domain ${MAILCOW_HOSTNAME}; then if check_domain ${MAILCOW_HOSTNAME}; then
@@ -321,32 +274,14 @@ while true; do
done done
fi fi
# Check if MAILCOW_HOSTNAME is covered by a wildcard in ADDITIONAL_SAN
MAILCOW_HOSTNAME_COVERED=0
if [[ ! -z ${VALIDATED_MAILCOW_HOSTNAME} ]]; then
if is_covered_by_wildcard "${VALIDATED_MAILCOW_HOSTNAME}"; then
MAILCOW_PARENT_DOMAIN=$(echo ${VALIDATED_MAILCOW_HOSTNAME} | cut -d. -f2-)
log_f "MAILCOW_HOSTNAME '${VALIDATED_MAILCOW_HOSTNAME}' is covered by wildcard '*.${MAILCOW_PARENT_DOMAIN}' - skipping explicit hostname"
MAILCOW_HOSTNAME_COVERED=1
fi
fi
# Unique domains for server certificate # Unique domains for server certificate
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
# create certificate for server name and fqdn SANs only # create certificate for server name and fqdn SANs only
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
SERVER_SAN_VALIDATED=($(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
else
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs)) SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
fi
else else
# create certificate for all domains, including all subdomains from other domains [*] # create certificate for all domains, including all subdomains from other domains [*]
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
SERVER_SAN_VALIDATED=($(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
else
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs)) SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
fi fi
fi
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
CERT_NAME=${SERVER_SAN_VALIDATED[0]} CERT_NAME=${SERVER_SAN_VALIDATED[0]}
VALIDATED_CERTIFICATES+=("${CERT_NAME}") VALIDATED_CERTIFICATES+=("${CERT_NAME}")
-34
View File
@@ -80,11 +80,6 @@ check_domain(){
return 1 return 1
fi fi
fi fi
if [[ ${ACME_DNS_CHALLENGE} == "y" ]]; then
log_f "ACME_DNS_CHALLENGE=y - skipping IP and HTTP validation for ${DOMAIN}"
return 0
fi
# Check if CNAME without v6 enabled target # Check if CNAME without v6 enabled target
if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then
AAAA_DOMAIN= AAAA_DOMAIN=
@@ -135,32 +130,3 @@ verify_challenge_path(){
return 1 return 1
fi fi
} }
# Check if a domain is covered by a wildcard (*.example.com) in ADDITIONAL_SAN
# Usage: is_covered_by_wildcard "subdomain.example.com"
# Returns: 0 if covered, 1 if not covered
# Note: Only returns 0 (covered) when DNS-01 challenge is enabled,
# as wildcards cannot be validated with HTTP-01 challenge
is_covered_by_wildcard() {
local DOMAIN=$1
# Only skip if DNS challenge is enabled (wildcards require DNS-01)
if [[ ${ACME_DNS_CHALLENGE} != "y" ]]; then
return 1
fi
# Return early if no ADDITIONAL_SAN is set
if [[ -z ${ADDITIONAL_SAN} ]]; then
return 1
fi
# Extract parent domain (e.g., mail.example.com -> example.com)
local PARENT_DOMAIN=$(echo ${DOMAIN} | cut -d. -f2-)
# Check if ADDITIONAL_SAN contains a wildcard for this parent domain
if [[ "${ADDITIONAL_SAN}" == *"*.${PARENT_DOMAIN}"* ]]; then
return 0 # Covered by wildcard
fi
return 1 # Not covered
}
-57
View File
@@ -1,57 +0,0 @@
#!/bin/bash
SCRIPT_SOURCE="${BASH_SOURCE[0]:-${0}}"
if [[ "${SCRIPT_SOURCE}" == "${0}" ]]; then
__dns_loader_standalone=1
else
__dns_loader_standalone=0
fi
CONFIG_PATH="${ACME_DNS_CONFIG_FILE:-/etc/acme/dns-01.conf}"
if [[ ! -f "${CONFIG_PATH}" ]]; then
if [[ $__dns_loader_standalone -eq 1 ]]; then
exit 0
else
return 0
fi
fi
source /srv/functions.sh
log_f "Loading DNS-01 configuration from ${CONFIG_PATH}"
LINE_NO=0
while IFS= read -r line || [[ -n "${line}" ]]; do
LINE_NO=$((LINE_NO+1))
line="${line%$'\r'}"
line_trimmed="$(printf '%s' "${line}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
[[ -z "${line_trimmed}" ]] && continue
[[ "${line_trimmed:0:1}" == "#" ]] && continue
if [[ "${line_trimmed}" != *=* ]]; then
log_f "Skipping invalid DNS config line ${LINE_NO} (missing key=value)"
continue
fi
KEY="${line_trimmed%%=*}"
VALUE="${line_trimmed#*=}"
KEY="$(printf '%s' "${KEY}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
VALUE="$(printf '%s' "${VALUE}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
if [[ -z "${KEY}" ]]; then
log_f "Skipping invalid DNS config line ${LINE_NO} (empty key)"
continue
fi
if [[ "${VALUE}" =~ ^\".*\"$ ]]; then
VALUE="${VALUE:1:-1}"
elif [[ "${VALUE}" =~ ^\'.*\'$ ]]; then
VALUE="${VALUE:1:-1}"
fi
export "${KEY}"="${VALUE}"
log_f "Exported DNS config key ${KEY}"
done < "${CONFIG_PATH}"
if [[ $__dns_loader_standalone -eq 1 ]]; then
exit 0
else
return 0
fi
@@ -1,177 +0,0 @@
#!/bin/bash
# Return values / exit codes
# 0 = cert created successfully
# 1 = cert renewed successfully
# 2 = cert not due for renewal
# * = errors
source /srv/functions.sh
CERT_DOMAINS=(${DOMAINS[@]})
CERT_DOMAIN=${CERT_DOMAINS[0]}
ACME_BASE=/var/lib/acme
# Load optional DNS provider secrets from /etc/acme/dns-01.conf
if [[ -f /srv/load-dns-config.sh ]]; then
source /srv/load-dns-config.sh
if declare -F log_f >/dev/null; then
log_f "ACME_DNS_CHALLENGE is enabled, DNS provider secrets loaded"
fi
fi
TYPE=${1}
PREFIX=""
# only support rsa certificates for now
if [[ "${TYPE}" != "rsa" ]]; then
log_f "Unknown certificate type '${TYPE}' requested"
exit 5
fi
if [[ -z "${ACME_DNS_PROVIDER}" ]]; then
log_f "ACME_DNS_PROVIDER is required when ACME_DNS_CHALLENGE is enabled"
exit 6
fi
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
KEY=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}key.pem
CSR=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}acme.csr
if [[ -z ${CERT_DOMAINS[*]} ]]; then
log_f "Missing CERT_DOMAINS to obtain a certificate"
exit 3
fi
if [[ "${LE_STAGING}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
if [[ ! -z "${DIRECTORY_URL}" ]]; then
log_f "Cannot use DIRECTORY_URL with LE_STAGING=y - ignoring DIRECTORY_URL"
fi
log_f "Using Let's Encrypt staging servers"
ACME_SH_SERVER_ARGS=("--staging")
elif [[ ! -z "${DIRECTORY_URL}" ]]; then
log_f "Using custom directory URL ${DIRECTORY_URL}"
ACME_SH_SERVER_ARGS=("--server" "${DIRECTORY_URL}")
else
log_f "Using Let's Encrypt production servers"
ACME_SH_SERVER_ARGS=("--server" "letsencrypt")
fi
if [[ -f ${DOMAINS_FILE} && "$(cat ${DOMAINS_FILE})" == "${CERT_DOMAINS[*]}" ]]; then
if [[ ! -f ${CERT} || ! -f "${KEY}" || -f "${ACME_BASE}/force_renew" ]]; then
log_f "Certificate ${CERT} doesn't exist yet or forced renewal - start obtaining"
elif ! openssl x509 -checkend 2592000 -noout -in ${CERT} > /dev/null; then
log_f "Certificate ${CERT} is due for renewal (< 30 days) - start renewing"
else
log_f "Certificate ${CERT} validation done, neither changed nor due for renewal."
exit 2
fi
else
log_f "Certificate ${CERT} missing or changed domains '${CERT_DOMAINS[*]}' - start obtaining"
fi
# Make backup
if [[ -f ${CERT} ]]; then
DATE=$(date +%Y-%m-%d_%H_%M_%S)
BACKUP_DIR=${ACME_BASE}/backups/${CERT_DOMAIN}/${PREFIX}${DATE}
log_f "Creating backups in ${BACKUP_DIR} ..."
mkdir -p ${BACKUP_DIR}/
[[ -f ${DOMAINS_FILE} ]] && cp ${DOMAINS_FILE} ${BACKUP_DIR}/
[[ -f ${CERT} ]] && cp ${CERT} ${BACKUP_DIR}/
[[ -f ${KEY} ]] && cp ${KEY} ${BACKUP_DIR}/
[[ -f ${CSR} ]] && cp ${CSR} ${BACKUP_DIR}/
fi
mkdir -p ${ACME_BASE}/${CERT_DOMAIN}
if [[ ! -f ${KEY} ]]; then
log_f "Copying shared private key for this certificate..."
cp ${SHARED_KEY} ${KEY}
chmod 600 ${KEY}
fi
# Generating CSR to keep layout parity with HTTP challenge flow
printf "[SAN]\nsubjectAltName=" > /tmp/_SAN
printf "DNS:%s," "${CERT_DOMAINS[@]}" >> /tmp/_SAN
sed -i '$s/,$//' /tmp/_SAN
openssl req -new -sha256 -key ${KEY} -subj "/" -reqexts SAN -config <(cat "$(openssl version -d | sed 's/.*\"\(.*\)\"/\1/g')/openssl.cnf" /tmp/_SAN) > ${CSR}
log_f "Checking resolver..."
until dig letsencrypt.org +time=3 +tries=1 @unbound > /dev/null; do
sleep 2
done
log_f "Resolver OK"
ACME_SH_BIN_PATH=${ACME_SH_BIN:-/opt/acme.sh/acme.sh}
ACME_SH_WORK_HOME=${ACME_SH_CONFIG_HOME:-/var/lib/acme/acme-sh}
mkdir -p ${ACME_SH_WORK_HOME}
if [[ ! -x ${ACME_SH_BIN_PATH} ]]; then
log_f "acme.sh binary not found at ${ACME_SH_BIN_PATH}"
exit 7
fi
if [[ ! -f ${ACME_SH_WORK_HOME}/account.conf ]]; then
if [[ -z "${ACME_ACCOUNT_EMAIL}" ]]; then
log_f "ACME_ACCOUNT_EMAIL is required to register a new acme.sh account"
exit 8
fi
log_f "Registering acme.sh account for ${ACME_ACCOUNT_EMAIL}"
REGISTER_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}" "--register-account" "-m" "${ACME_ACCOUNT_EMAIL}")
REGISTER_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
REGISTER_RESPONSE=$("${REGISTER_CMD[@]}" 2>&1)
if [[ $? -ne 0 ]]; then
log_f "Failed to register acme.sh account: ${REGISTER_RESPONSE}"
exit 9
fi
fi
TMP_CERT=$(mktemp /tmp/acme-cert.XXXXXX)
TMP_FULLCHAIN=$(mktemp /tmp/acme-fullchain.XXXXXX)
ACME_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}")
ACME_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
ACME_CMD+=("--issue" "--dns" "${ACME_DNS_PROVIDER}" "--key-file" "${KEY}" "--cert-file" "${TMP_CERT}" "--fullchain-file" "${TMP_FULLCHAIN}" "--force")
for domain in "${CERT_DOMAINS[@]}"; do
ACME_CMD+=("-d" "${domain}")
done
log_f "Using command ${ACME_CMD[*]}"
if [[ -n "${ACME_DNS_PROVIDER}" ]]; then
log_f "DNS provider: ${ACME_DNS_PROVIDER}"
fi
if compgen -A variable | grep -Eq "^DNS_|^ACME_"; then
LOG_KEYS=$(env | grep -E "^(DNS_|ACME_)" | cut -d= -f1 | tr '\n' ' ')
log_f "Available DNS/ACME env keys: ${LOG_KEYS}" redis_only
fi
ACME_RESPONSE=$("${ACME_CMD[@]}" 2>&1 | tee /dev/fd/5; exit ${PIPESTATUS[0]})
SUCCESS="$?"
ACME_RESPONSE_B64=$(echo "${ACME_RESPONSE}" | openssl enc -e -A -base64)
log_f "${ACME_RESPONSE_B64}" redis_only b64
case "$SUCCESS" in
0)
log_f "Deploying certificate ${CERT}..."
if verify_hash_match ${TMP_FULLCHAIN} ${KEY}; then
RETURN=0
if [[ -f ${CERT} ]]; then
RETURN=1
fi
mv -f ${TMP_FULLCHAIN} ${CERT}
rm -f ${TMP_CERT}
echo -n ${CERT_DOMAINS[*]} > ${DOMAINS_FILE}
log_f "Certificate successfully obtained via DNS challenge"
exit ${RETURN}
else
log_f "Certificate was requested, but key and certificate hashes do not match"
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
exit 4
fi
;;
*)
log_f "Failed to obtain certificate ${CERT} for domains '${CERT_DOMAINS[*]}' via DNS challenge"
redis-cli -h redis -a ${REDISPASS} --no-auth-warning SET ACME_FAIL_TIME "$(date +%s)"
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
exit 100${SUCCESS}
;;
esac
@@ -20,10 +20,6 @@ if [[ "${TYPE}" != "rsa" ]]; then
log_f "Unknown certificate type '${TYPE}' requested" log_f "Unknown certificate type '${TYPE}' requested"
exit 5 exit 5
fi fi
if [[ "${ACME_DNS_CHALLENGE}" == "y" ]]; then
exec /srv/obtain-certificate-dns.sh "$@"
fi
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
+36 -20
View File
@@ -1,29 +1,45 @@
#!/bin/bash #!/bin/bash
# Tell every live replica of nginx / dovecot / postfix to reload (or restart
# on cert-amount change) via the mailcow-agent control bus. Replaces the old
# dockerapi-based container_id lookup + exec dance.
reload_service() { # Reading container IDs
local svc="$1" # Wrapping as array to ensure trimmed content when calling $NGINX etc.
echo "Reloading ${svc} via mailcow-agent..." NGINX=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"nginx-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
if ! mailcow-agent-cli send "${svc}" reload >/dev/null; then DOVECOT=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"dovecot-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
echo "Could not publish reload to ${svc}, attempting restart..." POSTFIX=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
mailcow-agent-cli send "${svc}" restart >/dev/null || true
fi reload_nginx(){
echo "Reloading Nginx..."
NGINX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${NGINX}/exec -d '{"cmd":"reload", "task":"nginx"}' --silent -H 'Content-type: application/json' | jq -r .type)
[[ ${NGINX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Nginx, restarting container..."; restart_container ${NGINX} ; }
} }
restart_service() { reload_dovecot(){
local svc="$1" echo "Reloading Dovecot..."
echo "Restarting ${svc} via mailcow-agent..." DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type)
mailcow-agent-cli send "${svc}" restart >/dev/null || true [[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; }
}
reload_postfix(){
echo "Reloading Postfix..."
POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/exec -d '{"cmd":"reload", "task":"postfix"}' --silent -H 'Content-type: application/json' | jq -r .type)
[[ ${POSTFIX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Postfix, restarting container..."; restart_container ${POSTFIX} ; }
}
restart_container(){
for container in $*; do
echo "Restarting ${container}..."
C_REST_OUT=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${container}/restart --silent | jq -r '.msg')
echo "${C_REST_OUT}"
done
} }
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
restart_service nginx restart_container ${NGINX}
restart_service dovecot restart_container ${DOVECOT}
restart_service postfix restart_container ${POSTFIX}
else else
reload_service nginx reload_nginx
restart_service dovecot #reload_dovecot
restart_service postfix restart_container ${DOVECOT}
#reload_postfix
restart_container ${POSTFIX}
fi fi
-34
View File
@@ -1,34 +0,0 @@
# Builder image for mailcow-agent. Each service Dockerfile pulls the static
# binary from here via:
#
# COPY --from=ghcr.io/mailcow/agent:VERSION /out/mailcow-agent /usr/local/bin/mailcow-agent
#
# For local development: build this image first.
#
# docker build -t ghcr.io/mailcow/agent:dev data/Dockerfiles/agent/
#
# CI publishes a versioned tag and the service Dockerfiles pin against it via
# ARG AGENT_IMAGE.
FROM golang:1.22-alpine AS build
ENV CGO_ENABLED=0 \
GOOS=linux
WORKDIR /src
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN mkdir -p /out \
&& go build -trimpath -ldflags="-s -w" \
-o /out/mailcow-agent ./cmd/mailcow-agent \
&& cp mailcow-agent-cli /out/mailcow-agent-cli \
&& chmod +x /out/mailcow-agent-cli
# Final stage: tiny image whose only purpose is to be a COPY --from source.
FROM scratch
COPY --from=build /out/mailcow-agent /out/mailcow-agent
COPY --from=build /out/mailcow-agent-cli /out/mailcow-agent-cli
-16
View File
@@ -1,16 +0,0 @@
# mailcow-agent
Each mailcow service container (postfix, dovecot, …) runs `mailcow-agent` as
ENTRYPOINT. It supervises the original service main process and exposes its
control commands over a Redis Pub/Sub bus:
- `mailcow.control.<service>` — request channel (Backend → Agent)
- `mailcow.reply.<request_id>` — per-request reply channel
- `mailcow.events.<topic>` — broadcast events
- `mailcow.nodes.<service>` (ZSET) + `mailcow.node.<service>.<node_id>` (HASH) — heartbeat registry
- `mailcow.stats.<service>.<node_id>` (HASH) — per-node cpu/memory stats
Service behaviour is selected via `MAILCOW_AGENT_SERVICE=<service>`. The main
process command is configured via `MAILCOW_AGENT_MAIN_CMD` (string, executed via
`sh -c` so existing entrypoints/supervisord commands keep working).
@@ -1,278 +0,0 @@
// Per-container control-bus subscriber. Subscribes to mailcow.control.<service>
// on Redis, runs handlers from the per-service command table, publishes
// heartbeats and stats. Optionally supervises a child process.
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"os/signal"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/redis/go-redis/v9"
"github.com/mailcow/mailcow-dockerized/agent/internal/bus"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
"github.com/mailcow/mailcow-dockerized/agent/internal/registry"
"github.com/mailcow/mailcow-dockerized/agent/internal/services"
"github.com/mailcow/mailcow-dockerized/agent/internal/stats"
)
const agentVersion = "0.1.0"
// atomicSignal shares the last caught terminal signal between the handler
// goroutine and main() so it can be forwarded to the supervised child.
type atomicSignal struct{ v atomic.Int32 }
func (a *atomicSignal) Store(s syscall.Signal) { a.v.Store(int32(s)) }
func (a *atomicSignal) Load() os.Signal { return syscall.Signal(a.v.Load()) }
// healthState holds the latest health probe result. Written by the probe loop,
// read by the heartbeat loop.
type healthState struct {
mu sync.RWMutex
ok bool
detail string
at time.Time
}
func (h *healthState) Set(ok bool, detail string) {
h.mu.Lock()
h.ok = ok
h.detail = detail
h.at = time.Now()
h.mu.Unlock()
}
func (h *healthState) Snapshot() (ok bool, detail string, at time.Time) {
h.mu.RLock()
defer h.mu.RUnlock()
return h.ok, h.detail, h.at
}
func main() {
service := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_SERVICE"))
if service == "" {
fmt.Fprintf(os.Stderr, "mailcow-agent: MAILCOW_AGENT_SERVICE is required. Known: %v\n", services.Known())
os.Exit(2)
}
// `mailcow-agent healthcheck` runs the probe once and exits 0/1
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
runHealthcheckOnce(service)
}
nodeID := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_NODE_ID"))
if nodeID == "" {
h, err := os.Hostname()
if err != nil {
log.Fatalf("mailcow-agent: hostname: %v", err)
}
nodeID = h
}
mainCmd := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_MAIN_CMD"))
// host-agent has no supervised child; everything else runs one.
wantsSupervisor := service != "host" && mainCmd != ""
rdb, err := newRedis()
if err != nil {
log.Fatalf("mailcow-agent: redis: %v", err)
}
defer rdb.Close()
// Start the supervised process before serving bus requests — restart/stop
// handlers assume something is already running.
var sup *proc.Supervisor
if wantsSupervisor {
sup = proc.New(mainCmd)
if err := sup.Start(); err != nil {
log.Fatalf("mailcow-agent: start main: %v", err)
}
}
table, err := services.Build(service, sup)
if err != nil {
log.Fatalf("mailcow-agent: %v", err)
}
// We handle signals ourselves so we can (a) suppress the Go-runtime stack
// dump on SIGQUIT (php-fpm-alpine's STOPSIGNAL) and (b) remember which
// signal arrived to forward it to the child on shutdown.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT,
syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2)
defer signal.Stop(sigCh)
stopSig := atomicSignal{}
stopSig.Store(syscall.SIGTERM)
go func() {
for sig := range sigCh {
switch sig {
case syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT:
stopSig.Store(sig.(syscall.Signal))
log.Printf("mailcow-agent: caught %s, beginning graceful shutdown", sig)
cancel()
return
case syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2:
if sup != nil {
sup.SignalChild(sig)
}
}
}
}()
// Initial state is "ok" so the service isn't flagged unhealthy before the
// first probe has run.
health := &healthState{ok: true, detail: "", at: time.Now()}
if table.HealthProbe != nil {
go runHealthLoop(ctx, table.HealthProbe, health, 10*time.Second)
}
hb := registry.Heartbeat{
Service: service,
NodeID: nodeID,
Version: agentVersion,
StartedAt: time.Now(),
Image: os.Getenv("MAILCOW_AGENT_IMAGE"),
Health: health,
}
go registry.Loop(ctx, rdb, hb, 10*time.Second)
// cgroup stats for this container. Host metrics come from exec.host-stats.
pub := stats.NewPublisher(rdb, service, nodeID)
go pub.Run(ctx, 10*time.Second)
srv := bus.NewServer(rdb, table, nodeID)
busErrCh := make(chan error, 1)
go func() { busErrCh <- srv.Run(ctx) }()
log.Printf("mailcow-agent: service=%s node=%s ready (commands=%d)", service, nodeID, len(table.Handlers))
// Exit only on outside termination or fatal bus error. A crashed/stopped
// child should not tear down the container — the operator may want to
// issue `start` over the bus afterwards.
exitCode := 0
select {
case <-ctx.Done():
log.Println("mailcow-agent: shutdown signal received")
case err := <-busErrCh:
if err != nil && !errors.Is(err, context.Canceled) {
log.Printf("mailcow-agent: bus loop exited: %v", err)
exitCode = 1
}
}
// Graceful shutdown bounded at 35s.
shutCtx, shutCancel := context.WithTimeout(context.Background(), 35*time.Second)
defer shutCancel()
_ = srv.Shutdown(shutCtx)
_ = registry.Deregister(shutCtx, rdb, service, nodeID)
if sup != nil {
// Forward the exact signal we received so the child sees the same
// shutdown semantics it would without us in front (e.g. SIGQUIT →
// php-fpm graceful drain).
if err := sup.StopWithSignal(shutCtx, stopSig.Load()); err != nil {
log.Printf("mailcow-agent: stop main: %v", err)
}
}
os.Exit(exitCode)
}
// runHealthcheckOnce runs the local probe with a tight deadline and exits 0/1.
// Used by the `healthcheck` sub-command path.
func runHealthcheckOnce(service string) {
table, err := services.Build(service, nil)
if err != nil {
fmt.Fprintln(os.Stderr, "mailcow-agent healthcheck:", err)
os.Exit(2)
}
if table.HealthProbe == nil {
// Services without a probe are considered healthy.
os.Exit(0)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := table.HealthProbe(ctx); err != nil {
fmt.Fprintln(os.Stderr, "unhealthy:", err)
os.Exit(1)
}
os.Exit(0)
}
// runHealthLoop ticks the probe and updates state. Same probe path as the
// healthcheck sub-command.
func runHealthLoop(ctx context.Context, probe commands.HealthProbe, state *healthState, interval time.Duration) {
t := time.NewTicker(interval)
defer t.Stop()
check := func() {
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := probe(pctx); err != nil {
state.Set(false, err.Error())
} else {
state.Set(true, "")
}
}
check()
for {
select {
case <-ctx.Done():
return
case <-t.C:
check()
}
}
}
func newRedis() (*redis.Client, error) {
addr := os.Getenv("REDIS_SLAVEOF_IP")
port := os.Getenv("REDIS_SLAVEOF_PORT")
if addr == "" {
addr = "redis-mailcow"
port = "6379"
}
if port == "" {
port = "6379"
}
pass := os.Getenv("REDISPASS")
cli := redis.NewClient(&redis.Options{
Addr: addr + ":" + port,
Password: pass,
DB: 0,
MaxRetries: -1,
MinRetryBackoff: 200 * time.Millisecond,
MaxRetryBackoff: 5 * time.Second,
})
// Wait up to 2 minutes for Redis to come up before giving up
deadline := time.Now().Add(2 * time.Minute)
var lastErr error
for attempt := 1; time.Now().Before(deadline); attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
err := cli.Ping(ctx).Err()
cancel()
if err == nil {
return cli, nil
}
lastErr = err
wait := time.Duration(attempt) * time.Second
if wait > 10*time.Second {
wait = 10 * time.Second
}
log.Printf("mailcow-agent: waiting for redis %s (attempt %d): %v", addr, attempt, err)
time.Sleep(wait)
}
return nil, fmt.Errorf("ping %s after 2m: %w", addr, lastErr)
}
-13
View File
@@ -1,13 +0,0 @@
module github.com/mailcow/mailcow-dockerized/agent
go 1.22
require (
github.com/oklog/ulid/v2 v2.1.0
github.com/redis/go-redis/v9 v9.7.0
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
)
-6
View File
@@ -1,6 +0,0 @@
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
-175
View File
@@ -1,175 +0,0 @@
// Package bus implements the Redis Pub/Sub control bus: subscribing to the
// service's control channel, dispatching envelopes to a commands.Table, and
// publishing responses back to env.ReplyTo.
package bus
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/envelope"
)
// ControlChannel assembles the per-service control channel.
func ControlChannel(service string) string { return "mailcow.control." + service }
// Server subscribes to a control channel and dispatches commands.
type Server struct {
rdb *redis.Client
table *commands.Table
nodeID string
dedupe *lru
stop chan struct{}
stopped sync.Once
wg sync.WaitGroup
}
// NewServer wires a fresh server. nodeID is stamped into every Response and is
// what the backend's fan-in aggregator uses to attribute results.
func NewServer(rdb *redis.Client, table *commands.Table, nodeID string) *Server {
return &Server{
rdb: rdb,
table: table,
nodeID: nodeID,
dedupe: newLRU(1024),
stop: make(chan struct{}),
}
}
// Run blocks, subscribing to ControlChannel(service) and dispatching incoming
// envelopes concurrently. It returns when ctx is done or Shutdown is called.
func (s *Server) Run(ctx context.Context) error {
channel := ControlChannel(s.table.Service)
sub := s.rdb.Subscribe(ctx, channel)
defer sub.Close()
if _, err := sub.Receive(ctx); err != nil {
return fmt.Errorf("bus: subscribe %s: %w", channel, err)
}
msgs := sub.Channel()
for {
select {
case <-ctx.Done():
s.wg.Wait()
return ctx.Err()
case <-s.stop:
s.wg.Wait()
return nil
case m, ok := <-msgs:
if !ok {
s.wg.Wait()
return errors.New("bus: subscription channel closed")
}
s.wg.Add(1)
go func(payload string) {
defer s.wg.Done()
s.dispatch(ctx, payload)
}(m.Payload)
}
}
}
// Shutdown signals Run to stop and waits for in-flight handlers (bounded by
// ctx).
func (s *Server) Shutdown(ctx context.Context) error {
s.stopped.Do(func() { close(s.stop) })
done := make(chan struct{})
go func() { s.wg.Wait(); close(done) }()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Server) dispatch(parent context.Context, payload string) {
var req envelope.Request
if err := json.Unmarshal([]byte(payload), &req); err != nil {
// Malformed envelope: no RequestID/ReplyTo we can trust — drop.
return
}
if req.RequestID != "" && !s.dedupe.add(req.RequestID) {
// Duplicate (retry of an idempotent command): silently absorb.
return
}
// Per-node targeting: if args.target_node is set and doesn't match us,
// drop silently. The intended replica picks it up and replies.
if target, ok := req.Args["target_node"].(string); ok && target != "" && target != s.nodeID {
return
}
ctx, cancel := handlerContext(parent, req.Deadline)
defer cancel()
start := time.Now()
resp := envelope.Response{RequestID: req.RequestID, OK: true, Node: s.nodeID}
if h := s.table.Lookup(req.Cmd); h == nil {
resp.OK = false
resp.Error = fmt.Sprintf("no handler for cmd %q", req.Cmd)
resp.ErrorCode = envelope.ErrCodeUnsupportedCommand
} else {
result, err := runWithRecover(ctx, h, req.Args)
switch {
case err == nil:
resp.Result = result
case errors.Is(err, commands.ErrNotFound):
resp.OK = false
resp.Error = err.Error()
resp.ErrorCode = envelope.ErrCodeNotFound
case errors.Is(err, commands.ErrValidation):
resp.OK = false
resp.Error = err.Error()
resp.ErrorCode = envelope.ErrCodeValidation
case errors.Is(err, context.DeadlineExceeded), errors.Is(ctx.Err(), context.DeadlineExceeded):
resp.OK = false
resp.Error = err.Error()
resp.ErrorCode = envelope.ErrCodeTimeout
default:
resp.OK = false
resp.Error = err.Error()
resp.ErrorCode = envelope.ErrCodeInternal
}
}
resp.DurationMS = time.Since(start).Milliseconds()
if req.ReplyTo == "" {
return
}
data, err := json.Marshal(resp)
if err != nil {
return
}
// Replies go through a List (RPUSH + EXPIRE), not Pub/Sub. This sidesteps
// the "subscribe-before-publish" race and lets the backend fan-in via
// BLPOP — important because PhpRedis's subscribe() blocks, so we can't
// publish on the same connection after subscribing. Use parent ctx so a
// per-handler deadline can't stop us from delivering the timeout response.
pipe := s.rdb.Pipeline()
pipe.RPush(parent, req.ReplyTo, data)
pipe.Expire(parent, req.ReplyTo, 60*time.Second)
_, _ = pipe.Exec(parent)
}
func runWithRecover(ctx context.Context, h commands.Handler, args map[string]any) (out any, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("handler panic: %v", r)
}
}()
return h(ctx, args)
}
func handlerContext(parent context.Context, deadline time.Time) (context.Context, context.CancelFunc) {
if deadline.IsZero() {
return context.WithCancel(parent)
}
return context.WithDeadline(parent, deadline)
}
@@ -1,38 +0,0 @@
package bus
import (
"container/list"
"sync"
)
// lru is a tiny request-id deduplication cache. The bus treats Pub/Sub
// retries (same request_id) as no-ops. Not a security boundary — only a
// best-effort guard against accidental double-execution.
type lru struct {
mu sync.Mutex
cap int
idx map[string]*list.Element
list *list.List
}
func newLRU(cap int) *lru {
return &lru{cap: cap, idx: make(map[string]*list.Element, cap), list: list.New()}
}
// add returns true if id is new and was inserted; false if it was already
// known (caller should skip the duplicate).
func (l *lru) add(id string) bool {
l.mu.Lock()
defer l.mu.Unlock()
if _, ok := l.idx[id]; ok {
return false
}
e := l.list.PushFront(id)
l.idx[id] = e
for l.list.Len() > l.cap {
old := l.list.Back()
l.list.Remove(old)
delete(l.idx, old.Value.(string))
}
return true
}
@@ -1,83 +0,0 @@
// Package commands defines the per-service handler table. The bus dispatcher
// looks up handlers by name and wraps the result in an envelope.Response.
package commands
import (
"context"
"errors"
)
// ErrNotFound signals that the target (queue id, mailbox, …) doesn't live on
// this node. For broadcast operations the aggregator still counts success if
// any other node returns ok.
var ErrNotFound = errors.New("not_found")
// ErrValidation indicates a missing or malformed argument.
var ErrValidation = errors.New("validation")
// Handler executes a single command for a service.
type Handler func(ctx context.Context, args map[string]any) (any, error)
// HealthProbe returns nil if the supervised service is healthy, error otherwise.
// Shared between the `healthcheck` sub-command and the agent's heartbeat loop.
type HealthProbe func(ctx context.Context) error
// Table is the per-service command registry built once at startup.
type Table struct {
Service string
Handlers map[string]Handler
HealthProbe HealthProbe
}
// New constructs an empty table for a service.
func New(service string) *Table {
return &Table{Service: service, Handlers: make(map[string]Handler)}
}
// Register adds a handler. Duplicate registration panics — wiring bugs should
// be loud.
func (t *Table) Register(cmd string, h Handler) {
if _, dup := t.Handlers[cmd]; dup {
panic("commands: duplicate handler " + t.Service + "/" + cmd)
}
t.Handlers[cmd] = h
}
// Lookup returns the handler for cmd or nil.
func (t *Table) Lookup(cmd string) Handler {
return t.Handlers[cmd]
}
// ArgString extracts a required string argument.
func ArgString(args map[string]any, key string) (string, error) {
v, ok := args[key]
if !ok {
return "", errArg(key, "missing")
}
s, ok := v.(string)
if !ok || s == "" {
return "", errArg(key, "must be non-empty string")
}
return s, nil
}
// ArgStringOpt returns an optional string argument with a default.
func ArgStringOpt(args map[string]any, key, def string) string {
if v, ok := args[key]; ok {
if s, ok := v.(string); ok && s != "" {
return s
}
}
return def
}
func errArg(key, reason string) error {
return &validationError{key: key, reason: reason}
}
type validationError struct{ key, reason string }
func (e *validationError) Error() string { return "arg " + e.key + ": " + e.reason }
func (e *validationError) Is(target error) bool {
return target == ErrValidation
}
@@ -1,60 +0,0 @@
package commands
import (
"bytes"
"context"
"fmt"
"os/exec"
)
// RunOptions configures a single Run invocation.
type RunOptions struct {
// Stdin, if non-nil, is written to the process stdin.
Stdin []byte
// CombinedOutputCap limits the captured output (truncated at the end).
// 0 means unlimited. The agent uses ~1 MiB for cat-queue, smaller for
// status-style commands.
OutputCap int
}
// RunResult is what every shell-style command returns.
type RunResult struct {
Stdout string `json:"stdout,omitempty"`
Stderr string `json:"stderr,omitempty"`
ExitCode int `json:"exit_code"`
}
// Run executes argv[0] argv[1:] under ctx (the bus deadline). It does not
// translate exit codes to errors — callers inspect r.ExitCode themselves so
// they can map e.g. "queue id not found" exit codes to ErrNotFound.
func Run(ctx context.Context, opts RunOptions, argv ...string) (*RunResult, error) {
if len(argv) == 0 {
return nil, fmt.Errorf("commands.Run: empty argv")
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if opts.Stdin != nil {
cmd.Stdin = bytes.NewReader(opts.Stdin)
}
err := cmd.Run()
out := stdout.String()
errOut := stderr.String()
if opts.OutputCap > 0 {
if len(out) > opts.OutputCap {
out = out[:opts.OutputCap] + "\n…(truncated)"
}
if len(errOut) > opts.OutputCap {
errOut = errOut[:opts.OutputCap] + "\n…(truncated)"
}
}
exit := 0
if exitErr, ok := err.(*exec.ExitError); ok {
exit = exitErr.ExitCode()
err = nil
}
return &RunResult{Stdout: out, Stderr: errOut, ExitCode: exit}, err
}
@@ -1,34 +0,0 @@
// Package envelope defines the wire format for the mailcow-agent control bus.
package envelope
import "time"
// Request is what the backend publishes on mailcow.control.<service>.
type Request struct {
Cmd string `json:"cmd"`
RequestID string `json:"request_id"`
Args map[string]any `json:"args,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
Deadline time.Time `json:"deadline,omitempty"`
IssuedBy string `json:"issued_by,omitempty"`
}
// Response is what the agent publishes on the reply_to channel.
type Response struct {
RequestID string `json:"request_id"`
OK bool `json:"ok"`
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorCode string `json:"error_code,omitempty"`
DurationMS int64 `json:"duration_ms"`
Node string `json:"node,omitempty"`
}
// Error codes returned in Response.ErrorCode. Keep in sync with the V2 schema.
const (
ErrCodeValidation = "validation"
ErrCodeNotFound = "not_found"
ErrCodeTimeout = "timeout"
ErrCodeUnsupportedCommand = "unsupported_command"
ErrCodeInternal = "internal"
)
@@ -1,253 +0,0 @@
// Package proc supervises the service's main process — postfix, dovecot,
// nginx, … — as a child of the agent. It exposes the high-level lifecycle
// verbs (reload/restart/stop/start) used by the per-service command tables.
//
// "reload" → SIGHUP
// "restart" → SIGTERM, wait, exec again
// "stop" → SIGTERM, leave stopped
// "start" → exec again (only if currently stopped)
package proc
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"sync"
"syscall"
"time"
)
// Supervisor wraps a single child process.
type Supervisor struct {
cmdLine string // shell command (passed to `sh -c …`)
stopSignal os.Signal
stopGrace time.Duration
mu sync.Mutex
cmd *exec.Cmd
stopped bool
exitedCh chan struct{}
}
// New constructs a Supervisor. cmdLine is executed via `sh -c` so existing
// docker-entrypoint.sh scripts keep working without quoting headaches.
func New(cmdLine string) *Supervisor {
return &Supervisor{
cmdLine: cmdLine,
stopSignal: syscall.SIGTERM,
stopGrace: 30 * time.Second,
}
}
// Start launches the child process. Returns an error if it cannot be spawned.
// The agent's main() also blocks on Wait() to surface exit status.
func (s *Supervisor) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cmd != nil && s.cmd.Process != nil && !s.stopped {
return errors.New("proc: already running")
}
// `exec ` prefix tells the shell to replace itself with the command
// instead of forking and waiting. Without it, sh stays alive as the
// parent of the real service process, signals from us land on the
// shell instead of on the service, and SIGHUP for config reloads
// silently does nothing. With the prefix the supervised PID *is* the
// service after the script's own `exec "$@"` chains through.
cmd := exec.Command("/bin/sh", "-c", "exec "+s.cmdLine)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("proc: start: %w", err)
}
s.cmd = cmd
s.stopped = false
s.exitedCh = make(chan struct{})
go func() {
_ = cmd.Wait()
close(s.exitedCh)
}()
return nil
}
// Wait blocks until the child exits and returns its exit code.
func (s *Supervisor) Wait() int {
s.mu.Lock()
exited := s.exitedCh
cmd := s.cmd
s.mu.Unlock()
if exited == nil {
return -1
}
<-exited
if cmd == nil || cmd.ProcessState == nil {
return -1
}
return cmd.ProcessState.ExitCode()
}
// SignalChild forwards a single signal to the supervised child without
// changing the supervisor's lifecycle state. Used to relay SIGHUP/USR1/USR2
// from the agent's signal handler to the service so operators can still
// `docker compose kill -s HUP postfix-mailcow` and see the expected effect.
func (s *Supervisor) SignalChild(sig os.Signal) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cmd == nil || s.cmd.Process == nil || s.stopped {
return errors.New("proc: not running")
}
return s.cmd.Process.Signal(sig)
}
// Reload sends SIGHUP. Returns nil if the signal was delivered.
func (s *Supervisor) Reload() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.cmd == nil || s.cmd.Process == nil || s.stopped {
return errors.New("proc: not running")
}
return s.cmd.Process.Signal(syscall.SIGHUP)
}
// Stop sends the configured stop signal and waits for the process to exit
// (bounded by stopGrace). Marks the supervisor as stopped — Start must be
// called again to relaunch.
func (s *Supervisor) Stop(ctx context.Context) error {
return s.StopWithSignal(ctx, s.stopSignal)
}
// StopWithSignal is like Stop but lets the caller override the stop signal.
// Used by main() to forward whatever signal Docker sent us (SIGTERM for
// most containers, SIGQUIT for php-fpm-alpine which uses SIGQUIT for
// graceful shutdown) so the child gets the same signal semantics it would
// receive without the agent in front of it.
func (s *Supervisor) StopWithSignal(ctx context.Context, sig os.Signal) error {
s.mu.Lock()
cmd := s.cmd
exited := s.exitedCh
if cmd == nil || cmd.Process == nil {
s.mu.Unlock()
return nil
}
s.stopped = true
s.mu.Unlock()
sysSig, ok := sig.(syscall.Signal)
if !ok {
sysSig = syscall.SIGTERM
}
pgid, err := syscall.Getpgid(cmd.Process.Pid)
if err == nil {
_ = syscall.Kill(-pgid, sysSig)
} else {
_ = cmd.Process.Signal(sysSig)
}
timer := time.NewTimer(s.stopGrace)
defer timer.Stop()
select {
case <-exited:
return nil
case <-timer.C:
// Last resort: SIGKILL the whole process group.
if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil {
_ = syscall.Kill(-pgid, syscall.SIGKILL)
} else {
_ = cmd.Process.Kill()
}
<-exited
return errors.New("proc: forced kill after grace period")
case <-ctx.Done():
return ctx.Err()
}
}
// Restart performs Stop+Start using the supervisor's default stop signal.
// Different from a Docker-initiated shutdown: here it's an explicit "restart
// this service" command, so we want the standard SIGTERM semantics.
func (s *Supervisor) Restart(ctx context.Context) error {
if err := s.Stop(ctx); err != nil {
return err
}
return s.Start()
}
// IsRunning reports whether the supervised child is currently alive (started
// and not yet exited or stopped).
func (s *Supervisor) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.stopped || s.cmd == nil || s.cmd.Process == nil {
return false
}
// exitedCh is closed when the child exits. Non-blocking read.
select {
case <-s.exitedCh:
return false
default:
return true
}
}
// WaitStable blocks for `settle` and returns nil if the supervised child is
// still running at the end, otherwise an error describing the exit. Used by
// the `restart` command to give the operator real "did it come back up"
// feedback instead of an immediate OK.
func (s *Supervisor) WaitStable(ctx context.Context, settle time.Duration) error {
s.mu.Lock()
exited := s.exitedCh
s.mu.Unlock()
if exited == nil {
return errors.New("proc: not running")
}
select {
case <-exited:
// Child died within the settle window.
s.mu.Lock()
cmd := s.cmd
s.mu.Unlock()
code := -1
if cmd != nil && cmd.ProcessState != nil {
code = cmd.ProcessState.ExitCode()
}
return fmt.Errorf("proc: child exited within settle window (code=%d)", code)
case <-time.After(settle):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Forward installs a signal forwarder: SIGINT/SIGTERM/SIGHUP/SIGUSR1/SIGUSR2
// received by the agent are propagated to the child. Returns a cancel func
// to release the handler.
func (s *Supervisor) Forward(signals ...os.Signal) func() {
ch := make(chan os.Signal, len(signals)+1)
signalNotify(ch, signals...)
done := make(chan struct{})
go func() {
for {
select {
case <-done:
return
case sig := <-ch:
s.mu.Lock()
cmd := s.cmd
s.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Signal(sig)
}
if sig == syscall.SIGTERM || sig == syscall.SIGINT {
// On terminal signals propagate and let main exit.
return
}
}
}
}()
return func() {
close(done)
signalStop(ch)
}
}
@@ -1,14 +0,0 @@
package proc
import (
"os"
"os/signal"
)
// Indirection so tests can stub these out if ever needed.
var (
signalNotify = signal.Notify
signalStop = signal.Stop
)
var _ = os.Stdout // anchor import for go vet
@@ -1,97 +0,0 @@
// Package registry publishes per-node heartbeats to Redis so the backend can
// enumerate live containers. Two keys per service:
//
// ZSET mailcow.nodes.<service> score=unix_ts member=node_id
// HASH mailcow.node.<service>.<node_id> { version, started_at, image, health* }
//
// Both keys have a 30s TTL refreshed every 10s. Deregister clears them on
// graceful shutdown.
package registry
import (
"context"
"fmt"
"strconv"
"time"
"github.com/redis/go-redis/v9"
)
// HealthSnapshotter returns the latest health probe result so the heartbeat
// can attach it to each tick. Implemented by main.healthState.
type HealthSnapshotter interface {
Snapshot() (ok bool, detail string, at time.Time)
}
// Heartbeat carries the metadata published with every refresh.
type Heartbeat struct {
Service string
NodeID string
Version string
StartedAt time.Time
Image string
Health HealthSnapshotter // optional; nil → omit health fields
}
func nodesKey(service string) string { return "mailcow.nodes." + service }
func nodeKey(service, node string) string { return "mailcow.node." + service + "." + node }
// Publish writes one heartbeat tick. Callers run this in a loop.
func Publish(ctx context.Context, rdb *redis.Client, h Heartbeat) error {
now := time.Now().Unix()
fields := map[string]any{
"version": h.Version,
"started_at": h.StartedAt.UTC().Format(time.RFC3339),
"image": h.Image,
"node_id": h.NodeID,
"service": h.Service,
"updated_at": strconv.FormatInt(now, 10),
}
if h.Health != nil {
ok, detail, at := h.Health.Snapshot()
if ok {
fields["health"] = "ok"
} else {
fields["health"] = "fail"
}
fields["health_detail"] = detail
fields["health_at"] = strconv.FormatInt(at.Unix(), 10)
}
pipe := rdb.Pipeline()
pipe.ZAdd(ctx, nodesKey(h.Service), redis.Z{Score: float64(now), Member: h.NodeID})
pipe.Expire(ctx, nodesKey(h.Service), 5*time.Minute)
pipe.HSet(ctx, nodeKey(h.Service, h.NodeID), fields)
pipe.Expire(ctx, nodeKey(h.Service, h.NodeID), 30*time.Second)
_, err := pipe.Exec(ctx)
if err != nil {
return fmt.Errorf("registry: heartbeat exec: %w", err)
}
return nil
}
// Deregister removes the node from the ZSET and deletes its detail hash.
// Called on graceful shutdown so the dashboard reflects intentional stops
// immediately rather than waiting for TTL.
func Deregister(ctx context.Context, rdb *redis.Client, service, nodeID string) error {
pipe := rdb.Pipeline()
pipe.ZRem(ctx, nodesKey(service), nodeID)
pipe.Del(ctx, nodeKey(service, nodeID))
_, err := pipe.Exec(ctx)
return err
}
// Loop runs Publish on a ticker until ctx is done. It is the typical caller.
func Loop(ctx context.Context, rdb *redis.Client, h Heartbeat, interval time.Duration) {
// Publish once immediately so the dashboard sees us right away.
_ = Publish(ctx, rdb, h)
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = Publish(ctx, rdb, h)
}
}
}
@@ -1,9 +0,0 @@
package services
import "time"
// nowStamp returns a sortable timestamp used to suffix moved/garbage maildirs
// so repeated cleanups don't collide.
func nowStamp() string {
return time.Now().UTC().Format("20060102T150405Z")
}
@@ -1,294 +0,0 @@
package services
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
func init() { Register("dovecot", buildDovecot) }
const vmailRoot = "/var/vmail"
func dovecotHealthProbe(ctx context.Context) error {
// IMAP greeting on :143 — must be "* OK ..."
conn, err := net.DialTimeout("tcp", "127.0.0.1:143", 3*time.Second)
if err != nil {
return err
}
defer conn.Close()
buf := make([]byte, 64)
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
n, err := conn.Read(buf)
if err != nil {
return fmt.Errorf("read greeting: %w", err)
}
greeting := string(buf[:n])
if !strings.HasPrefix(greeting, "* OK") {
return fmt.Errorf("unexpected greeting: %s", strings.TrimSpace(greeting))
}
return nil
}
func buildDovecot(sup *proc.Supervisor) *commands.Table {
t := commands.New("dovecot")
t.HealthProbe = dovecotHealthProbe
// `dovecot reload` re-reads config without restarting the master process.
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
r, err := commands.Run(ctx, commands.RunOptions{}, "dovecot", "reload")
return nil, asError(r, err)
})
addLifecycleExceptReload(t, sup)
t.Register("exec.fts-rescan", func(ctx context.Context, args map[string]any) (any, error) {
user := commands.ArgStringOpt(args, "user", "")
argv := []string{"doveadm", "fts", "rescan"}
if user != "" {
argv = append(argv, "-u", user)
} else {
argv = append(argv, "-A")
}
r, err := commands.Run(ctx, commands.RunOptions{}, argv...)
return nil, asError(r, err)
})
t.Register("exec.sieve-list", func(ctx context.Context, args map[string]any) (any, error) {
user, err := commands.ArgString(args, "user")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "sieve", "list", "-u", user)
if err != nil {
return nil, err
}
if r.ExitCode != 0 {
return nil, &runError{msg: strings.TrimSpace(r.Stderr)}
}
scripts := splitNonEmpty(r.Stdout)
return map[string]any{"scripts": scripts}, nil
})
t.Register("exec.sieve-print", func(ctx context.Context, args map[string]any) (any, error) {
user, err := commands.ArgString(args, "user")
if err != nil {
return nil, err
}
script, err := commands.ArgString(args, "script")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 1 << 20}, "doveadm", "sieve", "get", "-u", user, script)
if err != nil {
return nil, err
}
if r.ExitCode != 0 {
return nil, &runError{msg: strings.TrimSpace(r.Stderr)}
}
return map[string]any{"body": r.Stdout}, nil
})
t.Register("exec.acl-get", func(ctx context.Context, args map[string]any) (any, error) {
user, err := commands.ArgString(args, "user")
if err != nil {
return nil, err
}
// First enumerate mailboxes, then collect ACLs per mailbox.
boxes, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "mailbox", "list", "-u", user)
if err != nil {
return nil, err
}
if boxes.ExitCode != 0 {
return nil, &runError{msg: strings.TrimSpace(boxes.Stderr)}
}
out := []map[string]any{}
for _, mbx := range splitNonEmpty(boxes.Stdout) {
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "get", "-u", user, mbx)
if err != nil || r.ExitCode != 0 {
continue
}
for _, line := range strings.Split(strings.TrimSpace(r.Stdout), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "ID") {
continue
}
fields := strings.Fields(line)
if len(fields) >= 2 {
out = append(out, map[string]any{
"mailbox": mbx,
"identifier": fields[0],
"rights": strings.Join(fields[1:], " "),
})
}
}
}
return map[string]any{"acls": out}, nil
})
t.Register("exec.acl-set", func(ctx context.Context, args map[string]any) (any, error) {
user, err := commands.ArgString(args, "user")
if err != nil {
return nil, err
}
mailbox, err := commands.ArgString(args, "mailbox")
if err != nil {
return nil, err
}
identifier, err := commands.ArgString(args, "identifier")
if err != nil {
return nil, err
}
rights, err := commands.ArgString(args, "rights")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "set", "-u", user, mailbox, identifier, rights)
return nil, asError(r, err)
})
t.Register("exec.acl-delete", func(ctx context.Context, args map[string]any) (any, error) {
user, err := commands.ArgString(args, "user")
if err != nil {
return nil, err
}
mailbox, err := commands.ArgString(args, "mailbox")
if err != nil {
return nil, err
}
identifier, err := commands.ArgString(args, "identifier")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "delete", "-u", user, mailbox, identifier)
return nil, asError(r, err)
})
t.Register("exec.maildir-cleanup", func(ctx context.Context, args map[string]any) (any, error) {
maildir, err := commands.ArgString(args, "maildir")
if err != nil {
return nil, err
}
if err := assertSafeMaildirPath(maildir); err != nil {
return nil, err
}
src := filepath.Join(vmailRoot, maildir)
dst := filepath.Join(vmailRoot, "_garbage", maildir+"_"+nowStamp())
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil, commands.ErrNotFound
}
if err := os.MkdirAll(filepath.Dir(dst), 0o770); err != nil {
return nil, err
}
return nil, os.Rename(src, dst)
})
t.Register("exec.df", func(ctx context.Context, args map[string]any) (any, error) {
dir := commands.ArgStringOpt(args, "dir", "/var/vmail")
var st syscall.Statfs_t
if err := syscall.Statfs(dir, &st); err != nil {
return nil, err
}
size := uint64(st.Blocks) * uint64(st.Bsize)
free := uint64(st.Bavail) * uint64(st.Bsize)
used := size - free
pct := 0
if size > 0 {
pct = int(float64(used) / float64(size) * 100)
}
// Format: Filesystem,Size,Used,Avail,Use%,Mounted-on
return fmt.Sprintf("%s,%s,%s,%s,%d%%,%s",
"local", humanBytes(size), humanBytes(used), humanBytes(free), pct, dir), nil
})
t.Register("exec.maildir-move", func(ctx context.Context, args map[string]any) (any, error) {
from, err := commands.ArgString(args, "from")
if err != nil {
return nil, err
}
to, err := commands.ArgString(args, "to")
if err != nil {
return nil, err
}
if err := assertSafeMaildirPath(from); err != nil {
return nil, err
}
if err := assertSafeMaildirPath(to); err != nil {
return nil, err
}
src := filepath.Join(vmailRoot, from)
dst := filepath.Join(vmailRoot, to)
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil, commands.ErrNotFound
}
if err := os.MkdirAll(filepath.Dir(dst), 0o770); err != nil {
return nil, err
}
return nil, os.Rename(src, dst)
})
return t
}
// addLifecycleExceptReload wires restart/stop/start without overriding reload,
// which postfix/dovecot define themselves (canonical CLI command).
func addLifecycleExceptReload(t *commands.Table, sup *proc.Supervisor) {
if sup == nil {
return
}
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
return nil, sup.Restart(ctx)
})
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
return nil, sup.Stop(ctx)
})
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
return nil, sup.Start()
})
}
func splitNonEmpty(s string) []string {
out := []string{}
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
line = strings.TrimSpace(line)
if line != "" {
out = append(out, line)
}
}
return out
}
// assertSafeMaildirPath blocks path traversal and absolute paths — relative
// names under /var/vmail only.
func assertSafeMaildirPath(p string) error {
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "..") {
return &validationErr{msg: "unsafe maildir path"}
}
return nil
}
type validationErr struct{ msg string }
func (e *validationErr) Error() string { return e.msg }
func (e *validationErr) Is(target error) bool { return target == commands.ErrValidation }
// humanBytes renders a byte count in `df -H` style (1000-based units).
func humanBytes(n uint64) string {
const unit = 1000
if n < unit {
return fmt.Sprintf("%dB", n)
}
div, exp := uint64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(n)/float64(div), "KMGTPE"[exp])
}
@@ -1,37 +0,0 @@
package services
import (
"context"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
// Services without any exec.* commands of their own — lifecycle only.
func init() {
Register("clamd", genericBuilder("clamd", tcpProbe("127.0.0.1:3310", 2*time.Second)))
Register("olefy", genericBuilder("olefy", tcpProbe("127.0.0.1:10055", 2*time.Second)))
Register("postfix-tlspol", genericBuilder("postfix-tlspol", tcpProbe("127.0.0.1:8642", 2*time.Second)))
Register("php-fpm", genericBuilder("php-fpm", tcpProbe("127.0.0.1:9001", 2*time.Second)))
Register("acme", genericBuilder("acme", nil))
Register("watchdog", genericBuilder("watchdog", nil))
Register("netfilter", genericBuilder("netfilter", nil))
Register("ofelia", genericBuilder("ofelia", nil))
Register("dovecot-fts", genericBuilder("dovecot-fts", nil))
}
func genericBuilder(name string, probe commands.HealthProbe) Builder {
return func(sup *proc.Supervisor) *commands.Table {
t := commands.New(name)
t.HealthProbe = probe
addLifecycle(t, sup)
return t
}
}
func tcpProbe(addr string, timeout time.Duration) commands.HealthProbe {
return func(ctx context.Context) error {
return probeTCP(addr, timeout)
}
}
@@ -1,79 +0,0 @@
package services
import (
"strings"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
)
// runError is what we return when a shell command exited non-zero but the
// failure is not a "target not found" case. The bus maps it to
// ErrCodeInternal.
type runError struct{ msg string }
func (e *runError) Error() string { return e.msg }
// asError converts a (RunResult, err) pair from commands.Run into a single
// error: pre-exec error → return as-is; non-zero exit → wrap stderr.
func asError(r *commands.RunResult, err error) error {
if err != nil {
return err
}
if r.ExitCode != 0 {
msg := strings.TrimSpace(r.Stderr)
if msg == "" {
msg = "command exited " + itoa(r.ExitCode)
}
return &runError{msg: msg}
}
return nil
}
// asNotFoundOrError is the variant for queue/mailbox operations that may fail
// because the target doesn't live on this node. Maps known stderr fragments
// to commands.ErrNotFound so broadcast aggregation works.
func asNotFoundOrError(r *commands.RunResult, err error) error {
if err != nil {
return err
}
if r.ExitCode == 0 {
return nil
}
if matchesAny(r.Stderr, notFoundFragments) {
return commands.ErrNotFound
}
return &runError{msg: strings.TrimSpace(r.Stderr)}
}
func matchesAny(haystack string, fragments []string) bool {
for _, f := range fragments {
if strings.Contains(haystack, f) {
return true
}
}
return false
}
func itoa(i int) string {
// avoid strconv import for a one-shot; small ints only
if i == 0 {
return "0"
}
neg := false
if i < 0 {
neg = true
i = -i
}
var b [20]byte
n := len(b)
for i > 0 {
n--
b[n] = byte('0' + i%10)
i /= 10
}
if neg {
n--
b[n] = '-'
}
return string(b[n:])
}
@@ -1,236 +0,0 @@
package services
import (
"bufio"
"context"
"fmt"
"os"
"strconv"
"strings"
"syscall"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
func init() { Register("host", buildHost) }
// hostProcRoot is where the host-agent container mounts /proc. If we're not
// running as host-agent, falling back to /proc still produces sensible numbers
// (the container's own view) so dashboards don't blank out in unit tests.
var hostProcRoot = "/host/proc"
func resolveProc(p string) string {
if _, err := os.Stat(hostProcRoot); err == nil {
return hostProcRoot + p
}
return "/proc" + p
}
func buildHost(_ *proc.Supervisor) *commands.Table {
t := commands.New("host")
// No lifecycle — the host-agent container has no main process to manage.
t.Register("exec.df", func(ctx context.Context, args map[string]any) (any, error) {
path := commands.ArgStringOpt(args, "path", "/")
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
return nil, fmt.Errorf("statfs %s: %w", path, err)
}
size := int64(stat.Blocks) * int64(stat.Bsize)
free := int64(stat.Bavail) * int64(stat.Bsize)
used := size - free
return map[string]any{
"path": path,
"size": size,
"used": used,
"available": free,
}, nil
})
t.Register("exec.host-stats", func(ctx context.Context, _ map[string]any) (any, error) {
return readHostStats()
})
return t
}
func readHostStats() (map[string]any, error) {
out := map[string]any{
"system_time": time.Now().Format("2006-01-02 15:04:05"),
"architecture": readArchitecture(),
}
if uptime, err := readUptime(); err == nil {
out["uptime"] = int64(uptime)
} else {
out["uptime"] = int64(0)
}
cores := readCPUCores()
cpuUsage, _ := sampleHostCPU(500 * time.Millisecond)
out["cpu"] = map[string]any{
"cores": cores,
"usage": cpuUsage,
}
memTotal, memUsage := readMemoryTotalAndUsagePct()
out["memory"] = map[string]any{
"total": memTotal, // bytes
"usage": memUsage, // percent 0..100
}
return out, nil
}
// readArchitecture returns the host's machine architecture (e.g. "x86_64",
// "aarch64"). Falls back to a single dash if syscall.Uname fails.
func readArchitecture() string {
var u syscall.Utsname
if err := syscall.Uname(&u); err != nil {
return "-"
}
return charsToString(u.Machine[:])
}
func charsToString(b []int8) string {
out := make([]byte, 0, len(b))
for _, c := range b {
if c == 0 {
break
}
out = append(out, byte(c))
}
return string(out)
}
// readCPUCores counts `^processor` lines in /proc/cpuinfo. On a container
// with /host/proc bind-mounted this gives the host's logical CPU count,
// not the container's cgroup limits.
func readCPUCores() int {
f, err := os.Open(resolveProc("/cpuinfo"))
if err != nil {
return 0
}
defer f.Close()
n := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
if strings.HasPrefix(sc.Text(), "processor") {
n++
}
}
return n
}
// readMemoryTotalAndUsagePct reads /proc/meminfo and returns (total_bytes,
// usage_pct_0_100). "Usage" is computed as (Total - Available)/Total which
// matches what tools like `free` show as "used".
func readMemoryTotalAndUsagePct() (int64, int) {
f, err := os.Open(resolveProc("/meminfo"))
if err != nil {
return 0, 0
}
defer f.Close()
var total, available int64
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
switch fields[0] {
case "MemTotal:":
total = parseInt64(fields[1]) * 1024
case "MemAvailable:":
available = parseInt64(fields[1]) * 1024
}
}
if total <= 0 {
return 0, 0
}
used := total - available
if available <= 0 {
used = total
}
pct := int(float64(used) / float64(total) * 100.0)
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
return total, pct
}
func readUptime() (float64, error) {
b, err := os.ReadFile(resolveProc("/uptime"))
if err != nil {
return 0, err
}
fields := strings.Fields(string(b))
if len(fields) < 1 {
return 0, fmt.Errorf("malformed uptime")
}
return strconv.ParseFloat(fields[0], 64)
}
// sampleHostCPU returns CPU utilization (0..100) sampled over `window`.
func sampleHostCPU(window time.Duration) (float64, error) {
a, err := readCPULine()
if err != nil {
return 0, err
}
time.Sleep(window)
b, err := readCPULine()
if err != nil {
return 0, err
}
totalA, totalB := sum(a), sum(b)
idleA, idleB := a[3], b[3]
dTotal, dIdle := totalB-totalA, idleB-idleA
if dTotal == 0 {
return 0, nil
}
return 100.0 * float64(dTotal-dIdle) / float64(dTotal), nil
}
func readCPULine() ([]int64, error) {
f, err := os.Open(resolveProc("/stat"))
if err != nil {
return nil, err
}
defer f.Close()
sc := bufio.NewScanner(f)
if !sc.Scan() {
return nil, fmt.Errorf("empty /proc/stat")
}
fields := strings.Fields(sc.Text())
if len(fields) < 5 || fields[0] != "cpu" {
return nil, fmt.Errorf("unexpected /proc/stat first line")
}
out := make([]int64, 0, len(fields)-1)
for _, f := range fields[1:] {
n, err := strconv.ParseInt(f, 10, 64)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, nil
}
func sum(xs []int64) int64 {
var s int64
for _, x := range xs {
s += x
}
return s
}
func parseInt64(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
return n
}
@@ -1,39 +0,0 @@
package services
import (
"context"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
func init() { Register("nginx", buildNginx) }
func nginxHealthProbe(ctx context.Context) error {
if err := probeShell(ctx, 3*time.Second, "nginx", "-t"); err != nil {
return err
}
return probeTCP("127.0.0.1:8081", 2*time.Second)
}
func buildNginx(sup *proc.Supervisor) *commands.Table {
t := commands.New("nginx")
t.HealthProbe = nginxHealthProbe
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
r, err := commands.Run(ctx, commands.RunOptions{}, "nginx", "-s", "reload")
return nil, asError(r, err)
})
addLifecycleExceptReload(t, sup)
t.Register("exec.test-config", func(ctx context.Context, _ map[string]any) (any, error) {
r, err := commands.Run(ctx, commands.RunOptions{}, "nginx", "-t")
if err != nil {
return nil, err
}
return map[string]any{
"ok": r.ExitCode == 0,
"output": r.Stderr + r.Stdout,
}, nil
})
return t
}
@@ -1,142 +0,0 @@
package services
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
func init() { Register("postfix", buildPostfix) }
// notFoundFragments are substrings emitted by postsuper/postqueue when the
// requested queue id doesn't live on this node. Broadcast handlers map them
// to commands.ErrNotFound so the backend can count partial success.
var notFoundFragments = []string{
"No such file or directory",
"no such file",
"unknown",
}
func postfixHealthProbe(ctx context.Context) error {
if err := probeSMTPGreeting("127.0.0.1:25", 3*time.Second); err != nil {
return err
}
return probeShell(ctx, 5*time.Second, "postfix", "status")
}
func buildPostfix(sup *proc.Supervisor) *commands.Table {
t := commands.New("postfix")
t.HealthProbe = postfixHealthProbe
// Override generic reload — `postfix reload` is the canonical operation,
// not SIGHUP-to-supervisord (which would just rotate logs).
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
r, err := commands.Run(ctx, commands.RunOptions{}, "postfix", "reload")
return nil, asError(r, err)
})
// Lifecycle: stop/start/restart still go through the supervisor.
if sup != nil {
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
return nil, sup.Restart(ctx)
})
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
return nil, sup.Stop(ctx)
})
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
return nil, sup.Start()
})
}
t.Register("exec.mailq", func(ctx context.Context, _ map[string]any) (any, error) {
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 8 << 20}, "postqueue", "-j")
if err != nil {
return nil, err
}
if r.ExitCode != 0 {
return nil, &runError{msg: "postqueue failed: " + r.Stderr}
}
// postqueue -j prints one JSON object per line.
entries := make([]map[string]any, 0)
for _, line := range strings.Split(strings.TrimSpace(r.Stdout), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var obj map[string]any
if err := json.Unmarshal([]byte(line), &obj); err == nil {
entries = append(entries, obj)
}
}
return map[string]any{"queue": entries}, nil
})
t.Register("exec.flush-queue", func(ctx context.Context, _ map[string]any) (any, error) {
r, err := commands.Run(ctx, commands.RunOptions{}, "postqueue", "-f")
return nil, asError(r, err)
})
t.Register("exec.delete-from-queue", func(ctx context.Context, args map[string]any) (any, error) {
qid, err := commands.ArgString(args, "queue_id")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-d", qid)
return nil, asNotFoundOrError(r, err)
})
t.Register("exec.hold-queue", func(ctx context.Context, args map[string]any) (any, error) {
qid, err := commands.ArgString(args, "queue_id")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-h", qid)
return nil, asNotFoundOrError(r, err)
})
t.Register("exec.unhold-queue", func(ctx context.Context, args map[string]any) (any, error) {
qid, err := commands.ArgString(args, "queue_id")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-H", qid)
return nil, asNotFoundOrError(r, err)
})
t.Register("exec.deliver-now", func(ctx context.Context, args map[string]any) (any, error) {
qid, err := commands.ArgString(args, "queue_id")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{}, "postqueue", "-i", qid)
return nil, asNotFoundOrError(r, err)
})
t.Register("exec.cat-queue", func(ctx context.Context, args map[string]any) (any, error) {
qid, err := commands.ArgString(args, "queue_id")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 2 << 20}, "postcat", "-q", qid)
if err != nil {
return nil, err
}
if r.ExitCode != 0 {
if matchesAny(r.Stderr, notFoundFragments) {
return nil, commands.ErrNotFound
}
return nil, &runError{msg: "postcat failed: " + r.Stderr}
}
return map[string]any{"body": r.Stdout}, nil
})
t.Register("exec.super-delete", func(ctx context.Context, _ map[string]any) (any, error) {
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-d", "ALL")
return nil, asError(r, err)
})
return t
}
@@ -1,81 +0,0 @@
package services
import (
"bufio"
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
)
// probeTCP opens a TCP connection to addr within timeout. Returns nil if the
// port accepts a connection, otherwise the dial error.
func probeTCP(addr string, timeout time.Duration) error {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return err
}
_ = conn.Close()
return nil
}
// probeSMTPGreeting connects to addr and reads the SMTP greeting line. The
// service is considered healthy if the line starts with "220".
func probeSMTPGreeting(addr string, timeout time.Duration) error {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return err
}
defer conn.Close()
_ = conn.SetReadDeadline(time.Now().Add(timeout))
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
return fmt.Errorf("read greeting: %w", err)
}
if !strings.HasPrefix(line, "220") {
return fmt.Errorf("unexpected greeting: %s", strings.TrimSpace(line))
}
return nil
}
// probeHTTP issues a GET to url, checks for a 2xx status.
func probeHTTP(ctx context.Context, url string, timeout time.Duration) error {
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http %s", resp.Status)
}
return nil
}
// probeShell runs argv with a timeout and returns nil if exit code is 0.
func probeShell(ctx context.Context, timeout time.Duration, argv ...string) error {
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
r, err := commands.Run(cctx, commands.RunOptions{}, argv...)
if err != nil {
return err
}
if r.ExitCode != 0 {
msg := strings.TrimSpace(r.Stderr)
if msg == "" {
msg = fmt.Sprintf("exit %d", r.ExitCode)
}
return errors.New(msg)
}
return nil
}
@@ -1,86 +0,0 @@
package services
import (
"context"
"os"
"path/filepath"
"strings"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
func init() { Register("rspamd", buildRspamd) }
func rspamdHealthProbe(ctx context.Context) error {
return probeHTTP(ctx, "http://127.0.0.1:11334/ping", 3*time.Second)
}
// Override file rspamd reads on startup for the controller's enable_password.
const rspamdWorkerPasswordPath = "/etc/rspamd/override.d/worker-controller-password.inc"
func buildRspamd(sup *proc.Supervisor) *commands.Table {
t := commands.New("rspamd")
t.HealthProbe = rspamdHealthProbe
addLifecycle(t, sup)
t.Register("exec.set-worker-password", func(ctx context.Context, args map[string]any) (any, error) {
password, err := commands.ArgString(args, "password")
if err != nil {
return nil, err
}
// rspamadm pw -e -p <pw> writes the hashed value to stdout.
r, err := commands.Run(ctx, commands.RunOptions{}, "rspamadm", "pw", "-e", "-p", password)
if err != nil {
return nil, err
}
if r.ExitCode != 0 {
return nil, &runError{msg: "rspamadm pw failed: " + strings.TrimSpace(r.Stderr)}
}
hash := strings.TrimSpace(r.Stdout)
// rspamd distinguishes `password` (read-only access to the controller)
// from `enable_password` (write access — restart, settings, learn).
content := "enable_password = \"" + hash + "\";\n"
if err := os.MkdirAll(filepath.Dir(rspamdWorkerPasswordPath), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(rspamdWorkerPasswordPath, []byte(content), 0o644); err != nil {
return nil, err
}
// Must do a full re-fork of workers (SIGHUP to rspamd master), not
// `rspamadm control reload`
if sup != nil {
return nil, sup.Reload()
}
return nil, nil
})
t.Register("exec.relearn-spam", func(ctx context.Context, args map[string]any) (any, error) {
path, err := commands.ArgString(args, "file")
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{Stdin: data}, "rspamc", "learn_spam")
return nil, asError(r, err)
})
t.Register("exec.relearn-ham", func(ctx context.Context, args map[string]any) (any, error) {
path, err := commands.ArgString(args, "file")
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{Stdin: data}, "rspamc", "learn_ham")
return nil, asError(r, err)
})
return t
}
@@ -1,87 +0,0 @@
// Package services registers per-service command tables. The agent selects
// the right table at startup via MAILCOW_AGENT_SERVICE.
//
// A service "builder" receives a Supervisor for lifecycle commands; services
// that don't supervise a main process (currently just "host") pass nil and
// the generic lifecycle commands are skipped.
package services
import (
"context"
"fmt"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
// Builder constructs a command table for a service. sup may be nil for
// services without a supervised main process.
type Builder func(sup *proc.Supervisor) *commands.Table
var registry = map[string]Builder{}
// Register installs a builder for a service name. Called from init() in each
// per-service file.
func Register(service string, b Builder) {
if _, dup := registry[service]; dup {
panic("services: duplicate registration for " + service)
}
registry[service] = b
}
// Build returns the table for service, or an error if no builder exists.
func Build(service string, sup *proc.Supervisor) (*commands.Table, error) {
b, ok := registry[service]
if !ok {
return nil, fmt.Errorf("services: unknown service %q (set MAILCOW_AGENT_SERVICE correctly)", service)
}
return b(sup), nil
}
// Known returns the list of registered service names (sorted-ish, depends on
// map iteration — for help output only).
func Known() []string {
out := make([]string, 0, len(registry))
for k := range registry {
out = append(out, k)
}
return out
}
// restartSettle is how long we wait after a Start to verify the new child
// didn't immediately crash. Gives the operator real "did the service come
// back up?" feedback instead of an instant OK that hides flapping services.
const restartSettle = 3 * time.Second
// addLifecycle wires reload/restart/stop/start onto t backed by sup. Services
// override these (e.g. postfix overrides reload to run `postfix reload`).
func addLifecycle(t *commands.Table, sup *proc.Supervisor) {
if sup == nil {
return
}
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
return nil, sup.Reload()
})
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
if err := sup.Restart(ctx); err != nil {
return nil, err
}
if err := sup.WaitStable(ctx, restartSettle); err != nil {
return nil, err
}
return map[string]any{"status": "restarted", "settled_ms": int(restartSettle / time.Millisecond)}, nil
})
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
return nil, sup.Stop(ctx)
})
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
if err := sup.Start(); err != nil {
return nil, err
}
if err := sup.WaitStable(ctx, restartSettle); err != nil {
return nil, err
}
return map[string]any{"status": "started", "settled_ms": int(restartSettle / time.Millisecond)}, nil
})
}
@@ -1,36 +0,0 @@
package services
import (
"context"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
func init() { Register("sogo", buildSogo) }
func sogoHealthProbe(ctx context.Context) error {
return probeHTTP(ctx, "http://127.0.0.1:20000/SOGo.index/", 3*time.Second)
}
func buildSogo(sup *proc.Supervisor) *commands.Table {
t := commands.New("sogo")
t.HealthProbe = sogoHealthProbe
addLifecycle(t, sup)
t.Register("exec.rename-user", func(ctx context.Context, args map[string]any) (any, error) {
oldName, err := commands.ArgString(args, "old")
if err != nil {
return nil, err
}
newName, err := commands.ArgString(args, "new")
if err != nil {
return nil, err
}
r, err := commands.Run(ctx, commands.RunOptions{}, "sogo-tool", "rename-user", oldName, newName)
return nil, asError(r, err)
})
return t
}
@@ -1,26 +0,0 @@
package services
import (
"context"
"time"
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
)
func init() { Register("unbound", buildUnbound) }
func unboundHealthProbe(ctx context.Context) error {
return probeShell(ctx, 3*time.Second, "dig", "+time=2", "+tries=1", "@127.0.0.1", "mailcow.email", "A")
}
func buildUnbound(sup *proc.Supervisor) *commands.Table {
t := commands.New("unbound")
t.HealthProbe = unboundHealthProbe
addLifecycle(t, sup)
t.Register("exec.flush-cache", func(ctx context.Context, _ map[string]any) (any, error) {
r, err := commands.Run(ctx, commands.RunOptions{}, "unbound-control", "flush_zone", ".")
return nil, asError(r, err)
})
return t
}
@@ -1,155 +0,0 @@
// Package stats reads cgroup CPU + memory usage and publishes them to
//
// HASH mailcow.stats.<service>.<node_id>
//
// with a 30s TTL. Supports both cgroup v1 and v2. The numbers are intentionally
// approximate — they replace what dockerapi exposed via /containers/<id>/stats.
package stats
import (
"context"
"os"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// Sample is one observation. CPUPercent is the share of one host CPU consumed
// since the previous sample (range 0..100*numCPU).
type Sample struct {
CPUPercent float64
MemoryBytes int64
MemoryLimit int64
Timestamp time.Time
}
func statsKey(service, node string) string { return "mailcow.stats." + service + "." + node }
// Publisher reads cgroup metrics and pushes them to Redis on a ticker.
type Publisher struct {
rdb *redis.Client
service string
node string
// previous CPU sample to derive a delta-based percent
prevCPUNanos int64
prevAt time.Time
}
// NewPublisher constructs a publisher. Caller drives it via Run.
func NewPublisher(rdb *redis.Client, service, node string) *Publisher {
return &Publisher{rdb: rdb, service: service, node: node}
}
// Run blocks on a ticker until ctx is done.
func (p *Publisher) Run(ctx context.Context, interval time.Duration) {
t := time.NewTicker(interval)
defer t.Stop()
// Prime the CPU sample so the first publish has a real delta.
if cpu, ok := readCPUNanos(); ok {
p.prevCPUNanos = cpu
p.prevAt = time.Now()
}
// Immediate first publish so the dashboard never sees a node without a
// stats hash. CPU is 0 in this first sample (no prev delta yet); memory
// is already accurate.
_ = p.publish(ctx, p.sample())
for {
select {
case <-ctx.Done():
return
case <-t.C:
_ = p.publish(ctx, p.sample())
}
}
}
func (p *Publisher) sample() Sample {
s := Sample{Timestamp: time.Now()}
if cpu, ok := readCPUNanos(); ok {
if !p.prevAt.IsZero() {
dCPU := cpu - p.prevCPUNanos
dT := s.Timestamp.Sub(p.prevAt).Nanoseconds()
if dT > 0 && dCPU >= 0 {
s.CPUPercent = (float64(dCPU) / float64(dT)) * 100.0
}
}
p.prevCPUNanos = cpu
p.prevAt = s.Timestamp
}
if mem, limit, ok := readMemory(); ok {
s.MemoryBytes = mem
s.MemoryLimit = limit
}
return s
}
func (p *Publisher) publish(ctx context.Context, s Sample) error {
pipe := p.rdb.Pipeline()
pipe.HSet(ctx, statsKey(p.service, p.node), map[string]any{
"cpu_percent": strconv.FormatFloat(s.CPUPercent, 'f', 2, 64),
"memory_bytes": s.MemoryBytes,
"memory_limit": s.MemoryLimit,
"timestamp": s.Timestamp.Unix(),
"node_id": p.node,
"service": p.service,
})
pipe.Expire(ctx, statsKey(p.service, p.node), 30*time.Second)
_, err := pipe.Exec(ctx)
return err
}
// --- cgroup readers --------------------------------------------------------
// readCPUNanos returns total CPU-nanoseconds consumed by the current cgroup,
// summed across all CPUs. Works for both cgroup v2 (cpu.stat) and v1
// (cpuacct.usage).
func readCPUNanos() (int64, bool) {
if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil {
// v2: lines like "usage_usec 12345"
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "usage_usec ") {
n, err := strconv.ParseInt(strings.TrimPrefix(line, "usage_usec "), 10, 64)
if err == nil {
return n * 1000, true // µs → ns
}
}
}
}
if data, err := os.ReadFile("/sys/fs/cgroup/cpuacct/cpuacct.usage"); err == nil {
n, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
if err == nil {
return n, true
}
}
return 0, false
}
// readMemory returns current usage and limit in bytes.
func readMemory() (int64, int64, bool) {
// v2
if cur, err := readInt("/sys/fs/cgroup/memory.current"); err == nil {
limit, _ := readInt("/sys/fs/cgroup/memory.max")
return cur, limit, true
}
// v1
if cur, err := readInt("/sys/fs/cgroup/memory/memory.usage_in_bytes"); err == nil {
limit, _ := readInt("/sys/fs/cgroup/memory/memory.limit_in_bytes")
return cur, limit, true
}
return 0, 0, false
}
func readInt(path string) (int64, error) {
b, err := os.ReadFile(path)
if err != nil {
return 0, err
}
s := strings.TrimSpace(string(b))
if s == "max" {
return -1, nil
}
return strconv.ParseInt(s, 10, 64)
}
-58
View File
@@ -1,58 +0,0 @@
#!/bin/sh
# mailcow-agent-cli — publish a control-bus command from inside a service
# container, optionally collecting one reply. Same wire protocol as the Go
# agent (see internal/envelope/envelope.go).
#
# Usage:
# mailcow-agent-cli send <service> <cmd> [json-args]
# Fire-and-forget. Prints the number of subscribers reached.
# mailcow-agent-cli call <service> <cmd> [json-args] [timeout-seconds]
# Publish + wait for one reply on its private reply list. Prints the
# reply envelope JSON on stdout.
#
# Requires the `redis-cli` binary to be present in the calling container.
set -e
op="${1:-}"
svc="${2:-}"
cmd="${3:-}"
args="${4:-{\}}"
tmo="${5:-10}"
if [ -z "$op" ] || [ -z "$svc" ] || [ -z "$cmd" ]; then
echo "usage: $0 send|call <service> <cmd> [json-args] [timeout-seconds]" >&2
exit 2
fi
redis_host="${REDIS_SLAVEOF_IP:-redis-mailcow}"
redis_port="${REDIS_SLAVEOF_PORT:-6379}"
rcli() {
if [ -n "${REDISPASS:-}" ]; then
redis-cli -h "$redis_host" -p "$redis_port" -a "$REDISPASS" --no-auth-warning "$@"
else
redis-cli -h "$redis_host" -p "$redis_port" "$@"
fi
}
rid="$(date +%s%N)$$"
issued_by="$(hostname 2>/dev/null || echo unknown)"
case "$op" in
send)
payload="{\"cmd\":\"${cmd}\",\"request_id\":\"${rid}\",\"args\":${args},\"issued_by\":\"${issued_by}\"}"
rcli PUBLISH "mailcow.control.${svc}" "$payload"
;;
call)
reply="mailcow.reply.${rid}"
payload="{\"cmd\":\"${cmd}\",\"request_id\":\"${rid}\",\"args\":${args},\"reply_to\":\"${reply}\",\"issued_by\":\"${issued_by}\"}"
rcli PUBLISH "mailcow.control.${svc}" "$payload" >/dev/null
# BLPOP returns two lines: the list name then the value. Print only the value.
rcli BLPOP "$reply" "$tmo" 2>/dev/null | tail -n1
;;
*)
echo "usage: $0 send|call <service> <cmd> [json-args] [timeout-seconds]" >&2
exit 2
;;
esac
+3 -15
View File
@@ -1,7 +1,3 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM alpine:3.21 AS builder FROM alpine:3.21 AS builder
WORKDIR /src WORKDIR /src
@@ -108,15 +104,7 @@ COPY healthcheck.sh /healthcheck.sh
COPY clamdcheck.sh /usr/local/bin COPY clamdcheck.sh /usr/local/bin
RUN chmod +x /healthcheck.sh RUN chmod +x /healthcheck.sh
RUN chmod +x /usr/local/bin/clamdcheck.sh RUN chmod +x /usr/local/bin/clamdcheck.sh
HEALTHCHECK --start-period=6m CMD "/healthcheck.sh"
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent ENTRYPOINT []
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli CMD ["/sbin/tini", "-g", "--", "/clamd.sh"]
ENV MAILCOW_AGENT_SERVICE=clamd \
MAILCOW_AGENT_MAIN_CMD="/sbin/tini -g -- /clamd.sh"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+27
View File
@@ -0,0 +1,27 @@
FROM alpine:3.21
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
ARG PIP_BREAK_SYSTEM_PACKAGES=1
WORKDIR /app
RUN apk add --update --no-cache python3 \
py3-pip \
openssl \
tzdata \
py3-psutil \
py3-redis \
py3-async-timeout \
&& pip3 install --upgrade pip \
fastapi \
uvicorn \
aiodocker \
docker
RUN mkdir /app/modules
COPY docker-entrypoint.sh /app/
COPY main.py /app/main.py
COPY modules/ /app/modules/
ENTRYPOINT ["/bin/sh", "/app/docker-entrypoint.sh"]
CMD ["python", "main.py"]
+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
`openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout /app/dockerapi_key.pem \
-out /app/dockerapi_cert.pem \
-subj /CN=dockerapi/O=mailcow \
-addext subjectAltName=DNS:dockerapi`
exec "$@"
+261
View File
@@ -0,0 +1,261 @@
import os
import sys
import uvicorn
import json
import uuid
import async_timeout
import asyncio
import aiodocker
import docker
import logging
from logging.config import dictConfig
from fastapi import FastAPI, Response, Request
from modules.DockerApi import DockerApi
from redis import asyncio as aioredis
from contextlib import asynccontextmanager
dockerapi = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global dockerapi
# Initialize a custom logger
logger = logging.getLogger("dockerapi")
logger.setLevel(logging.INFO)
# Configure the logger to output logs to the terminal
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
formatter = logging.Formatter("%(levelname)s: %(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.info("Init APP")
# Init redis client
if os.environ['REDIS_SLAVEOF_IP'] != "":
redis_client = redis = await aioredis.from_url(f"redis://{os.environ['REDIS_SLAVEOF_IP']}:{os.environ['REDIS_SLAVEOF_PORT']}/0", password=os.environ['REDISPASS'])
else:
redis_client = redis = await aioredis.from_url("redis://redis-mailcow:6379/0", password=os.environ['REDISPASS'])
# Init docker clients
sync_docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
async_docker_client = aiodocker.Docker(url='unix:///var/run/docker.sock')
dockerapi = DockerApi(redis_client, sync_docker_client, async_docker_client, logger)
logger.info("Subscribe to redis channel")
# Subscribe to redis channel
dockerapi.pubsub = redis.pubsub()
await dockerapi.pubsub.subscribe("MC_CHANNEL")
asyncio.create_task(handle_pubsub_messages(dockerapi.pubsub))
yield
# Close docker connections
dockerapi.sync_docker_client.close()
await dockerapi.async_docker_client.close()
# Close redis
await dockerapi.pubsub.unsubscribe("MC_CHANNEL")
await dockerapi.redis_client.close()
app = FastAPI(lifespan=lifespan)
# Define Routes
@app.get("/host/stats")
async def get_host_update_stats():
global dockerapi
if dockerapi.host_stats_isUpdating == False:
asyncio.create_task(dockerapi.get_host_stats())
dockerapi.host_stats_isUpdating = True
while True:
if await dockerapi.redis_client.exists('host_stats'):
break
await asyncio.sleep(1.5)
stats = json.loads(await dockerapi.redis_client.get('host_stats'))
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
@app.get("/containers/{container_id}/json")
async def get_container(container_id : str):
global dockerapi
if container_id and container_id.isalnum():
try:
for container in (await dockerapi.async_docker_client.containers.list()):
if container._id == container_id:
container_info = await container.show()
return Response(content=json.dumps(container_info, indent=4), media_type="application/json")
res = {
"type": "danger",
"msg": "no container found"
}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
except Exception as e:
res = {
"type": "danger",
"msg": str(e)
}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = {
"type": "danger",
"msg": "no or invalid id defined"
}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
@app.get("/containers/json")
async def get_containers():
global dockerapi
containers = {}
try:
for container in (await dockerapi.async_docker_client.containers.list()):
container_info = await container.show()
containers.update({container_info['Id']: container_info})
return Response(content=json.dumps(containers, indent=4), media_type="application/json")
except Exception as e:
res = {
"type": "danger",
"msg": str(e)
}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
@app.post("/containers/{container_id}/{post_action}")
async def post_containers(container_id : str, post_action : str, request: Request):
global dockerapi
try:
request_json = await request.json()
except Exception as err:
request_json = {}
if container_id and container_id.isalnum() and post_action:
try:
"""Dispatch container_post api call"""
if post_action == 'exec':
if not request_json or not 'cmd' in request_json:
res = {
"type": "danger",
"msg": "cmd is missing"
}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
if not request_json or not 'task' in request_json:
res = {
"type": "danger",
"msg": "task is missing"
}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
api_call_method_name = '__'.join(['container_post', str(post_action), str(request_json['cmd']), str(request_json['task']) ])
else:
api_call_method_name = '__'.join(['container_post', str(post_action) ])
api_call_method = getattr(dockerapi, api_call_method_name, lambda container_id: Response(content=json.dumps({'type': 'danger', 'msg':'container_post - unknown api call' }, indent=4), media_type="application/json"))
dockerapi.logger.info("api call: %s, container_id: %s" % (api_call_method_name, container_id))
return api_call_method(request_json, container_id=container_id)
except Exception as e:
dockerapi.logger.error("error - container_post: %s" % str(e))
res = {
"type": "danger",
"msg": str(e)
}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = {
"type": "danger",
"msg": "invalid container id or missing action"
}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
@app.post("/container/{container_id}/stats/update")
async def post_container_update_stats(container_id : str):
global dockerapi
# start update task for container if no task is running
if container_id not in dockerapi.containerIds_to_update:
asyncio.create_task(dockerapi.get_container_stats(container_id))
dockerapi.containerIds_to_update.append(container_id)
while True:
if await dockerapi.redis_client.exists(container_id + '_stats'):
break
await asyncio.sleep(1.5)
stats = json.loads(await dockerapi.redis_client.get(container_id + '_stats'))
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
# PubSub Handler
async def handle_pubsub_messages(channel: aioredis.client.PubSub):
global dockerapi
while True:
try:
async with async_timeout.timeout(60):
message = await channel.get_message(ignore_subscribe_messages=True, timeout=30)
if message is not None:
# Parse message
data_json = json.loads(message['data'].decode('utf-8'))
dockerapi.logger.info(f"PubSub Received - {json.dumps(data_json)}")
# Handle api_call
if 'api_call' in data_json:
# api_call: container_post
if data_json['api_call'] == "container_post":
if 'post_action' in data_json and 'container_name' in data_json:
try:
"""Dispatch container_post api call"""
request_json = {}
if data_json['post_action'] == 'exec':
if 'request' in data_json:
request_json = data_json['request']
if 'cmd' in request_json:
if 'task' in request_json:
api_call_method_name = '__'.join(['container_post', str(data_json['post_action']), str(request_json['cmd']), str(request_json['task']) ])
else:
dockerapi.logger.error("api call: task missing")
else:
dockerapi.logger.error("api call: cmd missing")
else:
dockerapi.logger.error("api call: request missing")
else:
api_call_method_name = '__'.join(['container_post', str(data_json['post_action'])])
if api_call_method_name:
api_call_method = getattr(dockerapi, api_call_method_name)
if api_call_method:
dockerapi.logger.info("api call: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
api_call_method(request_json, container_name=data_json['container_name'])
else:
dockerapi.logger.error("api call not found: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
except Exception as e:
dockerapi.logger.error("container_post: %s" % str(e))
else:
dockerapi.logger.error("api call: missing container_name, post_action or request")
else:
dockerapi.logger.error("Unknown PubSub received - %s" % json.dumps(data_json))
else:
dockerapi.logger.error("Unknown PubSub received - %s" % json.dumps(data_json))
await asyncio.sleep(0.0)
except asyncio.TimeoutError:
pass
if __name__ == '__main__':
uvicorn.run(
app,
host="0.0.0.0",
port=443,
ssl_certfile="/app/dockerapi_cert.pem",
ssl_keyfile="/app/dockerapi_key.pem",
log_level="info",
loop="none"
)
@@ -0,0 +1,626 @@
import psutil
import sys
import os
import re
import time
import json
import asyncio
import platform
from datetime import datetime
from fastapi import FastAPI, Response, Request
class DockerApi:
def __init__(self, redis_client, sync_docker_client, async_docker_client, logger):
self.redis_client = redis_client
self.sync_docker_client = sync_docker_client
self.async_docker_client = async_docker_client
self.logger = logger
self.host_stats_isUpdating = False
self.containerIds_to_update = []
# api call: container_post - post_action: stop
def container_post__stop(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
container.stop()
res = { 'type': 'success', 'msg': 'command completed successfully'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: start
def container_post__start(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
container.start()
res = { 'type': 'success', 'msg': 'command completed successfully'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: restart
def container_post__restart(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
container.restart()
res = { 'type': 'success', 'msg': 'command completed successfully'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: top
def container_post__top(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
res = { 'type': 'success', 'msg': container.top()}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: stats
def container_post__stats(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
for stat in container.stats(decode=True, stream=True):
res = { 'type': 'success', 'msg': stat}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: mailq - task: delete
def container_post__exec__mailq__delete(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
flagged_qids = ['-d %s' % i for i in filtered_qids]
sanitized_string = str(' '.join(flagged_qids))
for container in self.sync_docker_client.containers.list(filters=filters):
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
return self.exec_run_handler('generic', postsuper_r)
# api call: container_post - post_action: exec - cmd: mailq - task: hold
def container_post__exec__mailq__hold(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
flagged_qids = ['-h %s' % i for i in filtered_qids]
sanitized_string = str(' '.join(flagged_qids))
for container in self.sync_docker_client.containers.list(filters=filters):
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
return self.exec_run_handler('generic', postsuper_r)
# api call: container_post - post_action: exec - cmd: mailq - task: cat
def container_post__exec__mailq__cat(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
sanitized_string = str(' '.join(filtered_qids))
for container in self.sync_docker_client.containers.list(filters=filters):
postcat_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postcat -q " + sanitized_string], user='postfix')
if not postcat_return:
postcat_return = 'err: invalid'
return self.exec_run_handler('utf8_text_only', postcat_return)
# api call: container_post - post_action: exec - cmd: mailq - task: unhold
def container_post__exec__mailq__unhold(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
flagged_qids = ['-H %s' % i for i in filtered_qids]
sanitized_string = str(' '.join(flagged_qids))
for container in self.sync_docker_client.containers.list(filters=filters):
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
return self.exec_run_handler('generic', postsuper_r)
# api call: container_post - post_action: exec - cmd: mailq - task: deliver
def container_post__exec__mailq__deliver(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
flagged_qids = ['-i %s' % i for i in filtered_qids]
for container in self.sync_docker_client.containers.list(filters=filters):
for i in flagged_qids:
postqueue_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postqueue " + i], user='postfix')
# todo: check each exit code
res = { 'type': 'success', 'msg': 'Scheduled immediate delivery'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: mailq - task: list
def container_post__exec__mailq__list(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
mailq_return = container.exec_run(["/usr/sbin/postqueue", "-j"], user='postfix')
return self.exec_run_handler('utf8_text_only', mailq_return)
# api call: container_post - post_action: exec - cmd: mailq - task: flush
def container_post__exec__mailq__flush(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
postqueue_r = container.exec_run(["/usr/sbin/postqueue", "-f"], user='postfix')
return self.exec_run_handler('generic', postqueue_r)
# api call: container_post - post_action: exec - cmd: mailq - task: super_delete
def container_post__exec__mailq__super_delete(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
postsuper_r = container.exec_run(["/usr/sbin/postsuper", "-d", "ALL"])
return self.exec_run_handler('generic', postsuper_r)
# api call: container_post - post_action: exec - cmd: system - task: fts_rescan
def container_post__exec__system__fts_rescan(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'username' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -u '" + request_json['username'].replace("'", "'\\''") + "'"], user='vmail')
if rescan_return.exit_code == 0:
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
if 'all' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -A"], user='vmail')
if rescan_return.exit_code == 0:
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: system - task: df
def container_post__exec__system__df(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'dir' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
df_return = container.exec_run(["/bin/bash", "-c", "/bin/df -H '" + request_json['dir'].replace("'", "'\\''") + "' | /usr/bin/tail -n1 | /usr/bin/tr -s [:blank:] | /usr/bin/tr ' ' ','"], user='nobody')
if df_return.exit_code == 0:
return df_return.output.decode('utf-8').rstrip()
else:
return "0,0,0,0,0,0"
# api call: container_post - post_action: exec - cmd: system - task: mysql_upgrade
def container_post__exec__system__mysql_upgrade(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_upgrade -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "'\n"], user='mysql')
if sql_return.exit_code == 0:
matched = False
for line in sql_return.output.decode('utf-8').split("\n"):
if 'is already upgraded to' in line:
matched = True
if matched:
res = { 'type': 'success', 'msg':'mysql_upgrade: already upgraded', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
container.restart()
res = { 'type': 'warning', 'msg':'mysql_upgrade: upgrade was applied', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'error', 'msg': 'mysql_upgrade: error running command', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
def container_post__exec__system__mysql_tzinfo_to_sql(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_tzinfo_to_sql /usr/share/zoneinfo | /bin/sed 's/Local time zone must be set--see zic manual page/FCTY/' | /usr/bin/mysql -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "' mysql \n"], user='mysql')
if sql_return.exit_code == 0:
res = { 'type': 'info', 'msg': 'mysql_tzinfo_to_sql: command completed successfully', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'error', 'msg': 'mysql_tzinfo_to_sql: error running command', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: reload - task: dovecot
def container_post__exec__reload__dovecot(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
return self.exec_run_handler('generic', reload_return)
# api call: container_post - post_action: exec - cmd: reload - task: postfix
def container_post__exec__reload__postfix(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
return self.exec_run_handler('generic', reload_return)
# api call: container_post - post_action: exec - cmd: reload - task: nginx
def container_post__exec__reload__nginx(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
return self.exec_run_handler('generic', reload_return)
# api call: container_post - post_action: exec - cmd: sieve - task: list
def container_post__exec__sieve__list(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'username' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request_json['username'].replace("'", "'\\''") + "'"])
return self.exec_run_handler('utf8_text_only', sieve_return)
# api call: container_post - post_action: exec - cmd: sieve - task: print
def container_post__exec__sieve__print(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'username' in request_json and 'script_name' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]
sieve_return = container.exec_run(cmd)
return self.exec_run_handler('utf8_text_only', sieve_return)
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
def container_post__exec__maildir__cleanup(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'maildir' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
sane_name = re.sub(r'\W+', '', request_json['maildir'])
vmail_name = request_json['maildir'].replace("'", "'\\''")
cmd_vmail = "if [[ -d '/var/vmail/" + vmail_name + "' ]]; then /bin/mv '/var/vmail/" + vmail_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "'; fi"
index_name = request_json['maildir'].split("/")
if len(index_name) > 1:
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
cmd_vmail_index = "if [[ -d '/var/vmail_index/" + index_name + "' ]]; then /bin/mv '/var/vmail_index/" + index_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "_index'; fi"
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
else:
cmd = ["/bin/bash", "-c", cmd_vmail]
maildir_cleanup = container.exec_run(cmd, user='vmail')
return self.exec_run_handler('generic', maildir_cleanup)
# api call: container_post - post_action: exec - cmd: maildir - task: move
def container_post__exec__maildir__move(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'old_maildir' in request_json and 'new_maildir' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
vmail_name = request_json['old_maildir'].replace("'", "'\\''")
new_vmail_name = request_json['new_maildir'].replace("'", "'\\''")
cmd_vmail = f"if [[ -d '/var/vmail/{vmail_name}' ]]; then /bin/mv '/var/vmail/{vmail_name}' '/var/vmail/{new_vmail_name}'; fi"
index_name = request_json['old_maildir'].split("/")
new_index_name = request_json['new_maildir'].split("/")
if len(index_name) > 1 and len(new_index_name) > 1:
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
new_index_name = new_index_name[1].replace("'", "'\\''") + "@" + new_index_name[0].replace("'", "'\\''")
cmd_vmail_index = f"if [[ -d '/var/vmail_index/{index_name}' ]]; then /bin/mv '/var/vmail_index/{index_name}' '/var/vmail_index/{new_index_name}_index'; fi"
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
else:
cmd = ["/bin/bash", "-c", cmd_vmail]
maildir_move = container.exec_run(cmd, user='vmail')
return self.exec_run_handler('generic', maildir_move)
# api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
def container_post__exec__rspamd__worker_password(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'raw' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
cmd = "/usr/bin/rspamadm pw -e -p '" + request_json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
matched = False
for line in cmd_response.split("\n"):
if '$2$' in line:
hash = line.strip()
hash_out = re.search(r'\$2\$.+$', hash).group(0)
rspamd_passphrase_hash = re.sub(r'[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
rspamd_password_filename = "/etc/rspamd/override.d/worker-controller-password.inc"
cmd = '''/bin/echo 'enable_password = "%s";' > %s && cat %s''' % (rspamd_passphrase_hash, rspamd_password_filename, rspamd_password_filename)
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
container.restart()
matched = True
if matched:
res = { 'type': 'success', 'msg': 'command completed successfully' }
self.logger.info('success changing Rspamd password')
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
self.logger.error('failed changing Rspamd password')
res = { 'type': 'danger', 'msg': 'command did not complete' }
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: sogo - task: rename
def container_post__exec__sogo__rename_user(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'old_username' in request_json and 'new_username' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
old_username = request_json['old_username'].replace("'", "'\\''")
new_username = request_json['new_username'].replace("'", "'\\''")
sogo_return = container.exec_run(["/bin/bash", "-c", f"sogo-tool rename-user '{old_username}' '{new_username}'"], user='sogo')
return self.exec_run_handler('generic', sogo_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: get_acl
def container_post__exec__doveadm__get_acl(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
id = request_json['id'].replace("'", "'\\''")
shared_folders = container.exec_run(["/bin/bash", "-c", f"doveadm mailbox list -u '{id}'"])
shared_folders = shared_folders.output.decode('utf-8')
shared_folders = shared_folders.splitlines()
formatted_acls = []
mailbox_seen = []
for shared_folder in shared_folders:
if "Shared" not in shared_folder:
mailbox = shared_folder.replace("'", "'\\''")
if mailbox in mailbox_seen:
continue
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"])
acls = acls.output.decode('utf-8').strip().splitlines()
if len(acls) >= 2:
for acl in acls[1:]:
user_id, rights = acl.split(maxsplit=1)
user_id = user_id.split('=')[1]
mailbox_seen.append(mailbox)
formatted_acls.append({ 'user': id, 'id': user_id, 'mailbox': mailbox, 'rights': rights.split() })
elif "Shared" in shared_folder and "/" in shared_folder:
shared_folder = shared_folder.split("/")
if len(shared_folder) < 3:
continue
user = shared_folder[1].replace("'", "'\\''")
mailbox = '/'.join(shared_folder[2:]).replace("'", "'\\''")
if mailbox in mailbox_seen:
continue
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"])
acls = acls.output.decode('utf-8').strip().splitlines()
if len(acls) >= 2:
for acl in acls[1:]:
user_id, rights = acl.split(maxsplit=1)
user_id = user_id.split('=')[1].replace("'", "'\\''")
if user_id == id and mailbox not in mailbox_seen:
mailbox_seen.append(mailbox)
formatted_acls.append({ 'user': user, 'id': id, 'mailbox': mailbox, 'rights': rights.split() })
return Response(content=json.dumps(formatted_acls, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: doveadm - task: delete_acl
def container_post__exec__doveadm__delete_acl(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
user = request_json['user'].replace("'", "'\\''")
mailbox = request_json['mailbox'].replace("'", "'\\''")
id = request_json['id'].replace("'", "'\\''")
if user and mailbox and id:
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' 'user={id}'"])
return self.exec_run_handler('generic', acl_delete_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
user = request_json['user'].replace("'", "'\\''")
mailbox = request_json['mailbox'].replace("'", "'\\''")
id = request_json['id'].replace("'", "'\\''")
rights = ""
available_rights = [
"admin",
"create",
"delete",
"expunge",
"insert",
"lookup",
"post",
"read",
"write",
"write-deleted",
"write-seen"
]
for right in request_json['rights']:
right = right.replace("'", "'\\''").lower()
if right in available_rights:
rights += right + " "
if user and mailbox and id and rights:
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' 'user={id}' {rights}"])
return self.exec_run_handler('generic', acl_set_return)
# Collect host stats
async def get_host_stats(self, wait=5):
try:
system_time = datetime.now()
host_stats = {
"cpu": {
"cores": psutil.cpu_count(),
"usage": psutil.cpu_percent()
},
"memory": {
"total": psutil.virtual_memory().total,
"usage": psutil.virtual_memory().percent,
"swap": psutil.swap_memory()
},
"uptime": time.time() - psutil.boot_time(),
"system_time": system_time.strftime("%d.%m.%Y %H:%M:%S"),
"architecture": platform.machine()
}
await self.redis_client.set('host_stats', json.dumps(host_stats), ex=10)
except Exception as e:
res = {
"type": "danger",
"msg": str(e)
}
await asyncio.sleep(wait)
self.host_stats_isUpdating = False
# Collect container stats
async def get_container_stats(self, container_id, wait=5, stop=False):
if container_id and container_id.isalnum():
try:
for container in (await self.async_docker_client.containers.list()):
if container._id == container_id:
res = await container.stats(stream=False)
if await self.redis_client.exists(container_id + '_stats'):
stats = json.loads(await self.redis_client.get(container_id + '_stats'))
else:
stats = []
stats.append(res[0])
if len(stats) > 3:
del stats[0]
await self.redis_client.set(container_id + '_stats', json.dumps(stats), ex=60)
except Exception as e:
res = {
"type": "danger",
"msg": str(e)
}
else:
res = {
"type": "danger",
"msg": "no or invalid id defined"
}
await asyncio.sleep(wait)
if stop == True:
# update task was called second time, stop
self.containerIds_to_update.remove(container_id)
else:
# call update task a second time
await self.get_container_stats(container_id, wait=0, stop=True)
def exec_cmd_container(self, container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
def recv_socket_data(c_socket, timeout):
c_socket.setblocking(0)
total_data=[]
data=''
begin=time.time()
while True:
if total_data and time.time()-begin > timeout:
break
elif time.time()-begin > timeout*2:
break
try:
data = c_socket.recv(8192)
if data:
total_data.append(data.decode('utf-8'))
#change the beginning time for measurement
begin=time.time()
else:
#sleep for sometime to indicate a gap
time.sleep(0.1)
break
except:
pass
return ''.join(total_data)
try :
socket = container.exec_run([shell_cmd], stdin=True, socket=True, user=user).output._sock
if not cmd.endswith("\n"):
cmd = cmd + "\n"
socket.send(cmd.encode('utf-8'))
data = recv_socket_data(socket, timeout)
socket.close()
return data
except Exception as e:
self.logger.error("error - exec_cmd_container: %s" % str(e))
traceback.print_exc(file=sys.stdout)
def exec_run_handler(self, type, output):
if type == 'generic':
if output.exit_code == 0:
res = { 'type': 'success', 'msg': 'command completed successfully' }
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'danger', 'msg': 'command failed: ' + output.output.decode('utf-8') }
return Response(content=json.dumps(res, indent=4), media_type="application/json")
if type == 'utf8_text_only':
return Response(content=output.output.decode('utf-8'), media_type="text/plain")
+3 -16
View File
@@ -1,13 +1,9 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM alpine:3.21 FROM alpine:3.21
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$ # renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
ARG GOSU_VERSION=1.19 ARG GOSU_VERSION=1.17
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8 ENV LC_ALL=C.UTF-8
@@ -139,14 +135,5 @@ COPY quota_notify.py /usr/local/bin/quota_notify.py
COPY repl_health.sh /usr/local/bin/repl_health.sh COPY repl_health.sh /usr/local/bin/repl_health.sh
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
ENV MAILCOW_AGENT_SERVICE=dovecot \
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
@@ -47,7 +47,7 @@ try:
if max_score == "": if max_score == "":
max_score = 9999.0 max_score = 9999.0
def query_mysql(query, params = None, headers = True, update = False): def query_mysql(query, headers = True, update = False):
while True: while True:
try: try:
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci") cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
@@ -57,9 +57,6 @@ try:
else: else:
break break
cur = cnx.cursor() cur = cnx.cursor()
if params:
cur.execute(query, params)
else:
cur.execute(query) cur.execute(query)
if not update: if not update:
result = [] result = []
@@ -79,7 +76,7 @@ try:
def notify_rcpt(rcpt, msg_count, quarantine_acl, category): def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
if category == "add_header": category = "add header" if category == "add_header": category = "add header"
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = %s AND score < %s AND (action = %s OR "all" = %s)', (rcpt, max_score, category, category)) meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category))
print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count)) print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
if len(meta_query) == 0: if len(meta_query) == 0:
return return
@@ -133,7 +130,7 @@ try:
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text) server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
server.quit() server.quit()
for res in meta_query: for res in meta_query:
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = %s', (res['id'],), update = True) query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%d"' % (res['id']), update = True)
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now) r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
break break
except Exception as ex: except Exception as ex:
@@ -141,7 +138,7 @@ try:
print('%s' % (ex)) print('%s' % (ex))
time.sleep(3) time.sleep(3)
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %s AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt', (max_score,)) records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %f AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt' % (max_score))
for record in records: for record in records:
attrs = '' attrs = ''
@@ -159,7 +156,7 @@ try:
except Exception as ex: except Exception as ex:
print('Could not determine last notification for %s, assuming never' % (record['rcpt'])) print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
last_notification = 0 last_notification = 0
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = %s', (record['rcpt'],)) attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = "%s"' % (record['rcpt']))
attrs = attrs_json[0]['attributes'] attrs = attrs_json[0]['attributes']
if isinstance(attrs, str): if isinstance(attrs, str):
# if attr is str then just load it # if attr is str then just load it
+7 -6
View File
@@ -24,12 +24,13 @@ fi
sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules
if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}" CONTAINER_NAME=rspamd-mailcow
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}" CONTAINER_ID=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | \
REQ_ID="$(date +%s%N)" jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | \
PAYLOAD="{\"cmd\":\"restart\",\"request_id\":\"${REQ_ID}\",\"issued_by\":\"dovecot-sa-rules\"}" jq -rc "select( .name | tostring | contains(\"${CONTAINER_NAME}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning \ if [[ ! -z ${CONTAINER_ID} ]]; then
PUBLISH mailcow.control.rspamd "${PAYLOAD}" >/dev/null 2>&1 || true curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
fi
fi fi
# Cleanup # Cleanup
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
persist-name("redis1") persist-name("redis1")
port(`REDIS_SLAVEOF_PORT`) port(`REDIS_SLAVEOF_PORT`)
auth("`REDISPASS`") auth("`REDISPASS`")
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
); );
}; };
destination d_redis_f2b_channel { destination d_redis_f2b_channel {
+1 -1
View File
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
persist-name("redis1") persist-name("redis1")
port(6379) port(6379)
auth("`REDISPASS`") auth("`REDISPASS`")
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
); );
}; };
destination d_redis_f2b_channel { destination d_redis_f2b_channel {
-21
View File
@@ -1,21 +0,0 @@
# host-agent: dedicated container that reads /host/proc to publish host-level
# stats and answer exec.df / exec.host-stats commands. Reuses the same agent
# binary; behaviour selected via MAILCOW_AGENT_SERVICE=host.
#
# Requires:
# volumes:
# - /proc:/host/proc:ro
# - /:/host/rootfs:ro
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.0
FROM ${AGENT_IMAGE} AS agent
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=agent /out/mailcow-agent /usr/local/bin/mailcow-agent
COPY --from=agent /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=host
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
+2 -16
View File
@@ -1,8 +1,4 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00 FROM alpine:3.21
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM alpine:3.23
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -44,14 +40,4 @@ COPY ./docker-entrypoint.sh /app/
RUN chmod +x /app/docker-entrypoint.sh RUN chmod +x /app/docker-entrypoint.sh
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=netfilter \
MAILCOW_AGENT_MAIN_CMD="/app/docker-entrypoint.sh"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+2 -15
View File
@@ -1,7 +1,3 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM nginx:alpine FROM nginx:alpine
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
@@ -18,14 +14,5 @@ RUN mkdir -p /etc/nginx/includes
COPY ./bootstrap.py / COPY ./bootstrap.py /
COPY ./docker-entrypoint.sh / COPY ./docker-entrypoint.sh /
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli CMD ["nginx", "-g", "daemon off;"]
ENV MAILCOW_AGENT_SERVICE=nginx \
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh nginx -g 'daemon off;'"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+1 -15
View File
@@ -1,7 +1,3 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM alpine:3.21 FROM alpine:3.21
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -22,16 +18,6 @@ ADD olefy.py /app/
RUN chown -R nobody:nobody /app /tmp RUN chown -R nobody:nobody /app /tmp
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
USER nobody USER nobody
ENV MAILCOW_AGENT_SERVICE=olefy \ CMD ["python3", "-u", "/app/olefy.py"]
MAILCOW_AGENT_MAIN_CMD="python3 -u /app/olefy.py"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+7 -19
View File
@@ -1,23 +1,19 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM php:8.2-fpm-alpine3.21 FROM php:8.2-fpm-alpine3.21
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
# renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$ # renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG APCU_PECL_VERSION=5.1.28 ARG APCU_PECL_VERSION=5.1.27
# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$ # renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
ARG IMAGICK_PECL_VERSION=3.8.1 ARG IMAGICK_PECL_VERSION=3.8.0
# renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$ # renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG MAILPARSE_PECL_VERSION=3.1.9 ARG MAILPARSE_PECL_VERSION=3.1.9
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$ # renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG MEMCACHED_PECL_VERSION=3.4.0 ARG MEMCACHED_PECL_VERSION=3.3.0
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$ # renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
ARG REDIS_PECL_VERSION=6.3.0 ARG REDIS_PECL_VERSION=6.2.0
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$ # renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
ARG COMPOSER_VERSION=2.9.5 ARG COMPOSER_VERSION=2.8.6
RUN apk add -U --no-cache autoconf \ RUN apk add -U --no-cache autoconf \
aspell-dev \ aspell-dev \
@@ -113,14 +109,6 @@ RUN apk add -U --no-cache autoconf \
COPY ./docker-entrypoint.sh / COPY ./docker-entrypoint.sh /
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=php-fpm \ CMD ["php-fpm"]
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh php-fpm"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+51 -23
View File
@@ -29,35 +29,63 @@ session.save_handler = redis
session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'" session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'"
' > /usr/local/etc/php/conf.d/session_store.ini ' > /usr/local/etc/php/conf.d/session_store.ini
# Wait for MariaDB. The upstream mariadb image already runs mariadb-upgrade # Check mysql_upgrade (master and slave)
# itself on startup when needed CONTAINER_ID=
echo "Waiting for MariaDB socket at /var/run/mysqld/mysqld.sock..." until [[ ! -z "${CONTAINER_ID}" ]] && [[ "${CONTAINER_ID}" =~ ^[[:alnum:]]*$ ]]; do
WAIT_C=0 CONTAINER_ID=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"mysql-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
until mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} -e "SELECT 1" >/dev/null 2>&1; do echo "Could not get mysql-mailcow container id... trying again"
WAIT_C=$((WAIT_C+1)) sleep 2
if [ ${WAIT_C} -gt 60 ]; then done
echo "MariaDB did not respond after 60s — continuing anyway." echo "MySQL @ ${CONTAINER_ID}"
SQL_LOOP_C=0
SQL_CHANGED=0
until [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; do
if [ ${SQL_LOOP_C} -gt 4 ]; then
echo "Tried to upgrade MySQL and failed, giving up after ${SQL_LOOP_C} retries and starting container (oops, not good)"
break break
fi fi
sleep 1 SQL_FULL_UPGRADE_RETURN=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_upgrade"}' --silent -H 'Content-type: application/json')
SQL_UPGRADE_STATUS=$(echo ${SQL_FULL_UPGRADE_RETURN} | jq -r .type)
SQL_LOOP_C=$((SQL_LOOP_C+1))
echo "SQL upgrade iteration #${SQL_LOOP_C}"
if [[ ${SQL_UPGRADE_STATUS} == 'warning' ]]; then
SQL_CHANGED=1
echo "MySQL applied an upgrade, debug output:"
echo ${SQL_FULL_UPGRADE_RETURN}
sleep 3
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
echo "Waiting for SQL to return, please wait"
sleep 2
done
continue
elif [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; then
echo "MySQL is up-to-date - debug output:"
echo ${SQL_FULL_UPGRADE_RETURN}
else
echo "No valid reponse for mysql_upgrade was received, debug output:"
echo ${SQL_FULL_UPGRADE_RETURN}
fi
done done
echo "MariaDB is ready."
# Timezone tables — check if CONVERT_TZ works, import if it returns NULL. # doing post-installation stuff, if SQL was upgraded (master and slave)
# Some Alpine builds drop mariadb-tzinfo-to-sql; fall back to a Python if [ ${SQL_CHANGED} -eq 1 ]; then
# emitter that produces the same INSERT statements from /usr/share/zoneinfo. POSTFIX=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
if [[ -z "${POSTFIX}" ]] || ! [[ "${POSTFIX}" =~ ^[[:alnum:]]*$ ]]; then
echo "Could not determine Postfix container ID, skipping Postfix restart."
else
echo "Restarting Postfix"
curl -X POST --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/restart | jq -r '.msg'
echo "Sleeping 5 seconds..."
sleep 5
fi
fi
# Check mysql tz import (master and slave)
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null) TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
echo "Importing timezone data into mysql.time_zone_* …" SQL_FULL_TZINFO_IMPORT_RETURN=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_tzinfo_to_sql"}' --silent -H 'Content-type: application/json')
if command -v mariadb-tzinfo-to-sql >/dev/null 2>&1; then echo "MySQL mysql_tzinfo_to_sql - debug output:"
mariadb-tzinfo-to-sql /usr/share/zoneinfo 2>/dev/null \ echo ${SQL_FULL_TZINFO_IMPORT_RETURN}
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
elif command -v mysql_tzinfo_to_sql >/dev/null 2>&1; then
mysql_tzinfo_to_sql /usr/share/zoneinfo 2>/dev/null \
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
else
echo "No tzinfo-to-sql tool available — skipping timezone import."
fi
fi fi
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
+2 -14
View File
@@ -1,7 +1,3 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM golang:1.25-bookworm AS builder FROM golang:1.25-bookworm AS builder
WORKDIR /src WORKDIR /src
@@ -49,14 +45,6 @@ RUN chmod +x /opt/postfix-tlspol.sh \
/docker-entrypoint.sh /docker-entrypoint.sh
RUN rm -rf /tmp/* /var/tmp/* RUN rm -rf /tmp/* /var/tmp/*
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=postfix-tlspol \ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
persist-name("redis1") persist-name("redis1")
port(`REDIS_SLAVEOF_PORT`) port(`REDIS_SLAVEOF_PORT`)
auth("`REDISPASS`") auth("`REDISPASS`")
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
); );
}; };
filter f_mail { facility(mail); }; filter f_mail { facility(mail); };
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
persist-name("redis1") persist-name("redis1")
port(6379) port(6379)
auth("`REDISPASS`") auth("`REDISPASS`")
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
); );
}; };
filter f_mail { facility(mail); }; filter f_mail { facility(mail); };
+2 -14
View File
@@ -1,7 +1,3 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM debian:bookworm-slim FROM debian:bookworm-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
@@ -62,14 +58,6 @@ RUN rm -rf /tmp/* /var/tmp/*
EXPOSE 588 EXPOSE 588
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=postfix \ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
-3
View File
@@ -329,17 +329,14 @@ query = SELECT goto FROM alias
SELECT id FROM alias SELECT id FROM alias
WHERE address='%s' WHERE address='%s'
AND (active='1' OR active='2') AND (active='1' OR active='2')
AND sender_allowed='1'
), ( ), (
SELECT id FROM alias SELECT id FROM alias
WHERE address='@%d' WHERE address='@%d'
AND (active='1' OR active='2') AND (active='1' OR active='2')
AND sender_allowed='1'
) )
) )
) )
AND active='1' AND active='1'
AND sender_allowed='1'
AND (domain IN AND (domain IN
(SELECT domain FROM domain (SELECT domain FROM domain
WHERE domain='%d' WHERE domain='%d'
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
persist-name("redis1") persist-name("redis1")
port(`REDIS_SLAVEOF_PORT`) port(`REDIS_SLAVEOF_PORT`)
auth("`REDISPASS`") auth("`REDISPASS`")
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
); );
}; };
destination d_redis_f2b_channel { destination d_redis_f2b_channel {
+1 -1
View File
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
persist-name("redis1") persist-name("redis1")
port(6379) port(6379)
auth("`REDISPASS`") auth("`REDISPASS`")
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
); );
}; };
destination d_redis_f2b_channel { destination d_redis_f2b_channel {
+6 -18
View File
@@ -1,13 +1,9 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00 FROM debian:bookworm-slim
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG RSPAMD_VER=rspamd_3.14.3-1~236eb65 ARG RSPAMD_VER=rspamd_3.13.2-1~8bf602278
ARG CODENAME=trixie ARG CODENAME=bookworm
ENV LC_ALL=C ENV LC_ALL=C
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -37,16 +33,8 @@ COPY settings.conf /etc/rspamd/settings.conf
COPY set_worker_password.sh /set_worker_password.sh COPY set_worker_password.sh /set_worker_password.sh
COPY docker-entrypoint.sh /docker-entrypoint.sh COPY docker-entrypoint.sh /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
STOPSIGNAL SIGTERM STOPSIGNAL SIGTERM
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent CMD ["/usr/bin/rspamd", "-f", "-u", "_rspamd", "-g", "_rspamd"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=rspamd \
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/rspamd -f -u _rspamd -g _rspamd"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+18 -150
View File
@@ -1,55 +1,19 @@
# SOGo built from source to enable security patch application FROM debian:bookworm-slim
# Repository: https://github.com/Alinto/sogo
# Version: SOGo-5.12.8
#
# Applied security patches:
# -
#
# To add new patches, modify SOGO_SECURITY_PATCHES ARG below with space-separated commit hashes
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM debian:bookworm
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG SOGO_VERSION=SOGo-5.12.8 ARG DEBIAN_VERSION=bookworm
ARG SOPE_VERSION=SOPE-5.12.8 ARG SOGO_DEBIAN_REPOSITORY=https://packagingv2.sogo.nu/sogo-nightly-debian/
# Security patches to apply (space-separated commit hashes)
ARG SOGO_SECURITY_PATCHES=""
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$ # renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
ARG GOSU_VERSION=1.19 ARG GOSU_VERSION=1.17
ENV LC_ALL=C ENV LC_ALL=C
# Install dependencies, build SOPE and SOGo, then clean up (all in one layer to minimize image size) # Prerequisites
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
# Build dependencies && apt-get update && apt-get install -y --no-install-recommends \
git \
build-essential \
gobjc \
pkg-config \
gnustep-make \
gnustep-base-runtime \
libgnustep-base-dev \
libxml2-dev \
libldap2-dev \
libssl-dev \
zlib1g-dev \
libpq-dev \
libmariadb-dev-compat \
libmemcached-dev \
libsodium-dev \
libcurl4-openssl-dev \
libzip-dev \
libytnef0-dev \
libwbxml2-dev \
curl \
ca-certificates \
# Runtime dependencies
apt-transport-https \ apt-transport-https \
ca-certificates \
gettext \ gettext \
gnupg \ gnupg \
mariadb-client \ mariadb-client \
@@ -63,109 +27,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
psmisc \ psmisc \
wget \ wget \
patch \ patch \
libobjc4 \
libxml2 \
libldap-2.5-0 \
libssl3 \
zlib1g \
libmariadb3 \
libmemcached11 \
libsodium23 \
libcurl4 \
libzip4 \
libytnef0 \
libwbxml2-1 \
# Download gosu
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \ && dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \ && wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
&& chmod +x /usr/local/bin/gosu \ && chmod +x /usr/local/bin/gosu \
&& gosu nobody true \ && gosu nobody true \
# Build SOPE && mkdir /usr/share/doc/sogo \
&& git clone --depth 1 --branch ${SOPE_VERSION} https://github.com/Alinto/sope.git /tmp/sope \
&& cd /tmp/sope \
&& rm -rf .git \
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
&& ./configure --prefix=/usr --disable-debug --disable-strip \
&& make -j$(nproc) \
&& make install \
&& cd / \
&& rm -rf /tmp/sope \
# Build SOGo with security patches
&& git clone --depth 1 --branch ${SOGO_VERSION} https://github.com/Alinto/sogo.git /tmp/sogo \
&& cd /tmp/sogo \
&& git config user.email "builder@mailcow.local" \
&& git config user.name "SOGo Builder" \
&& for patch in ${SOGO_SECURITY_PATCHES}; do \
echo "Applying security patch: ${patch}"; \
git fetch origin ${patch} && git cherry-pick ${patch}; \
done \
&& rm -rf .git \
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
&& ./configure --disable-debug --disable-strip \
&& make -j$(nproc) \
&& make install \
&& cd /tmp/sogo/ActiveSync \
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
&& make -j$(nproc) install \
&& cd / \
&& rm -rf /tmp/sogo \
# Strip binaries
&& strip --strip-unneeded /usr/local/sbin/sogod 2>/dev/null || true \
&& strip --strip-unneeded /usr/local/sbin/sogo-tool 2>/dev/null || true \
&& strip --strip-unneeded /usr/local/sbin/sogo-ealarms-notify 2>/dev/null || true \
&& strip --strip-unneeded /usr/local/sbin/sogo-slapd-sockd 2>/dev/null || true \
# Remove build dependencies and clean up
&& apt-get purge -y --auto-remove \
git \
build-essential \
gobjc \
gnustep-make \
libgnustep-base-dev \
libxml2-dev \
libldap2-dev \
libssl-dev \
zlib1g-dev \
libpq-dev \
libmariadb-dev-compat \
libmemcached-dev \
libsodium-dev \
libcurl4-openssl-dev \
libzip-dev \
libytnef0-dev \
curl \
&& apt-get autoremove -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/share/doc/* \
&& rm -rf /usr/share/man/* \
&& rm -rf /var/cache/debconf/* \
&& rm -rf /tmp/* \
&& rm -rf /root/.cache \
&& find /usr/local/lib -name '*.a' -delete \
&& find /usr/lib -name '*.a' -delete \
&& mkdir -p /usr/share/doc/sogo \
&& touch /usr/share/doc/sogo/empty.sh \ && touch /usr/share/doc/sogo/empty.sh \
&& wget -O- https://keys.openpgp.org/vks/v1/by-fingerprint/74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9 | gpg --dearmor | apt-key add - \
&& echo "deb [trusted=yes] ${SOGO_DEBIAN_REPOSITORY} ${DEBIAN_VERSION} main" > /etc/apt/sources.list.d/sogo.list \
&& apt-get update && apt-get install -y --no-install-recommends \
sogo \
sogo-activesync \
&& apt-get autoclean \
&& rm -rf /var/lib/apt/lists/* \
&& touch /etc/default/locale && touch /etc/default/locale
# Configure library paths
RUN echo "/usr/lib64" > /etc/ld.so.conf.d/sogo.conf \
&& echo "/usr/local/lib/sogo" >> /etc/ld.so.conf.d/sogo.conf \
&& echo "/usr/local/lib/GNUstep/Frameworks/SOGo.framework/Versions/5/sogo" >> /etc/ld.so.conf.d/sogo.conf \
&& ldconfig
# Create sogo user and group
RUN groupadd -r -g 999 sogo \
&& useradd -r -u 999 -g sogo -d /var/lib/sogo -s /bin/bash -c "SOGo Daemon" sogo \
&& mkdir -p /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo \
&& chown -R sogo:sogo /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo
# Create symlinks for SOGo binaries
RUN ln -s /usr/local/sbin/sogod /usr/sbin/sogod \
&& ln -s /usr/local/sbin/sogo-tool /usr/sbin/sogo-tool \
&& ln -s /usr/local/sbin/sogo-ealarms-notify /usr/sbin/sogo-ealarms-notify \
&& ln -s /usr/local/sbin/sogo-slapd-sockd /usr/sbin/sogo-slapd-sockd
# Copy configuration files and scripts
COPY ./bootstrap-sogo.sh /bootstrap-sogo.sh COPY ./bootstrap-sogo.sh /bootstrap-sogo.sh
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf 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 syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
@@ -178,14 +54,6 @@ COPY docker-entrypoint.sh /
RUN chmod +x /bootstrap-sogo.sh \ RUN chmod +x /bootstrap-sogo.sh \
/usr/local/sbin/stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=sogo \ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+2 -2
View File
@@ -1,5 +1,5 @@
--- /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200 --- /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200
+++ /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200 +++ /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200
@@ -46,7 +46,7 @@ @@ -46,7 +46,7 @@
</md-item-template> </md-item-template>
</md-autocomplete> </md-autocomplete>
+7 -11
View File
@@ -130,22 +130,18 @@ chmod 600 /var/lib/sogo/GNUstep/Defaults/sogod.plist
# Patch ACLs # Patch ACLs
#if [[ ${ACL_ANYONE} == 'allow' ]]; then #if [[ ${ACL_ANYONE} == 'allow' ]]; then
# #enable any or authenticated targets for ACL # #enable any or authenticated targets for ACL
# if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then # if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
# patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff; # patch -R /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
# fi # fi
#else #else
# #disable any or authenticated targets for ACL # #disable any or authenticated targets for ACL
# if patch -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then # if patch -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
# patch /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff; # patch /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
# fi # fi
#fi #fi
# Apply custom UI patch (reverse patch to ADD buttons) if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then patch -R /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
echo "Applying navMailcowBtns patch (reverse to add buttons)..."
patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
else
echo "navMailcowBtns patch already applied or cannot be applied"
fi fi
# Rename custom logo, if any # Rename custom logo, if any
@@ -153,7 +149,7 @@ fi
# Rsync web content # Rsync web content
echo "Syncing web content with named volume" echo "Syncing web content with named volume"
rsync -a /usr/local/lib/GNUstep/SOGo/. /sogo_web/ rsync -a /usr/lib/GNUstep/SOGo/. /sogo_web/
# Chown backup path # Chown backup path
chown -R sogo:sogo /sogo_backup chown -R sogo:sogo /sogo_backup
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
persist-name("redis1") persist-name("redis1")
port(`REDIS_SLAVEOF_PORT`) port(`REDIS_SLAVEOF_PORT`)
auth("`REDISPASS`") auth("`REDISPASS`")
command("LPUSH" "SOGO_LOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") command("LPUSH" "SOGO_LOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
); );
}; };
destination d_redis_f2b_channel { destination d_redis_f2b_channel {
+1 -1
View File
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
persist-name("redis1") persist-name("redis1")
port(6379) port(6379)
auth("`REDISPASS`") auth("`REDISPASS`")
command("LPUSH" "SOGO_LOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") command("LPUSH" "SOGO_LOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
); );
}; };
destination d_redis_f2b_channel { destination d_redis_f2b_channel {
+5 -16
View File
@@ -1,8 +1,4 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00 FROM alpine:3.21
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM alpine:3.23
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -33,15 +29,8 @@ COPY supervisord.conf /etc/supervisor/supervisord.conf
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
RUN chmod +x /healthcheck.sh RUN chmod +x /healthcheck.sh
HEALTHCHECK --interval=30s --timeout=10s \
CMD sh -c '[ -f /tmp/healthcheck_status ] && [ "$(cat /tmp/healthcheck_status)" -eq 0 ] || exit 1'
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
ENV MAILCOW_AGENT_SERVICE=unbound \
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+2 -17
View File
@@ -1,8 +1,4 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00 FROM alpine:3.21
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM alpine:3.23
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -41,16 +37,5 @@ RUN apk add --update \
COPY watchdog.sh /watchdog.sh COPY watchdog.sh /watchdog.sh
COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh
COPY check_dns.sh /usr/lib/mailcow/check_dns.sh COPY check_dns.sh /usr/lib/mailcow/check_dns.sh
COPY client.cnf /etc/my.cnf.d/client.cnf
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent CMD ["/watchdog.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=watchdog \
MAILCOW_AGENT_MAIN_CMD="/watchdog.sh"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+4 -4
View File
@@ -19,19 +19,19 @@ if [ -z "$HOST" ]; then
fi fi
# run dig and measure the time it takes to run # run dig and measure the time it takes to run
START_TIME=$(perl -MTime::HiRes -e 'print Time::HiRes::time') START_TIME=$(date +%s%3N)
dig_output=$(dig +short +timeout=2 +tries=1 "$HOST" @"$SERVER" 2>/dev/null) dig_output=$(dig +short +timeout=2 +tries=1 "$HOST" @"$SERVER" 2>/dev/null)
dig_rc=$? dig_rc=$?
END_TIME=$(perl -MTime::HiRes -e 'print Time::HiRes::time')
dig_output_ips=$(echo "$dig_output" | grep -E '^[0-9.]+$' | sort | paste -sd ',' -) dig_output_ips=$(echo "$dig_output" | grep -E '^[0-9.]+$' | sort | paste -sd ',' -)
ELAPSED_TIME=$(perl -e "printf('%.3f', $END_TIME - $START_TIME)") END_TIME=$(date +%s%3N)
ELAPSED_TIME=$((END_TIME - START_TIME))
# validate and perform nagios like output and exit codes # validate and perform nagios like output and exit codes
if [ $dig_rc -ne 0 ] || [ -z "$dig_output" ]; then if [ $dig_rc -ne 0 ] || [ -z "$dig_output" ]; then
echo "Domain $HOST was not found by the server" echo "Domain $HOST was not found by the server"
exit 2 exit 2
elif [ $dig_rc -eq 0 ]; then elif [ $dig_rc -eq 0 ]; then
echo "DNS OK: $ELAPSED_TIME seconds response time. $HOST returns $dig_output_ips" echo "DNS OK: $ELAPSED_TIME ms response time. $HOST returns $dig_output_ips"
exit 0 exit 0
else else
echo "Unknown error" echo "Unknown error"
-3
View File
@@ -1,3 +0,0 @@
[client]
ssl = false
ssl-verify-server-cert = false
+608 -74
View File
@@ -38,7 +38,7 @@ if [[ ! -p /tmp/com_pipe ]]; then
fi fi
# Wait for containers # Wait for containers
while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
echo "Waiting for SQL..." echo "Waiting for SQL..."
sleep 2 sleep 2
done done
@@ -188,6 +188,44 @@ function notify_error() {
fi fi
} }
get_container_ip() {
# ${1} is container
CONTAINER_ID=()
CONTAINER_IPS=()
CONTAINER_IP=
LOOP_C=1
until [[ ${CONTAINER_IP} =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || [[ ${LOOP_C} -gt 5 ]]; do
if [ ${IP_BY_DOCKER_API} -eq 0 ]; then
CONTAINER_IP=$(dig a "${1}" +short)
else
sleep 0.5
# get long container id for exact match
CONTAINER_ID=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring == \"${1}\") | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id"))
# returned id can have multiple elements (if scaled), shuffle for random test
CONTAINER_ID=($(printf "%s\n" "${CONTAINER_ID[@]}" | shuf))
if [[ ! -z ${CONTAINER_ID} ]]; then
for matched_container in "${CONTAINER_ID[@]}"; do
CONTAINER_IPS=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${matched_container}/json | jq -r '.NetworkSettings.Networks[].IPAddress'))
for ip_match in "${CONTAINER_IPS[@]}"; do
# grep will do nothing if one of these vars is empty
[[ -z ${ip_match} ]] && continue
[[ -z ${IPV4_NETWORK} ]] && continue
# only return ips that are part of our network
if ! grep -q ${IPV4_NETWORK} <(echo ${ip_match}); then
continue
else
CONTAINER_IP=${ip_match}
break
fi
done
[[ ! -z ${CONTAINER_IP} ]] && break
done
fi
fi
LOOP_C=$((LOOP_C + 1))
done
[[ ${LOOP_C} -gt 5 ]] && echo 240.0.0.0 || echo ${CONTAINER_IP}
}
# One-time check # One-time check
if grep -qi "$(echo ${IPV6_NETWORK} | cut -d: -f1-3)" <<< "$(ip a s)"; then if grep -qi "$(echo ${IPV6_NETWORK} | cut -d: -f1-3)" <<< "$(ip a s)"; then
@@ -229,6 +267,295 @@ external_checks() {
return 1 return 1
} }
nginx_checks() {
err_count=0
diff_c=0
THRESHOLD=${NGINX_THRESHOLD}
# 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
touch /tmp/nginx-mailcow; echo "$(tail -50 /tmp/nginx-mailcow)" > /tmp/nginx-mailcow
host_ip=$(get_container_ip nginx-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_http -4 -H ${host_ip} -u / -p 8081 2>> /tmp/nginx-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "Nginx" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
unbound_checks() {
err_count=0
diff_c=0
THRESHOLD=${UNBOUND_THRESHOLD}
# 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
touch /tmp/unbound-mailcow; echo "$(tail -50 /tmp/unbound-mailcow)" > /tmp/unbound-mailcow
host_ip=$(get_container_ip unbound-mailcow)
err_c_cur=${err_count}
/usr/lib/mailcow/check_dns.sh -s ${host_ip} -H stackoverflow.com 2>> /tmp/unbound-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
DNSSEC=$(dig com +dnssec | egrep 'flags:.+ad')
if [[ -z ${DNSSEC} ]]; then
echo "DNSSEC failure" 2>> /tmp/unbound-mailcow 1>&2
err_count=$(( ${err_count} + 1))
else
echo "DNSSEC check succeeded" 2>> /tmp/unbound-mailcow 1>&2
fi
[ ${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}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
redis_checks() {
# A check for the local redis container
err_count=0
diff_c=0
THRESHOLD=${REDIS_THRESHOLD}
# 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
touch /tmp/redis-mailcow; echo "$(tail -50 /tmp/redis-mailcow)" > /tmp/redis-mailcow
host_ip=$(get_container_ip redis-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_tcp -4 -H redis-mailcow -p 6379 -E -s "AUTH ${REDISPASS}\nPING\n" -q "QUIT" -e "PONG" 2>> /tmp/redis-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "Redis" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
mysql_checks() {
err_count=0
diff_c=0
THRESHOLD=${MYSQL_THRESHOLD}
# 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
touch /tmp/mysql-mailcow; echo "$(tail -50 /tmp/mysql-mailcow)" > /tmp/mysql-mailcow
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_mysql -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_mysql_query -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} -q "SELECT COUNT(*) FROM information_schema.tables" 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "MySQL/MariaDB" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
mysql_repl_checks() {
err_count=0
diff_c=0
THRESHOLD=${MYSQL_REPLICATION_THRESHOLD}
# 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
touch /tmp/mysql_repl_checks; echo "$(tail -50 /tmp/mysql_repl_checks)" > /tmp/mysql_repl_checks
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_mysql_slavestatus.sh -S /var/run/mysqld/mysqld.sock -u root -p ${DBROOT} 2>> /tmp/mysql_repl_checks 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "MySQL/MariaDB replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 60
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
sogo_checks() {
err_count=0
diff_c=0
THRESHOLD=${SOGO_THRESHOLD}
# 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
touch /tmp/sogo-mailcow; echo "$(tail -50 /tmp/sogo-mailcow)" > /tmp/sogo-mailcow
host_ip=$(get_container_ip sogo-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_http -4 -H ${host_ip} -u /SOGo.index/ -p 20000 2>> /tmp/sogo-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "SOGo" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
postfix_checks() {
err_count=0
diff_c=0
THRESHOLD=${POSTFIX_THRESHOLD}
# 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
touch /tmp/postfix-mailcow; echo "$(tail -50 /tmp/postfix-mailcow)" > /tmp/postfix-mailcow
host_ip=$(get_container_ip postfix-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 589 -f "watchdog@invalid" -C "RCPT TO:watchdog@localhost" -C DATA -C . -R 250 2>> /tmp/postfix-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 589 -S 2>> /tmp/postfix-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "Postfix" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
postfix-tlspol_checks() {
err_count=0
diff_c=0
THRESHOLD=${POSTFIX_TLSPOL_THRESHOLD}
# 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
touch /tmp/postfix-tlspol-mailcow; echo "$(tail -50 /tmp/postfix-tlspol-mailcow)" > /tmp/postfix-tlspol-mailcow
host_ip=$(get_container_ip postfix-tlspol-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 8642 2>> /tmp/postfix-tlspol-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "Postfix TLS Policy companion" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
clamd_checks() {
err_count=0
diff_c=0
THRESHOLD=${CLAMD_THRESHOLD}
# 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
touch /tmp/clamd-mailcow; echo "$(tail -50 /tmp/clamd-mailcow)" > /tmp/clamd-mailcow
host_ip=$(get_container_ip clamd-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_clamd -4 -H ${host_ip} 2>> /tmp/clamd-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "Clamd" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 120 ) + 20 ))
fi
done
return 1
}
dovecot_checks() {
err_count=0
diff_c=0
THRESHOLD=${DOVECOT_THRESHOLD}
# 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
touch /tmp/dovecot-mailcow; echo "$(tail -50 /tmp/dovecot-mailcow)" > /tmp/dovecot-mailcow
host_ip=$(get_container_ip dovecot-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 24 -f "watchdog@invalid" -C "RCPT TO:<watchdog@invalid>" -L -R "User doesn't exist" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_imap -4 -H ${host_ip} -p 993 -S -e "OK " 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_imap -4 -H ${host_ip} -p 143 -e "OK " 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 10001 -e "VERSION" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 4190 -e "Dovecot ready" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "Dovecot" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
dovecot_repl_checks() {
err_count=0
diff_c=0
THRESHOLD=${DOVECOT_REPL_THRESHOLD}
D_REPL_STATUS=$(redis-cli -h redis -a ${REDISPASS} --no-auth-warning -r GET DOVECOT_REPL_HEALTH)
# 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
err_c_cur=${err_count}
D_REPL_STATUS=$(redis-cli --raw -h redis -a ${REDISPASS} --no-auth-warning GET DOVECOT_REPL_HEALTH)
if [[ "${D_REPL_STATUS}" != "1" ]]; then
err_count=$(( ${err_count} + 1 ))
fi
[ ${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 "Dovecot replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 60
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
cert_checks() { cert_checks() {
err_count=0 err_count=0
diff_c=0 diff_c=0
@@ -237,9 +564,11 @@ cert_checks() {
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1 trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/certcheck; echo "$(tail -50 /tmp/certcheck)" > /tmp/certcheck touch /tmp/certcheck; echo "$(tail -50 /tmp/certcheck)" > /tmp/certcheck
host_ip_postfix=$(get_container_ip postfix)
host_ip_dovecot=$(get_container_ip dovecot)
err_c_cur=${err_count} err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_smtp -H postfix -p 589 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${err_count} + $? )) /usr/lib/nagios/plugins/check_smtp -H ${host_ip_postfix} -p 589 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_imap -H dovecot -p 993 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${err_count} + $? )) /usr/lib/nagios/plugins/check_imap -H ${host_ip_dovecot} -p 993 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1 [ ${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} )) [ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Primary certificate expiry check" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c} progress "Primary certificate expiry check" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
@@ -249,6 +578,31 @@ cert_checks() {
return 1 return 1
} }
phpfpm_checks() {
err_count=0
diff_c=0
THRESHOLD=${PHPFPM_THRESHOLD}
# 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
touch /tmp/php-fpm-mailcow; echo "$(tail -50 /tmp/php-fpm-mailcow)" > /tmp/php-fpm-mailcow
host_ip=$(get_container_ip php-fpm-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_tcp -H ${host_ip} -p 9001 2>> /tmp/php-fpm-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_tcp -H ${host_ip} -p 9002 2>> /tmp/php-fpm-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "PHP-FPM" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
ratelimit_checks() { ratelimit_checks() {
err_count=0 err_count=0
@@ -382,63 +736,90 @@ acme_checks() {
return 1 return 1
} }
rspamd_checks() {
err_count=0
diff_c=0
THRESHOLD=${RSPAMD_THRESHOLD}
# 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
touch /tmp/rspamd-mailcow; echo "$(tail -50 /tmp/rspamd-mailcow)" > /tmp/rspamd-mailcow
host_ip=$(get_container_ip rspamd-mailcow)
err_c_cur=${err_count}
SCORE=$(echo 'To: null@localhost
From: watchdog@localhost
Empty
' | usr/bin/curl --max-time 10 -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/scan | jq -rc .default.required_score | sed 's/\..*//' )
if [[ ${SCORE} -ne 9999 ]]; then
echo "Rspamd settings check failed, score returned: ${SCORE}" 2>> /tmp/rspamd-mailcow 1>&2
err_count=$(( ${err_count} + 1))
else
echo "Rspamd settings check succeeded, score returned: ${SCORE}" 2>> /tmp/rspamd-mailcow 1>&2
fi
# A dirty hack until a PING PONG event is implemented to worker proxy
# We expect an empty response, not a timeout
if [ "$(curl -s --max-time 10 ${host_ip}:9900 2> /dev/null ; echo $?)" == "28" ]; then
echo "Milter check failed" 2>> /tmp/rspamd-mailcow 1>&2; err_count=$(( ${err_count} + 1 ));
else
echo "Milter check succeeded" 2>> /tmp/rspamd-mailcow 1>&2
fi
[ ${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 "Rspamd" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
olefy_checks() {
err_count=0
diff_c=0
THRESHOLD=${OLEFY_THRESHOLD}
# 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
touch /tmp/olefy-mailcow; echo "$(tail -50 /tmp/olefy-mailcow)" > /tmp/olefy-mailcow
host_ip=$(get_container_ip olefy-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 10055 -s "PING\n" 2>> /tmp/olefy-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${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 "Olefy" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
# Notify about start # Notify about start
if [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then if [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
notify_error "watchdog-mailcow" "Watchdog started monitoring mailcow." notify_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
fi fi
# Health checks run inside each container (mailcow-agent healthcheck + heartbeat). # Create watchdog agents
# We just read the per-node health field from Redis and restart on N consecutive fails.
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
REDIS_CMDLINE_FULL="redis-cli -h ${REDIS_HOST} -p ${REDIS_PORT} -a ${REDISPASS} --no-auth-warning"
HEALTH_WATCHED_SERVICES=(
postfix dovecot sogo rspamd nginx
clamd unbound olefy phpfpm postfix-tlspol
)
declare -A HEALTH_FAIL_COUNT
HEALTH_FAIL_THRESHOLD=3
[[ "${SKIP_SOGO}" =~ ^([yY][eE][sS]|[yY])+$ ]] && HEALTH_WATCHED_SERVICES=("${HEALTH_WATCHED_SERVICES[@]/sogo}")
[[ "${SKIP_CLAMD}" =~ ^([yY][eE][sS]|[yY])+$ ]] && HEALTH_WATCHED_SERVICES=("${HEALTH_WATCHED_SERVICES[@]/clamd}")
[[ "${SKIP_OLEFY}" =~ ^([yY][eE][sS]|[yY])+$ ]] && HEALTH_WATCHED_SERVICES=("${HEALTH_WATCHED_SERVICES[@]/olefy}")
( (
# Counters are per-node in an associative array reset on restart, so absorb USR1
# instead of dying (other tasks trap it to decrement their own err_count).
trap '' USR1
declare -A HEALTH_FAIL_COUNT
while true; do while true; do
for svc in "${HEALTH_WATCHED_SERVICES[@]}"; do if ! nginx_checks; then
[[ -z "$svc" ]] && continue log_msg "Nginx hit error limit"
nodes=$(${REDIS_CMDLINE_FULL} ZRANGEBYSCORE "mailcow.nodes.${svc}" "$(( $(date +%s) - 30 ))" "+inf" 2>/dev/null) echo nginx-mailcow > /tmp/com_pipe
[[ -z "${nodes}" ]] && continue
while IFS= read -r node; do
[[ -z "${node}" ]] && continue
health=$(${REDIS_CMDLINE_FULL} HGET "mailcow.node.${svc}.${node}" health 2>/dev/null)
key="${svc}|${node}"
if [[ "${health}" == "fail" ]]; then
HEALTH_FAIL_COUNT[$key]=$(( ${HEALTH_FAIL_COUNT[$key]:-0} + 1 ))
if [[ ${HEALTH_FAIL_COUNT[$key]} -ge ${HEALTH_FAIL_THRESHOLD} ]]; then
detail=$(${REDIS_CMDLINE_FULL} HGET "mailcow.node.${svc}.${node}" health_detail 2>/dev/null)
log_msg "Service ${svc} node ${node} unhealthy (${detail:-no detail}) — sending restart"
echo "${svc}-mailcow|${node}" > /tmp/com_pipe
HEALTH_FAIL_COUNT[$key]=0
fi fi
else
HEALTH_FAIL_COUNT[$key]=0
fi
done <<< "${nodes}"
done
sleep 15
done done
) & ) &
PID=$! PID=$!
echo "Spawned registry-based health monitor with PID ${PID}" echo "Spawned nginx_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID}) BACKGROUND_TASKS+=(${PID})
if [[ ${WATCHDOG_EXTERNAL_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then if [[ ${WATCHDOG_EXTERNAL_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
@@ -455,6 +836,110 @@ echo "Spawned external_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID}) BACKGROUND_TASKS+=(${PID})
fi fi
if [[ ${WATCHDOG_MYSQL_REPLICATION_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
(
while true; do
if ! mysql_repl_checks; then
log_msg "MySQL replication check hit error limit"
echo mysql_repl_checks > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned mysql_repl_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
(
while true; do
if ! mysql_checks; then
log_msg "MySQL hit error limit"
echo mysql-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned mysql_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! redis_checks; then
log_msg "Local Redis hit error limit"
echo redis-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned redis_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! phpfpm_checks; then
log_msg "PHP-FPM hit error limit"
echo php-fpm-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned phpfpm_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
if [[ "${SKIP_SOGO}" =~ ^([nN][oO]|[nN])+$ ]]; then
(
while true; do
if ! sogo_checks; then
log_msg "SOGo hit error limit"
echo sogo-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned sogo_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
if [ ${CHECK_UNBOUND} -eq 1 ]; then
(
while true; do
if ! unbound_checks; then
log_msg "Unbound hit error limit"
echo unbound-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned unbound_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
if [[ "${SKIP_CLAMD}" =~ ^([nN][oO]|[nN])+$ ]]; then
(
while true; do
if ! clamd_checks; then
log_msg "Clamd hit error limit"
echo clamd-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned clamd_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
(
while true; do
if ! postfix_checks; then
log_msg "Postfix hit error limit"
echo postfix-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned postfix_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
( (
while true; do while true; do
if ! mailq_checks; then if ! mailq_checks; then
@@ -467,6 +952,54 @@ PID=$!
echo "Spawned mailq_checks with PID ${PID}" echo "Spawned mailq_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID}) BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! postfix-tlspol_checks; then
log_msg "Postfix TLS Policy hit error limit"
echo postfix-tlspol-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned postfix-tlspol_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! dovecot_checks; then
log_msg "Dovecot hit error limit"
echo dovecot-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned dovecot_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! dovecot_repl_checks; then
log_msg "Dovecot hit error limit"
echo dovecot_repl_checks > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned dovecot_repl_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! rspamd_checks; then
log_msg "Rspamd hit error limit"
echo rspamd-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned rspamd_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
( (
while true; do while true; do
if ! ratelimit_checks; then if ! ratelimit_checks; then
@@ -503,6 +1036,20 @@ PID=$!
echo "Spawned cert_checks with PID ${PID}" echo "Spawned cert_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID}) BACKGROUND_TASKS+=(${PID})
if [[ "${SKIP_OLEFY}" =~ ^([nN][oO]|[nN])+$ ]]; then
(
while true; do
if ! olefy_checks; then
log_msg "Olefy hit error limit"
echo olefy-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned olefy_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
( (
while true; do while true; do
if ! acme_checks; then if ! acme_checks; then
@@ -528,19 +1075,15 @@ while true; do
done done
) & ) &
# Pause background checks while Redis (the control bus) is unreachable, otherwise # Monitor dockerapi
# we'd flag every service as unhealthy at once.
( (
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
ping_bus() { redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning ping > /dev/null 2>&1; }
while true; do while true; do
while ping_bus; do while nc -z dockerapi 443; do
sleep 3 sleep 3
done done
log_msg "Cannot reach redis-mailcow (control bus), waiting to recover..." log_msg "Cannot find dockerapi-mailcow, waiting to recover..."
kill -STOP ${BACKGROUND_TASKS[*]} kill -STOP ${BACKGROUND_TASKS[*]}
until ping_bus; do until nc -z dockerapi 443; do
sleep 3 sleep 3
done done
kill -CONT ${BACKGROUND_TASKS[*]} kill -CONT ${BACKGROUND_TASKS[*]}
@@ -600,34 +1143,25 @@ while true; do
elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then
kill -STOP ${BACKGROUND_TASKS[*]} kill -STOP ${BACKGROUND_TASKS[*]}
sleep 10 sleep 10
# "<service>-mailcow|<node>" restarts a single replica; bare "<service>-mailcow" CONTAINER_ID=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"${com_pipe_answer}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
# broadcasts the restart to every replica of the service. if [[ ! -z ${CONTAINER_ID} ]]; then
AGENT_NODE="" if [[ "${com_pipe_answer}" == "php-fpm-mailcow" ]]; then
AGENT_SVC="${com_pipe_answer%-mailcow}" HAS_INITDB=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/top | jq '.msg.Processes[] | contains(["php -c /usr/local/etc/php -f /web/inc/init_db.inc.php"])' | grep true)
if [[ "${com_pipe_answer}" == *"|"* ]]; then
AGENT_NODE="${com_pipe_answer#*|}"
AGENT_SVC="${com_pipe_answer%|*}"
AGENT_SVC="${AGENT_SVC%-mailcow}"
fi
STARTED_AT_RAW=$(redis-cli -h "${REDIS_SLAVEOF_IP:-redis-mailcow}" -p "${REDIS_SLAVEOF_PORT:-6379}" -a "${REDISPASS}" --no-auth-warning HGET "mailcow.node.${AGENT_SVC}.${AGENT_NODE:-$(hostname)}" started_at 2>/dev/null)
S_RUNNING=999
if [[ -n "${STARTED_AT_RAW}" ]]; then
S_RUNNING=$(( $(date +%s) - $(date -d "${STARTED_AT_RAW}" +%s 2>/dev/null || echo 0) ))
fi fi
S_RUNNING=$(($(date +%s) - $(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/json | jq .State.StartedAt | xargs -n1 date +%s -d)))
if [ ${S_RUNNING} -lt 360 ]; then if [ ${S_RUNNING} -lt 360 ]; then
log_msg "Container is running for less than 360 seconds, skipping action..." log_msg "Container is running for less than 360 seconds, skipping action..."
elif [[ ! -z ${HAS_INITDB} ]]; then
log_msg "Database is being initialized by php-fpm-mailcow, not restarting but delaying checks for a minute..."
sleep 60
else else
if [[ -n "${AGENT_NODE}" ]]; then log_msg "Sending restart command to ${CONTAINER_ID}..."
log_msg "Sending restart to ${AGENT_SVC} node ${AGENT_NODE} via control bus..." curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
mailcow-agent-cli send "${AGENT_SVC}" restart "{\"target_node\":\"${AGENT_NODE}\"}" >/dev/null || true
else
log_msg "Sending restart broadcast to ${AGENT_SVC} via control bus..."
mailcow-agent-cli send "${AGENT_SVC}" restart >/dev/null || true
fi
notify_error "${com_pipe_answer}" notify_error "${com_pipe_answer}"
log_msg "Wait for restarted container to settle and continue watching..." log_msg "Wait for restarted container to settle and continue watching..."
sleep 35 sleep 35
fi fi
fi
kill -CONT ${BACKGROUND_TASKS[*]} kill -CONT ${BACKGROUND_TASKS[*]}
sleep 1 sleep 1
kill -USR1 ${BACKGROUND_TASKS[*]} kill -USR1 ${BACKGROUND_TASKS[*]}
+4 -11
View File
@@ -80,21 +80,14 @@ if ($isSOGoRequest) {
} }
if ($result === false){ if ($result === false){
// If it's a SOGo Request, don't check for protocol access // If it's a SOGo Request, don't check for protocol access
if ($isSOGoRequest) { $service = ($isSOGoRequest) ? false : array($post['service'] => true);
$service = 'SOGO'; $result = apppass_login($post['username'], $post['password'], $service, array(
$post['service'] = 'NONE';
} else {
$service = $post['service'];
}
$result = apppass_login($post['username'], $post['password'], array(
'service' => $post['service'],
'is_internal' => true, 'is_internal' => true,
'remote_addr' => $post['real_rip'] 'remote_addr' => $post['real_rip']
)); ));
if ($result) { if ($result) {
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']); error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $service); set_sasl_log($post['username'], $post['real_rip'], $post['service']);
} }
} }
if ($result === false){ if ($result === false){
@@ -261,19 +261,19 @@ location ~* /sogo$ {
} }
location /SOGo.woa/WebServerResources/ { location /SOGo.woa/WebServerResources/ {
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/; alias /usr/lib/GNUstep/SOGo/WebServerResources/;
} }
location /.woa/WebServerResources/ { location /.woa/WebServerResources/ {
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/; alias /usr/lib/GNUstep/SOGo/WebServerResources/;
} }
location /SOGo/WebServerResources/ { location /SOGo/WebServerResources/ {
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/; alias /usr/lib/GNUstep/SOGo/WebServerResources/;
} }
location (^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\.(jpg|png|gif|css|js)$) { location (^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\.(jpg|png|gif|css|js)$) {
alias /usr/local/lib/GNUstep/SOGo/$1.SOGo/Resources/$2; alias /usr/lib/GNUstep/SOGo/$1.SOGo/Resources/$2;
} }
{% endif %} {% endif %}
+76 -187
View File
@@ -1,8 +1,7 @@
# Whitelist generated by Postwhite v3.4 on Fri May 1 00:43:37 UTC 2026 # Whitelist generated by Postwhite v3.4 on Mon Dec 1 00:24:43 UTC 2025
# https://github.com/stevejenkins/postwhite/ # https://github.com/stevejenkins/postwhite/
# 2249 total rules # 2186 total rules
2a00:1450:4000::/36 permit 2a00:1450:4000::/36 permit
2a00:1450:4864::/56 permit
2a01:111:f400::/48 permit 2a01:111:f400::/48 permit
2a01:111:f403:2800::/53 permit 2a01:111:f403:2800::/53 permit
2a01:111:f403:8000::/51 permit 2a01:111:f403:8000::/51 permit
@@ -32,11 +31,8 @@
2a02:a60:0:5::/64 permit 2a02:a60:0:5::/64 permit
2a0f:f640::/56 permit 2a0f:f640::/56 permit
2c0f:fb50:4000::/36 permit 2c0f:fb50:4000::/36 permit
2c0f:fb50:4864::/56 permit
2.207.151.32/27 permit
2.207.151.53 permit 2.207.151.53 permit
2.207.217.30 permit 2.207.217.30 permit
2.207.223.160/27 permit
3.64.237.68 permit 3.64.237.68 permit
3.65.3.180 permit 3.65.3.180 permit
3.70.123.177 permit 3.70.123.177 permit
@@ -56,14 +52,10 @@
8.25.194.0/23 permit 8.25.194.0/23 permit
8.25.196.0/23 permit 8.25.196.0/23 permit
8.36.116.0/24 permit 8.36.116.0/24 permit
8.39.54.0/23 permit
8.39.54.250/31 permit
8.39.144.0/24 permit 8.39.144.0/24 permit
8.40.222.0/23 permit
8.40.222.250/31 permit
12.130.86.238 permit 12.130.86.238 permit
13.107.213.40 permit 13.107.213.69 permit
13.107.246.40 permit 13.107.246.69 permit
13.108.16.0/20 permit 13.108.16.0/20 permit
13.110.208.0/21 permit 13.110.208.0/21 permit
13.110.209.0/24 permit 13.110.209.0/24 permit
@@ -177,7 +169,6 @@
34.215.104.144 permit 34.215.104.144 permit
34.218.115.239 permit 34.218.115.239 permit
34.225.212.172 permit 34.225.212.172 permit
34.241.242.183 permit
35.83.148.184 permit 35.83.148.184 permit
35.155.198.111 permit 35.155.198.111 permit
35.158.23.94 permit 35.158.23.94 permit
@@ -201,7 +192,6 @@
40.233.64.216 permit 40.233.64.216 permit
40.233.83.78 permit 40.233.83.78 permit
40.233.88.28 permit 40.233.88.28 permit
43.239.212.33 permit
44.206.138.57 permit 44.206.138.57 permit
44.210.169.44 permit 44.210.169.44 permit
44.217.45.156 permit 44.217.45.156 permit
@@ -281,10 +271,8 @@
50.56.130.221 permit 50.56.130.221 permit
50.56.130.222 permit 50.56.130.222 permit
50.112.246.219 permit 50.112.246.219 permit
51.83.17.38 permit
52.1.14.157 permit 52.1.14.157 permit
52.5.230.59 permit 52.5.230.59 permit
52.6.74.205 permit
52.12.53.23 permit 52.12.53.23 permit
52.13.214.179 permit 52.13.214.179 permit
52.26.1.71 permit 52.26.1.71 permit
@@ -308,6 +296,14 @@
52.94.124.0/28 permit 52.94.124.0/28 permit
52.95.48.152/29 permit 52.95.48.152/29 permit
52.95.49.88/29 permit 52.95.49.88/29 permit
52.96.91.34 permit
52.96.111.82 permit
52.96.172.98 permit
52.96.222.194 permit
52.96.222.226 permit
52.96.223.2 permit
52.96.228.130 permit
52.96.229.242 permit
52.100.0.0/15 permit 52.100.0.0/15 permit
52.102.0.0/16 permit 52.102.0.0/16 permit
52.103.0.0/17 permit 52.103.0.0/17 permit
@@ -341,7 +337,6 @@
54.244.54.130 permit 54.244.54.130 permit
54.244.242.0/24 permit 54.244.242.0/24 permit
54.255.61.23 permit 54.255.61.23 permit
56.124.6.228 permit
57.103.64.0/18 permit 57.103.64.0/18 permit
57.129.93.249 permit 57.129.93.249 permit
62.13.128.0/24 permit 62.13.128.0/24 permit
@@ -373,7 +368,6 @@
64.132.88.0/23 permit 64.132.88.0/23 permit
64.132.92.0/24 permit 64.132.92.0/24 permit
64.181.194.190 permit 64.181.194.190 permit
64.181.213.254 permit
64.207.219.7 permit 64.207.219.7 permit
64.207.219.8 permit 64.207.219.8 permit
64.207.219.9 permit 64.207.219.9 permit
@@ -403,12 +397,22 @@
64.207.219.143 permit 64.207.219.143 permit
64.233.160.0/19 permit 64.233.160.0/19 permit
65.52.80.137 permit 65.52.80.137 permit
65.54.121.120/29 permit
65.55.29.77 permit 65.55.29.77 permit
65.55.33.64/28 permit
65.55.42.224/28 permit 65.55.42.224/28 permit
65.55.52.224/27 permit
65.55.78.128/25 permit
65.55.81.48/28 permit
65.55.94.0/25 permit
65.55.113.64/26 permit
65.55.126.0/25 permit
65.55.174.0/25 permit
65.55.178.128/27 permit
65.55.234.192/26 permit
65.110.161.77 permit 65.110.161.77 permit
65.123.29.213 permit 65.123.29.213 permit
65.123.29.220 permit 65.123.29.220 permit
65.154.166.0/24 permit
65.212.180.36 permit 65.212.180.36 permit
66.102.0.0/20 permit 66.102.0.0/20 permit
66.119.150.192/26 permit 66.119.150.192/26 permit
@@ -459,11 +463,7 @@
66.218.75.252/31 permit 66.218.75.252/31 permit
66.218.75.254 permit 66.218.75.254 permit
66.220.144.128/25 permit 66.220.144.128/25 permit
66.220.144.178 permit
66.220.144.179 permit
66.220.155.0/24 permit 66.220.155.0/24 permit
66.220.155.178 permit
66.220.155.179 permit
66.220.157.0/25 permit 66.220.157.0/25 permit
66.231.80.0/20 permit 66.231.80.0/20 permit
66.240.227.0/24 permit 66.240.227.0/24 permit
@@ -528,9 +528,8 @@
69.162.98.0/24 permit 69.162.98.0/24 permit
69.169.224.0/20 permit 69.169.224.0/20 permit
69.171.232.0/24 permit 69.171.232.0/24 permit
69.171.232.180 permit
69.171.232.181 permit
69.171.244.0/23 permit 69.171.244.0/23 permit
70.37.151.128/25 permit
70.42.149.35 permit 70.42.149.35 permit
72.3.185.0/24 permit 72.3.185.0/24 permit
72.14.192.0/18 permit 72.14.192.0/18 permit
@@ -651,28 +650,18 @@
77.238.189.148/30 permit 77.238.189.148/30 permit
79.135.106.0/24 permit 79.135.106.0/24 permit
79.135.107.0/24 permit 79.135.107.0/24 permit
80.225.160.128/25 permit
81.169.146.243 permit 81.169.146.243 permit
81.169.146.245 permit 81.169.146.245 permit
81.169.146.246 permit 81.169.146.246 permit
81.223.46.0/27 permit 81.223.46.0/27 permit
82.165.159.2 permit
82.165.159.3 permit
82.165.159.4 permit
82.165.159.12 permit 82.165.159.12 permit
82.165.159.13 permit 82.165.159.13 permit
82.165.159.14 permit 82.165.159.14 permit
82.165.159.34 permit
82.165.159.35 permit
82.165.159.40 permit 82.165.159.40 permit
82.165.159.41 permit 82.165.159.41 permit
82.165.159.42 permit 82.165.159.42 permit
82.165.159.45 permit
82.165.159.130 permit 82.165.159.130 permit
82.165.159.131 permit 82.165.159.131 permit
84.8.68.0/25 permit
84.8.192.128/25 permit
84.8.224.128/25 permit
85.9.206.169 permit 85.9.206.169 permit
85.9.210.45 permit 85.9.210.45 permit
85.158.136.0/21 permit 85.158.136.0/21 permit
@@ -723,11 +712,11 @@
87.248.117.205 permit 87.248.117.205 permit
87.253.232.0/21 permit 87.253.232.0/21 permit
89.22.108.0/24 permit 89.22.108.0/24 permit
91.198.2.177 permit 91.198.2.0/24 permit
91.198.2.217 permit
91.198.2.222 permit
91.211.240.0/22 permit 91.211.240.0/22 permit
94.236.119.0/26 permit 94.236.119.0/26 permit
94.245.112.0/27 permit
94.245.112.10/31 permit
95.131.104.0/21 permit 95.131.104.0/21 permit
95.217.114.154 permit 95.217.114.154 permit
96.43.144.0/20 permit 96.43.144.0/20 permit
@@ -1222,9 +1211,6 @@
99.78.197.208/28 permit 99.78.197.208/28 permit
103.9.96.0/22 permit 103.9.96.0/22 permit
103.28.42.0/24 permit 103.28.42.0/24 permit
103.84.217.15 permit
103.84.217.238 permit
103.89.75.238 permit
103.151.192.0/23 permit 103.151.192.0/23 permit
103.168.172.128/27 permit 103.168.172.128/27 permit
103.237.104.0/22 permit 103.237.104.0/22 permit
@@ -1368,6 +1354,11 @@
108.179.144.0/20 permit 108.179.144.0/20 permit
109.224.244.0/24 permit 109.224.244.0/24 permit
109.237.142.0/24 permit 109.237.142.0/24 permit
111.221.23.128/25 permit
111.221.26.0/27 permit
111.221.66.0/25 permit
111.221.69.128/25 permit
111.221.112.0/21 permit
112.19.199.64/29 permit 112.19.199.64/29 permit
112.19.242.64/29 permit 112.19.242.64/29 permit
116.214.12.47 permit 116.214.12.47 permit
@@ -1385,9 +1376,6 @@
117.120.16.0/21 permit 117.120.16.0/21 permit
119.42.242.52/31 permit 119.42.242.52/31 permit
119.42.242.156 permit 119.42.242.156 permit
121.244.91.48 permit
121.244.91.52 permit
122.15.156.182 permit
123.126.78.64/29 permit 123.126.78.64/29 permit
124.108.96.24/31 permit 124.108.96.24/31 permit
124.108.96.28/31 permit 124.108.96.28/31 permit
@@ -1424,20 +1412,6 @@
129.146.88.28 permit 129.146.88.28 permit
129.146.147.105 permit 129.146.147.105 permit
129.146.236.58 permit 129.146.236.58 permit
129.148.135.0/25 permit
129.148.148.0/25 permit
129.148.164.0/25 permit
129.148.180.0/25 permit
129.148.215.0/25 permit
129.149.6.0/25 permit
129.149.22.0/25 permit
129.149.38.0/25 permit
129.149.52.0/25 permit
129.149.68.0/25 permit
129.149.84.0/25 permit
129.149.100.0/25 permit
129.149.118.0/25 permit
129.149.126.0/25 permit
129.151.67.221 permit 129.151.67.221 permit
129.153.62.216 permit 129.153.62.216 permit
129.153.104.71 permit 129.153.104.71 permit
@@ -1446,23 +1420,19 @@
129.153.194.228 permit 129.153.194.228 permit
129.154.255.129 permit 129.154.255.129 permit
129.158.56.255 permit 129.158.56.255 permit
129.158.62.153 permit
129.159.22.159 permit 129.159.22.159 permit
129.159.87.137 permit 129.159.87.137 permit
129.213.195.191 permit 129.213.195.191 permit
130.35.116.0/25 permit
130.61.9.72 permit 130.61.9.72 permit
130.162.39.83 permit 130.162.39.83 permit
130.248.172.0/24 permit 130.248.172.0/24 permit
130.248.173.0/24 permit 130.248.173.0/24 permit
131.186.12.0/25 permit
131.253.30.0/24 permit 131.253.30.0/24 permit
131.253.121.0/26 permit 131.253.121.0/26 permit
132.145.13.209 permit 132.145.13.209 permit
132.226.26.225 permit 132.226.26.225 permit
132.226.49.32 permit 132.226.49.32 permit
132.226.56.24 permit 132.226.56.24 permit
134.98.248.128/25 permit
134.128.64.0/19 permit 134.128.64.0/19 permit
134.128.96.0/19 permit 134.128.96.0/19 permit
134.170.27.8 permit 134.170.27.8 permit
@@ -1470,10 +1440,6 @@
134.170.141.64/26 permit 134.170.141.64/26 permit
134.170.143.0/24 permit 134.170.143.0/24 permit
134.170.174.0/24 permit 134.170.174.0/24 permit
135.84.80.0/24 permit
135.84.81.0/24 permit
135.84.82.0/24 permit
135.84.83.0/24 permit
135.84.216.0/22 permit 135.84.216.0/22 permit
136.143.160.0/24 permit 136.143.160.0/24 permit
136.143.161.0/24 permit 136.143.161.0/24 permit
@@ -1493,10 +1459,6 @@
136.147.182.0/24 permit 136.147.182.0/24 permit
136.147.224.0/20 permit 136.147.224.0/20 permit
136.179.50.206 permit 136.179.50.206 permit
136.248.224.128/25 permit
136.248.232.128/25 permit
138.1.108.0/25 permit
138.1.170.0/24 permit
139.60.152.0/22 permit 139.60.152.0/22 permit
139.138.35.44 permit 139.138.35.44 permit
139.138.46.121 permit 139.138.46.121 permit
@@ -1504,12 +1466,8 @@
139.138.46.219 permit 139.138.46.219 permit
139.138.57.55 permit 139.138.57.55 permit
139.138.58.119 permit 139.138.58.119 permit
139.167.79.86 permit
139.177.108.0/25 permit
139.180.17.0/24 permit 139.180.17.0/24 permit
140.238.148.191 permit 140.238.148.191 permit
141.148.55.217 permit
141.148.91.244 permit
141.148.159.229 permit 141.148.159.229 permit
141.193.32.0/23 permit 141.193.32.0/23 permit
141.193.184.32/27 permit 141.193.184.32/27 permit
@@ -1544,9 +1502,6 @@
146.88.28.0/24 permit 146.88.28.0/24 permit
146.148.116.76 permit 146.148.116.76 permit
147.154.32.0/25 permit 147.154.32.0/25 permit
147.154.63.0/24 permit
147.154.126.0/24 permit
147.154.191.0/24 permit
147.243.1.47 permit 147.243.1.47 permit
147.243.1.48 permit 147.243.1.48 permit
147.243.1.153 permit 147.243.1.153 permit
@@ -1558,32 +1513,33 @@
149.72.234.184 permit 149.72.234.184 permit
149.72.248.236 permit 149.72.248.236 permit
149.97.173.180 permit 149.97.173.180 permit
149.118.160.128/25 permit
150.136.21.199 permit
150.230.98.160 permit 150.230.98.160 permit
151.145.38.14 permit 151.145.38.14 permit
152.67.105.195 permit 152.67.105.195 permit
152.69.200.236 permit 152.69.200.236 permit
152.70.155.126 permit 152.70.155.126 permit
155.248.135.128/25 permit
155.248.140.0/25 permit
155.248.148.0/25 permit
155.248.208.51 permit 155.248.208.51 permit
155.248.220.138 permit 155.248.220.138 permit
155.248.234.149 permit 155.248.234.149 permit
155.248.237.141 permit 155.248.237.141 permit
157.55.9.128/25 permit
157.55.11.0/25 permit
157.55.49.0/25 permit
157.55.61.0/24 permit
157.55.157.128/25 permit
157.55.225.0/25 permit
157.56.24.0/25 permit
157.56.120.128/26 permit 157.56.120.128/26 permit
157.56.232.0/21 permit
157.56.240.0/20 permit
157.56.248.0/21 permit
157.58.30.128/25 permit 157.58.30.128/25 permit
157.58.196.96/29 permit 157.58.196.96/29 permit
157.58.249.3 permit 157.58.249.3 permit
157.137.32.128/25 permit
157.137.96.128/25 permit
157.151.208.65 permit 157.151.208.65 permit
157.255.1.64/29 permit 157.255.1.64/29 permit
158.101.211.207 permit 158.101.211.207 permit
158.247.16.0/20 permit 158.247.16.0/20 permit
158.247.100.0/25 permit
159.13.4.0/25 permit
159.92.154.0/24 permit 159.92.154.0/24 permit
159.92.155.0/24 permit 159.92.155.0/24 permit
159.92.157.0/24 permit 159.92.157.0/24 permit
@@ -1605,10 +1561,8 @@
159.135.224.0/20 permit 159.135.224.0/20 permit
159.135.228.10 permit 159.135.228.10 permit
159.183.0.0/16 permit 159.183.0.0/16 permit
159.183.14.233 permit
159.183.68.71 permit 159.183.68.71 permit
159.183.79.38 permit 159.183.79.38 permit
159.183.121.182 permit
159.183.129.172 permit 159.183.129.172 permit
160.1.62.192 permit 160.1.62.192 permit
161.38.192.0/20 permit 161.38.192.0/20 permit
@@ -1617,7 +1571,6 @@
161.71.64.0/20 permit 161.71.64.0/20 permit
162.88.4.0/23 permit 162.88.4.0/23 permit
162.88.8.0/24 permit 162.88.8.0/24 permit
162.88.24.0/23 permit
162.88.24.0/24 permit 162.88.24.0/24 permit
162.88.25.0/24 permit 162.88.25.0/24 permit
162.88.36.0/24 permit 162.88.36.0/24 permit
@@ -1629,18 +1582,9 @@
163.114.135.16 permit 163.114.135.16 permit
163.116.128.0/17 permit 163.116.128.0/17 permit
163.192.116.87 permit 163.192.116.87 permit
163.192.125.176 permit
163.192.196.146 permit
163.192.204.161 permit
164.152.23.32 permit 164.152.23.32 permit
164.152.25.241 permit 164.152.25.241 permit
164.177.132.168/30 permit 164.177.132.168/30 permit
165.1.100.0/25 permit
165.173.128.0/24 permit
165.173.180.1 permit
165.173.180.250/31 permit
165.173.182.250/31 permit
165.173.189.205 permit
166.78.68.0/22 permit 166.78.68.0/22 permit
166.78.68.221 permit 166.78.68.221 permit
166.78.69.169 permit 166.78.69.169 permit
@@ -1659,12 +1603,9 @@
167.89.75.126 permit 167.89.75.126 permit
167.89.75.136 permit 167.89.75.136 permit
167.89.75.164 permit 167.89.75.164 permit
167.89.101.2 permit
167.89.101.192/28 permit
167.220.67.232/29 permit 167.220.67.232/29 permit
168.107.248.128/25 permit
168.110.160.128/25 permit
168.110.248.128/25 permit
168.129.184.128/25 permit
168.129.248.128/25 permit
168.138.5.36 permit 168.138.5.36 permit
168.138.73.51 permit 168.138.73.51 permit
168.138.77.31 permit 168.138.77.31 permit
@@ -1673,20 +1614,6 @@
168.245.12.252 permit 168.245.12.252 permit
168.245.46.9 permit 168.245.46.9 permit
168.245.127.231 permit 168.245.127.231 permit
169.148.129.0/24 permit
169.148.131.0/24 permit
169.148.138.0/24 permit
169.148.142.10 permit
169.148.142.33 permit
169.148.144.0/25 permit
169.148.144.10 permit
169.148.146.0/23 permit
169.148.174.10 permit
169.148.175.3 permit
169.148.179.3 permit
169.148.188.0/24 permit
169.148.188.182 permit
170.9.232.254 permit
170.10.128.0/24 permit 170.10.128.0/24 permit
170.10.129.0/24 permit 170.10.129.0/24 permit
170.10.132.56/29 permit 170.10.132.56/29 permit
@@ -1696,6 +1623,7 @@
173.0.84.224/27 permit 173.0.84.224/27 permit
173.0.94.244/30 permit 173.0.94.244/30 permit
173.194.0.0/16 permit 173.194.0.0/16 permit
173.194.0.0/17 permit
173.203.79.182 permit 173.203.79.182 permit
173.203.81.39 permit 173.203.81.39 permit
173.224.161.128/25 permit 173.224.161.128/25 permit
@@ -1719,7 +1647,8 @@
182.50.78.64/28 permit 182.50.78.64/28 permit
183.240.219.64/29 permit 183.240.219.64/29 permit
185.4.120.0/22 permit 185.4.120.0/22 permit
185.11.255.144 permit 185.11.253.128/27 permit
185.11.255.0/24 permit
185.12.80.0/22 permit 185.12.80.0/22 permit
185.28.196.0/22 permit 185.28.196.0/22 permit
185.58.84.93 permit 185.58.84.93 permit
@@ -1733,16 +1662,8 @@
185.138.56.128/25 permit 185.138.56.128/25 permit
185.189.236.0/22 permit 185.189.236.0/22 permit
185.211.120.0/22 permit 185.211.120.0/22 permit
185.233.188.68 permit 185.233.188.0/23 permit
185.233.188.75 permit 185.233.190.0/23 permit
185.233.188.84 permit
185.233.188.160 permit
185.233.188.176 permit
185.233.188.247 permit
185.233.189.44 permit
185.233.189.98 permit
185.233.189.122 permit
185.233.189.228 permit
185.250.236.0/22 permit 185.250.236.0/22 permit
185.250.239.148 permit 185.250.239.148 permit
185.250.239.168 permit 185.250.239.168 permit
@@ -1801,24 +1722,8 @@
192.18.139.154 permit 192.18.139.154 permit
192.18.145.36 permit 192.18.145.36 permit
192.18.152.58 permit 192.18.152.58 permit
192.22.32.128/25 permit
192.22.96.128/25 permit
192.22.160.128/25 permit
192.22.224.128/25 permit
192.28.128.0/18 permit 192.28.128.0/18 permit
192.29.24.0/25 permit
192.29.44.0/25 permit
192.29.72.0/25 permit
192.29.88.0/25 permit
192.29.103.128/25 permit 192.29.103.128/25 permit
192.29.134.0/25 permit
192.29.151.128/25 permit
192.29.172.0/25 permit
192.29.178.0/25 permit
192.29.200.0/25 permit
192.29.216.0/25 permit
192.29.232.0/25 permit
192.29.248.0/25 permit
192.30.252.0/22 permit 192.30.252.0/22 permit
192.161.144.0/20 permit 192.161.144.0/20 permit
192.162.87.0/24 permit 192.162.87.0/24 permit
@@ -1826,12 +1731,15 @@
192.237.159.42 permit 192.237.159.42 permit
192.237.159.43 permit 192.237.159.43 permit
192.254.112.0/20 permit 192.254.112.0/20 permit
192.254.112.60 permit
192.254.112.98/31 permit
192.254.113.10 permit
192.254.113.101 permit
192.254.114.176 permit
193.109.254.0/23 permit 193.109.254.0/23 permit
193.122.128.100 permit 193.122.128.100 permit
193.123.56.63 permit 193.123.56.63 permit
193.142.157.15 permit 193.142.157.0/24 permit
193.142.157.125 permit
193.142.157.158 permit
193.142.157.191 permit 193.142.157.191 permit
193.142.157.198 permit 193.142.157.198 permit
194.19.134.0/25 permit 194.19.134.0/25 permit
@@ -1891,16 +1799,7 @@
199.16.156.0/22 permit 199.16.156.0/22 permit
199.33.145.1 permit 199.33.145.1 permit
199.33.145.32 permit 199.33.145.32 permit
199.34.22.36 permit
199.59.148.0/22 permit 199.59.148.0/22 permit
199.67.80.2 permit
199.67.80.20 permit
199.67.82.2 permit
199.67.82.20 permit
199.67.84.0/24 permit
199.67.86.0/24 permit
199.67.88.0/24 permit
199.67.90.0/24 permit
199.101.161.130 permit 199.101.161.130 permit
199.101.162.0/25 permit 199.101.162.0/25 permit
199.122.120.0/21 permit 199.122.120.0/21 permit
@@ -1953,11 +1852,10 @@
204.14.232.64/28 permit 204.14.232.64/28 permit
204.14.234.64/28 permit 204.14.234.64/28 permit
204.75.142.0/24 permit 204.75.142.0/24 permit
204.79.197.212 permit
204.92.114.187 permit 204.92.114.187 permit
204.92.114.203 permit 204.92.114.203 permit
204.92.114.204/31 permit 204.92.114.204/31 permit
204.141.32.0/23 permit
204.141.42.0/23 permit
204.216.164.202 permit 204.216.164.202 permit
204.220.160.0/21 permit 204.220.160.0/21 permit
204.220.168.0/21 permit 204.220.168.0/21 permit
@@ -1980,13 +1878,23 @@
206.165.246.80/29 permit 206.165.246.80/29 permit
206.191.224.0/19 permit 206.191.224.0/19 permit
206.246.157.1 permit 206.246.157.1 permit
207.46.4.128/25 permit
207.46.22.35 permit 207.46.22.35 permit
207.46.50.72 permit 207.46.50.72 permit
207.46.50.82 permit 207.46.50.82 permit
207.46.50.192/26 permit
207.46.50.224 permit
207.46.52.71 permit 207.46.52.71 permit
207.46.52.79 permit 207.46.52.79 permit
207.46.58.128/25 permit
207.46.116.128/29 permit
207.46.132.128/27 permit
207.46.198.0/25 permit
207.46.200.0/27 permit
207.67.38.0/24 permit 207.67.38.0/24 permit
207.67.98.192/27 permit 207.67.98.192/27 permit
207.68.176.0/26 permit
207.68.176.96/27 permit
207.97.204.96/29 permit 207.97.204.96/29 permit
207.126.144.0/20 permit 207.126.144.0/20 permit
207.171.160.0/19 permit 207.171.160.0/19 permit
@@ -1994,7 +1902,6 @@
207.211.30.128/25 permit 207.211.30.128/25 permit
207.211.31.0/25 permit 207.211.31.0/25 permit
207.211.41.113 permit 207.211.41.113 permit
207.211.132.0/25 permit
207.218.90.0/24 permit 207.218.90.0/24 permit
207.218.90.122 permit 207.218.90.122 permit
207.250.68.0/24 permit 207.250.68.0/24 permit
@@ -2002,8 +1909,6 @@
208.43.21.28/30 permit 208.43.21.28/30 permit
208.43.21.64/29 permit 208.43.21.64/29 permit
208.43.21.72/30 permit 208.43.21.72/30 permit
208.56.9.224 permit
208.56.13.196 permit
208.64.132.0/22 permit 208.64.132.0/22 permit
208.71.40.63 permit 208.71.40.63 permit
208.71.40.64/31 permit 208.71.40.64/31 permit
@@ -2030,7 +1935,6 @@
208.71.42.214 permit 208.71.42.214 permit
208.72.249.240/29 permit 208.72.249.240/29 permit
208.75.120.0/22 permit 208.75.120.0/22 permit
208.76.62.0/23 permit
208.76.62.0/24 permit 208.76.62.0/24 permit
208.76.63.0/24 permit 208.76.63.0/24 permit
208.82.237.96/29 permit 208.82.237.96/29 permit
@@ -2089,19 +1993,11 @@
212.82.111.228/31 permit 212.82.111.228/31 permit
212.82.111.230 permit 212.82.111.230 permit
212.123.28.40 permit 212.123.28.40 permit
212.227.15.3 permit
212.227.15.4 permit
212.227.15.5 permit
212.227.15.6 permit
212.227.15.7 permit 212.227.15.7 permit
212.227.15.8 permit 212.227.15.8 permit
212.227.15.14 permit
212.227.15.15 permit 212.227.15.15 permit
212.227.15.18 permit 212.227.15.18 permit
212.227.15.19 permit 212.227.15.19 permit
212.227.15.25 permit
212.227.15.26 permit
212.227.15.29 permit
212.227.15.44 permit 212.227.15.44 permit
212.227.15.45 permit 212.227.15.45 permit
212.227.15.46 permit 212.227.15.46 permit
@@ -2109,17 +2005,11 @@
212.227.15.50 permit 212.227.15.50 permit
212.227.15.52 permit 212.227.15.52 permit
212.227.15.53 permit 212.227.15.53 permit
212.227.15.54 permit
212.227.15.55 permit
212.227.17.1 permit 212.227.17.1 permit
212.227.17.2 permit 212.227.17.2 permit
212.227.17.7 permit 212.227.17.7 permit
212.227.17.11 permit
212.227.17.12 permit
212.227.17.16 permit 212.227.17.16 permit
212.227.17.17 permit 212.227.17.17 permit
212.227.17.18 permit
212.227.17.19 permit
212.227.17.20 permit 212.227.17.20 permit
212.227.17.21 permit 212.227.17.21 permit
212.227.17.22 permit 212.227.17.22 permit
@@ -2139,10 +2029,14 @@
212.227.126.225 permit 212.227.126.225 permit
212.227.126.226 permit 212.227.126.226 permit
212.227.126.227 permit 212.227.126.227 permit
213.95.19.64/27 permit
213.95.135.4 permit
213.199.128.139 permit 213.199.128.139 permit
213.199.128.145 permit 213.199.128.145 permit
213.199.138.181 permit 213.199.138.181 permit
213.199.138.191 permit 213.199.138.191 permit
213.199.161.128/27 permit
213.199.177.0/26 permit
216.17.150.242 permit 216.17.150.242 permit
216.17.150.251 permit 216.17.150.251 permit
216.24.224.0/20 permit 216.24.224.0/20 permit
@@ -2170,6 +2064,7 @@
216.39.62.60/31 permit 216.39.62.60/31 permit
216.39.62.136/29 permit 216.39.62.136/29 permit
216.39.62.144/31 permit 216.39.62.144/31 permit
216.58.192.0/19 permit
216.66.217.240/29 permit 216.66.217.240/29 permit
216.71.138.33 permit 216.71.138.33 permit
216.71.152.207 permit 216.71.152.207 permit
@@ -2191,7 +2086,6 @@
216.136.168.80/28 permit 216.136.168.80/28 permit
216.139.64.0/19 permit 216.139.64.0/19 permit
216.145.221.0/24 permit 216.145.221.0/24 permit
216.146.32.0/23 permit
216.146.32.0/24 permit 216.146.32.0/24 permit
216.146.33.0/24 permit 216.146.33.0/24 permit
216.198.0.0/18 permit 216.198.0.0/18 permit
@@ -2200,8 +2094,6 @@
216.205.24.0/24 permit 216.205.24.0/24 permit
216.221.160.0/19 permit 216.221.160.0/19 permit
216.239.32.0/19 permit 216.239.32.0/19 permit
217.72.192.77 permit
217.72.192.78 permit
217.77.141.52 permit 217.77.141.52 permit
217.77.141.59 permit 217.77.141.59 permit
217.175.194.0/24 permit 217.175.194.0/24 permit
@@ -2212,7 +2104,6 @@
223.165.120.0/23 permit 223.165.120.0/23 permit
2001:0868:0100:0600::/64 permit 2001:0868:0100:0600::/64 permit
2001:4860:4000::/36 permit 2001:4860:4000::/36 permit
2001:4860:4864::/56 permit
2001:748:100:40::2:0/112 permit 2001:748:100:40::2:0/112 permit
2001:748:400:1300::3 permit 2001:748:400:1300::3 permit
2001:748:400:1300::4 permit 2001:748:400:1300::4 permit
@@ -2230,13 +2121,12 @@
2001:748:400:3301::3 permit 2001:748:400:3301::3 permit
2001:748:400:3301::4 permit 2001:748:400:3301::4 permit
2404:6800:4000::/36 permit 2404:6800:4000::/36 permit
2404:6800:4864::/56 permit 2603:1010:3:3::5b permit
2603:1061:14:72::1 permit 2603:1020:201:10::10f permit
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit 2603:1030:20e:3::23c permit
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit 2603:1030:b:3::152 permit
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit 2603:1030:c02:8::14 permit
2607:f8b0:4000::/36 permit 2607:f8b0:4000::/36 permit
2607:f8b0:4864::/56 permit
2620:109:c003:104::/64 permit 2620:109:c003:104::/64 permit
2620:109:c003:104::215 permit 2620:109:c003:104::215 permit
2620:109:c006:104::/64 permit 2620:109:c006:104::/64 permit
@@ -2249,6 +2139,5 @@
2620:119:50c0:207::/64 permit 2620:119:50c0:207::/64 permit
2620:119:50c0:207::215 permit 2620:119:50c0:207::215 permit
2800:3f0:4000::/36 permit 2800:3f0:4000::/36 permit
2800:3f0:4864::/56 permit
49.12.4.251 permit # checks.mailcow.email 49.12.4.251 permit # checks.mailcow.email
2a01:4f8:c17:7906::10 permit # checks.mailcow.email 2a01:4f8:c17:7906::10 permit # checks.mailcow.email
+42 -278
View File
@@ -146,171 +146,8 @@ rspamd_config:register_symbol({
return false return false
end end
-- Helper function to parse IPv6 into 8 segments
local function ipv6_to_segments(ip_str)
-- Remove zone identifier if present (e.g., %eth0)
ip_str = ip_str:gsub("%%.*$", "")
local segments = {}
-- Handle :: compression
if ip_str:find('::') then
local before, after = ip_str:match('^(.*)::(.*)$')
before = before or ''
after = after or ''
local before_parts = {}
local after_parts = {}
if before ~= '' then
for seg in before:gmatch('[^:]+') do
table.insert(before_parts, tonumber(seg, 16) or 0)
end
end
if after ~= '' then
for seg in after:gmatch('[^:]+') do
table.insert(after_parts, tonumber(seg, 16) or 0)
end
end
-- Add before segments
for _, seg in ipairs(before_parts) do
table.insert(segments, seg)
end
-- Add compressed zeros
local zeros_needed = 8 - #before_parts - #after_parts
for i = 1, zeros_needed do
table.insert(segments, 0)
end
-- Add after segments
for _, seg in ipairs(after_parts) do
table.insert(segments, seg)
end
else
-- No compression
for seg in ip_str:gmatch('[^:]+') do
table.insert(segments, tonumber(seg, 16) or 0)
end
end
-- Ensure we have exactly 8 segments
while #segments < 8 do
table.insert(segments, 0)
end
return segments
end
-- Generate all common IPv6 notations
local function get_ipv6_variants(ip_str)
local variants = {}
local seen = {}
local function add_variant(v)
if v and not seen[v] then
table.insert(variants, v)
seen[v] = true
end
end
-- For IPv4, just return the original
if not ip_str:find(':') then
add_variant(ip_str)
return variants
end
local segments = ipv6_to_segments(ip_str)
-- 1. Fully expanded form (all zeros shown as 0000)
local expanded_parts = {}
for _, seg in ipairs(segments) do
table.insert(expanded_parts, string.format('%04x', seg))
end
add_variant(table.concat(expanded_parts, ':'))
-- 2. Standard form (no leading zeros, but all segments present)
local standard_parts = {}
for _, seg in ipairs(segments) do
table.insert(standard_parts, string.format('%x', seg))
end
add_variant(table.concat(standard_parts, ':'))
-- 3. Find all possible :: compressions
-- RFC 5952: compress the longest run of consecutive zeros
-- But we need to check all possibilities since Redis might have any form
-- Find all zero runs
local zero_runs = {}
local in_run = false
local run_start = 0
local run_length = 0
for i = 1, 8 do
if segments[i] == 0 then
if not in_run then
in_run = true
run_start = i
run_length = 1
else
run_length = run_length + 1
end
else
if in_run then
if run_length >= 1 then -- Allow single zero compression too
table.insert(zero_runs, {start = run_start, length = run_length})
end
in_run = false
end
end
end
-- Don't forget the last run
if in_run and run_length >= 1 then
table.insert(zero_runs, {start = run_start, length = run_length})
end
-- Generate variant for each zero run compression
for _, run in ipairs(zero_runs) do
local parts = {}
-- Before compression
for i = 1, run.start - 1 do
table.insert(parts, string.format('%x', segments[i]))
end
-- The compression
if run.start == 1 then
table.insert(parts, '')
table.insert(parts, '')
elseif run.start + run.length - 1 == 8 then
table.insert(parts, '')
table.insert(parts, '')
else
table.insert(parts, '')
end
-- After compression
for i = run.start + run.length, 8 do
table.insert(parts, string.format('%x', segments[i]))
end
local compressed = table.concat(parts, ':'):gsub('::+', '::')
add_variant(compressed)
end
return variants
end
local from_ip_string = tostring(ip) local from_ip_string = tostring(ip)
local ip_check_table = {} ip_check_table = {from_ip_string}
-- Add all variants of the exact IP
for _, variant in ipairs(get_ipv6_variants(from_ip_string)) do
table.insert(ip_check_table, variant)
end
local maxbits = 128 local maxbits = 128
local minbits = 32 local minbits = 32
@@ -318,18 +155,10 @@ rspamd_config:register_symbol({
maxbits = 32 maxbits = 32
minbits = 8 minbits = 8
end end
-- Add all CIDR notations with variants
for i=maxbits,minbits,-1 do for i=maxbits,minbits,-1 do
local masked_ip = ip:apply_mask(i) local nip = ip:apply_mask(i):to_string() .. "/" .. i
local cidr_base = masked_ip:to_string() table.insert(ip_check_table, nip)
for _, variant in ipairs(get_ipv6_variants(cidr_base)) do
local cidr = variant .. "/" .. i
table.insert(ip_check_table, cidr)
end end
end
local function keep_spam_cb(err, data) local function keep_spam_cb(err, data)
if err then if err then
rspamd_logger.infox(rspamd_config, "keep_spam query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err) rspamd_logger.infox(rspamd_config, "keep_spam query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err)
@@ -337,15 +166,12 @@ rspamd_config:register_symbol({
else else
for k,v in pairs(data) do for k,v in pairs(data) do
if (v and v ~= userdata and v == '1') then if (v and v ~= userdata and v == '1') then
rspamd_logger.infox(rspamd_config, "found ip %s (checked as: %s) in keep_spam map, setting pre-result accept", from_ip_string, ip_check_table[k]) rspamd_logger.infox(rspamd_config, "found ip in keep_spam map, setting pre-result")
task:set_pre_result('accept', 'ip matched with forward hosts', 'keep_spam') task:set_pre_result('accept', 'ip matched with forward hosts', 'keep_spam')
task:set_flag('no_stat')
return
end end
end end
end end
end end
table.insert(ip_check_table, 1, 'KEEP_SPAM') table.insert(ip_check_table, 1, 'KEEP_SPAM')
local redis_ret_user = rspamd_redis_make_request(task, local redis_ret_user = rspamd_redis_make_request(task,
redis_params, -- connect params redis_params, -- connect params
@@ -384,7 +210,6 @@ rspamd_config:register_symbol({
rspamd_config:register_symbol({ rspamd_config:register_symbol({
name = 'TAG_MOO', name = 'TAG_MOO',
type = 'postfilter', type = 'postfilter',
flags = 'ignore_passthrough',
callback = function(task) callback = function(task)
local util = require("rspamd_util") local util = require("rspamd_util")
local rspamd_logger = require "rspamd_logger" local rspamd_logger = require "rspamd_logger"
@@ -392,7 +217,9 @@ rspamd_config:register_symbol({
local rspamd_http = require "rspamd_http" local rspamd_http = require "rspamd_http"
local rcpts = task:get_recipients('smtp') local rcpts = task:get_recipients('smtp')
local lua_util = require "lua_util" local lua_util = require "lua_util"
local tagged_rcpt = task:get_symbol("TAGGED_RCPT") local tagged_rcpt = task:get_symbol("TAGGED_RCPT")
local mailcow_domain = task:get_symbol("RCPT_MAILCOW_DOMAIN")
local function remove_moo_tag() local function remove_moo_tag()
local moo_tag_header = task:get_header('X-Moo-Tag', false) local moo_tag_header = task:get_header('X-Moo-Tag', false)
@@ -404,78 +231,32 @@ rspamd_config:register_symbol({
return true return true
end end
-- Check if we have exactly one recipient if tagged_rcpt and tagged_rcpt[1].options and mailcow_domain then
if not (rcpts and #rcpts == 1) then local tag = tagged_rcpt[1].options[1]
rspamd_logger.infox("TAG_MOO: not exactly one rcpt (%s), removing moo tag", rcpts and #rcpts or 0) rspamd_logger.infox("found tag: %s", tag)
remove_moo_tag()
return
end
local rcpt_addr = rcpts[1]['addr']
local rcpt_user = rcpts[1]['user']
local rcpt_domain = rcpts[1]['domain']
-- Check if recipient has a tag (contains '+')
local tag = nil
if tagged_rcpt ~= nil then
tag = tagged_rcpt
rspamd_logger.infox("TAG_MOO: found tag in recipient: %s (base: %s, tag: %s)", rcpt_addr, base_user, tag)
end
if not tag then
rspamd_logger.infox("TAG_MOO: no tag found in recipient %s, removing moo tag", rcpt_addr)
remove_moo_tag()
return
end
-- Optional: Check if domain is a mailcow domain
-- When KEEP_SPAM is active, RCPT_MAILCOW_DOMAIN might not be set
-- If the mail is being delivered, we can assume it's valid
local mailcow_domain = task:get_symbol("RCPT_MAILCOW_DOMAIN")
if not mailcow_domain then
rspamd_logger.infox("TAG_MOO: RCPT_MAILCOW_DOMAIN not set (possibly due to pre-result), proceeding anyway for domain %s", rcpt_domain)
end
local action = task:get_metric_action('default') local action = task:get_metric_action('default')
rspamd_logger.infox("TAG_MOO: metric action: %s", action) rspamd_logger.infox("metric action now: %s", action)
-- Check if we have a pre-result (e.g., from KEEP_SPAM or POSTMASTER_HANDLER) if action ~= 'no action' and action ~= 'greylist' then
local allow_processing = false rspamd_logger.infox("skipping tag handler for action: %s", action)
if task.has_pre_result then
local has_pre, pre_action = task:has_pre_result()
if has_pre then
rspamd_logger.infox("TAG_MOO: pre-result detected: %s", tostring(pre_action))
if pre_action == 'accept' then
allow_processing = true
rspamd_logger.infox("TAG_MOO: pre-result is accept, will process")
end
end
end
-- Allow processing for mild actions or when we have pre-result accept
if not allow_processing and action ~= 'no action' and action ~= 'greylist' then
rspamd_logger.infox("TAG_MOO: skipping tag handler for action: %s", action)
remove_moo_tag() remove_moo_tag()
return true return true
end end
rspamd_logger.infox("TAG_MOO: processing allowed")
local function http_callback(err_message, code, body, headers) local function http_callback(err_message, code, body, headers)
if body ~= nil and body ~= "" then if body ~= nil and body ~= "" then
rspamd_logger.infox(rspamd_config, "TAG_MOO: expanding rcpt to \"%s\"", body) rspamd_logger.infox(rspamd_config, "expanding rcpt to \"%s\"", body)
local function tag_callback_subject(err, data) local function tag_callback_subject(err, data)
if err or type(data) ~= 'string' or data == '' then if err or type(data) ~= 'string' then
rspamd_logger.infox(rspamd_config, "TAG_MOO: subject tag handler rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\") - trying subfolder tag handler...", body, data, err) rspamd_logger.infox(rspamd_config, "subject tag handler rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\") - trying subfolder tag handler...", body, data, err)
local function tag_callback_subfolder(err, data) local function tag_callback_subfolder(err, data)
if err or type(data) ~= 'string' or data == '' then if err or type(data) ~= 'string' then
rspamd_logger.infox(rspamd_config, "TAG_MOO: subfolder tag handler for rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\")", body, data, err) rspamd_logger.infox(rspamd_config, "subfolder tag handler for rcpt %s returned invalid or empty data (\"%s\") or error (\"%s\")", body, data, err)
remove_moo_tag() remove_moo_tag()
else else
rspamd_logger.infox("TAG_MOO: User wants subfolder tag, adding X-Moo-Tag header") rspamd_logger.infox("Add X-Moo-Tag header")
task:set_milter_reply({ task:set_milter_reply({
add_headers = {['X-Moo-Tag'] = 'YES'} add_headers = {['X-Moo-Tag'] = 'YES'}
}) })
@@ -491,15 +272,14 @@ rspamd_config:register_symbol({
{'RCPT_WANTS_SUBFOLDER_TAG', body} -- arguments {'RCPT_WANTS_SUBFOLDER_TAG', body} -- arguments
) )
if not redis_ret_subfolder then if not redis_ret_subfolder then
rspamd_logger.infox(rspamd_config, "TAG_MOO: cannot make request to load tag handler for rcpt") rspamd_logger.infox(rspamd_config, "cannot make request to load tag handler for rcpt")
remove_moo_tag() remove_moo_tag()
end end
else else
rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail") rspamd_logger.infox("user wants subject modified for tagged mail")
local sbj = task:get_header('Subject') or '' local sbj = task:get_header('Subject')
local tag_value = tag[1] and tag[1].options and tag[1].options[1] or '' new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?='
new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag_value .. '] ' .. sbj)) .. '?='
task:set_milter_reply({ task:set_milter_reply({
remove_headers = { remove_headers = {
['Subject'] = 1, ['Subject'] = 1,
@@ -519,32 +299,33 @@ rspamd_config:register_symbol({
{'RCPT_WANTS_SUBJECT_TAG', body} -- arguments {'RCPT_WANTS_SUBJECT_TAG', body} -- arguments
) )
if not redis_ret_subject then if not redis_ret_subject then
rspamd_logger.infox(rspamd_config, "TAG_MOO: cannot make request to load tag handler for rcpt") rspamd_logger.infox(rspamd_config, "cannot make request to load tag handler for rcpt")
remove_moo_tag() remove_moo_tag()
end end
else
rspamd_logger.infox("TAG_MOO: alias expansion returned empty body")
remove_moo_tag()
end end
end end
local rcpt_split = rspamd_str_split(rcpt_addr, '@') if rcpts and #rcpts == 1 then
for _,rcpt in ipairs(rcpts) do
local rcpt_split = rspamd_str_split(rcpt['addr'], '@')
if #rcpt_split == 2 then if #rcpt_split == 2 then
if rcpt_split[1]:match('^postmaster') then if rcpt_split[1] == 'postmaster' then
rspamd_logger.infox(rspamd_config, "TAG_MOO: not expanding postmaster alias") rspamd_logger.infox(rspamd_config, "not expanding postmaster alias")
remove_moo_tag() remove_moo_tag()
else else
rspamd_logger.infox("TAG_MOO: requesting alias expansion for %s", rcpt_addr)
rspamd_http.request({ rspamd_http.request({
task=task, task=task,
url='http://nginx:8081/aliasexp.php', url='http://nginx:8081/aliasexp.php',
body='', body='',
callback=http_callback, callback=http_callback,
headers={Rcpt=rcpt_addr}, headers={Rcpt=rcpt['addr']},
}) })
end end
end
end
end
else else
rspamd_logger.infox("TAG_MOO: invalid rcpt format")
remove_moo_tag() remove_moo_tag()
end end
end, end,
@@ -554,7 +335,6 @@ rspamd_config:register_symbol({
rspamd_config:register_symbol({ rspamd_config:register_symbol({
name = 'BCC', name = 'BCC',
type = 'postfilter', type = 'postfilter',
flags = 'ignore_passthrough',
callback = function(task) callback = function(task)
local util = require("rspamd_util") local util = require("rspamd_util")
local rspamd_http = require "rspamd_http" local rspamd_http = require "rspamd_http"
@@ -583,13 +363,11 @@ rspamd_config:register_symbol({
local email_content = tostring(task:get_content()) local email_content = tostring(task:get_content())
email_content = string.gsub(email_content, "\r\n%.", "\r\n..") email_content = string.gsub(email_content, "\r\n%.", "\r\n..")
-- send mail -- send mail
local from_smtp = task:get_from('smtp')
local from_addr = (from_smtp and from_smtp[1] and from_smtp[1].addr) or 'mailer-daemon@localhost'
lua_smtp.sendmail({ lua_smtp.sendmail({
task = task, task = task,
host = os.getenv("IPV4_NETWORK") .. '.253', host = os.getenv("IPV4_NETWORK") .. '.253',
port = 591, port = 591,
from = from_addr, from = task:get_from(stp)[1].addr,
recipients = bcc_dest, recipients = bcc_dest,
helo = 'bcc', helo = 'bcc',
timeout = 20, timeout = 20,
@@ -619,41 +397,27 @@ rspamd_config:register_symbol({
end end
local action = task:get_metric_action('default') local action = task:get_metric_action('default')
rspamd_logger.infox("BCC: metric action: %s", action) rspamd_logger.infox("metric action now: %s", action)
-- Check for pre-result accept (e.g., from KEEP_SPAM)
local allow_bcc = false
if task.has_pre_result then
local has_pre, pre_action = task:has_pre_result()
if has_pre and pre_action == 'accept' then
allow_bcc = true
rspamd_logger.infox("BCC: pre-result accept detected, will send BCC")
end
end
-- Allow BCC for mild actions or when we have pre-result accept
if not allow_bcc and action ~= 'no action' and action ~= 'add header' and action ~= 'rewrite subject' then
rspamd_logger.infox("BCC: skipping for action: %s", action)
return
end
local function rcpt_callback(err_message, code, body, headers) local function rcpt_callback(err_message, code, body, headers)
if err_message == nil and code == 201 and body ~= nil then if err_message == nil and code == 201 and body ~= nil then
rspamd_logger.infox("BCC: sending BCC to %s for rcpt match", body) if action == 'no action' or action == 'add header' or action == 'rewrite subject' then
send_mail(task, body) send_mail(task, body)
end end
end end
end
local function from_callback(err_message, code, body, headers) local function from_callback(err_message, code, body, headers)
if err_message == nil and code == 201 and body ~= nil then if err_message == nil and code == 201 and body ~= nil then
rspamd_logger.infox("BCC: sending BCC to %s for from match", body) if action == 'no action' or action == 'add header' or action == 'rewrite subject' then
send_mail(task, body) send_mail(task, body)
end end
end end
end
if rcpt_table then if rcpt_table then
for _,e in ipairs(rcpt_table) do for _,e in ipairs(rcpt_table) do
rspamd_logger.infox(rspamd_config, "BCC: checking bcc for rcpt address %s", e) rspamd_logger.infox(rspamd_config, "checking bcc for rcpt address %s", e)
rspamd_http.request({ rspamd_http.request({
task=task, task=task,
url='http://nginx:8081/bcc.php', url='http://nginx:8081/bcc.php',
@@ -666,7 +430,7 @@ rspamd_config:register_symbol({
if from_table then if from_table then
for _,e in ipairs(from_table) do for _,e in ipairs(from_table) do
rspamd_logger.infox(rspamd_config, "BCC: checking bcc for from address %s", e) rspamd_logger.infox(rspamd_config, "checking bcc for from address %s", e)
rspamd_http.request({ rspamd_http.request({
task=task, task=task,
url='http://nginx:8081/bcc.php', url='http://nginx:8081/bcc.php',
@@ -677,7 +441,7 @@ rspamd_config:register_symbol({
end end
end end
-- Don't return true to avoid symbol being logged return true
end, end,
priority = 20 priority = 20
}) })
+46 -80
View File
@@ -2,7 +2,18 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
protect_route(['admin']); if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
header('Location: /domainadmin/mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
header('Location: /admin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
@@ -16,92 +27,46 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
$js_minifier->add('/web/js/site/dashboard.js'); $js_minifier->add('/web/js/site/dashboard.js');
$vmail_df_resp = agent('request', 'dovecot', 'exec.df', array('dir' => '/var/vmail'), 5); // vmail df
$vmail_df = (!empty($vmail_df_resp['ok']) && is_string($vmail_df_resp['result'])) $exec_fields = array('cmd' => 'system', 'task' => 'df', 'dir' => '/var/vmail');
? explode(',', $vmail_df_resp['result']) $vmail_df = explode(',', (string)json_decode(docker('post', 'dovecot-mailcow', 'exec', $exec_fields), true));
: array('', '', '', '', '', '/var/vmail');
$known_services = agent('services');
try {
$tz_obj = new DateTimeZone(getenv('TZ') ?: 'UTC');
}
catch (Exception $e) {
$tz_obj = new DateTimeZone('UTC');
}
// containers
$containers_info = (array) docker('info');
if ($clamd_status === false) unset($containers_info['clamd-mailcow']);
if ($olefy_status === false) unset($containers_info['olefy-mailcow']);
ksort($containers_info);
$containers = array(); $containers = array();
foreach ($known_services as $svc) { foreach ($containers_info as $container => $container_info) {
$live_nodes = agent('live_nodes', $svc); if (!isset($container_info['State']) || !is_array($container_info['State']) || !isset($container_info['State']['StartedAt'])){
$running = !empty($live_nodes); continue;
$first_node = $running ? $live_nodes[0] : ''; }
$first_meta = $running ? (agent('node_meta', $svc, $first_node) ?: array()) : array(); date_default_timezone_set('UTC');
$StartedAt = date_parse($container_info['State']['StartedAt']);
$started_at_hr = '—'; if ($StartedAt['hour'] !== false) {
$started_at_iso = isset($first_meta['started_at']) ? $first_meta['started_at'] : ''; $date = new \DateTime();
if ($started_at_iso !== '') { $date->setTimestamp(mktime(
$StartedAt['hour'],
$StartedAt['minute'],
$StartedAt['second'],
$StartedAt['month'],
$StartedAt['day'],
$StartedAt['year']));
try { try {
$d = new DateTime($started_at_iso); $user_tz = new DateTimeZone(getenv('TZ'));
$d->setTimezone($tz_obj); $date->setTimezone($user_tz);
$started_at_hr = $d->format('r'); $container_info['State']['StartedAtHR'] = $date->format('r');
} } catch(Exception $e) {
catch (Exception $e) {} $container_info['State']['StartedAtHR'] = '?';
}
$nodes = array();
$unhealthy_nodes = 0;
$first_unhealthy_detail = '';
foreach ($live_nodes as $n) {
$m = agent('node_meta', $svc, $n) ?: array();
$s = agent('node_stats', $svc, $n) ?: array();
$node_health = isset($m['health']) ? $m['health'] : '';
$node_health_detail = isset($m['health_detail']) ? $m['health_detail'] : '';
if ($node_health === 'fail') {
$unhealthy_nodes++;
if ($first_unhealthy_detail === '') {
$first_unhealthy_detail = $node_health_detail;
} }
} }
$nodes[] = array( else {
'NodeId' => $n, $container_info['State']['StartedAtHR'] = '?';
'Image' => isset($m['image']) ? $m['image'] : '',
'StartedAt' => isset($m['started_at']) ? $m['started_at'] : '',
'Version' => isset($m['version']) ? $m['version'] : '',
'CPUPercent' => isset($s['cpu_percent']) ? $s['cpu_percent'] : '',
'MemoryBytes' => isset($s['memory_bytes']) ? $s['memory_bytes'] : '',
'Health' => $node_health,
'HealthDetail' => $node_health_detail
);
} }
$containers[$container] = $container_info;
$service_health = 'unknown';
if ($running) {
$service_health = ($unhealthy_nodes === 0) ? 'ok' : (($unhealthy_nodes === count($live_nodes)) ? 'fail' : 'degraded');
}
$containers[$svc . '-mailcow'] = array(
'Service' => $svc,
'State' => array(
'Running' => $running ? 1 : 0,
'NodeCount' => count($live_nodes),
'UnhealthyCount' => $unhealthy_nodes,
'Health' => $service_health,
'HealthDetail' => $first_unhealthy_detail,
'StartedAt' => $started_at_iso,
'StartedAtHR' => $started_at_hr
),
'Config' => array(
'Image' => isset($first_meta['image']) ? $first_meta['image'] : ''
),
'Id' => $first_node,
'Nodes' => $nodes,
'External' => false
);
} }
$infra_containers = infra('status'); // get mailcow data
ksort($containers);
$hostname = getenv('MAILCOW_HOSTNAME'); $hostname = getenv('MAILCOW_HOSTNAME');
$timezone = getenv('TZ'); $timezone = getenv('TZ');
@@ -116,7 +81,6 @@ $template_data = [
'clamd_status' => $clamd_status, 'clamd_status' => $clamd_status,
'olefy_status' => $olefy_status, 'olefy_status' => $olefy_status,
'containers' => $containers, 'containers' => $containers,
'infra_containers' => $infra_containers,
'ip_check' => customize('get', 'ip_check'), 'ip_check' => customize('get', 'ip_check'),
'lang_admin' => json_encode($lang['admin']), 'lang_admin' => json_encode($lang['admin']),
'lang_debug' => json_encode($lang['debug']), 'lang_debug' => json_encode($lang['debug']),
@@ -124,3 +88,5 @@ $template_data = [
]; ];
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
-3
View File
@@ -3,11 +3,8 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') { if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
// Only redirect to dashboard if NO pending actions
if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
header('Location: /admin/dashboard'); header('Location: /admin/dashboard');
exit(); exit();
}
} }
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') { elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
header('Location: /domainadmin/mailbox'); header('Location: /domainadmin/mailbox');
+12 -1
View File
@@ -2,7 +2,18 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
protect_route(['admin']); if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
header('Location: /domainadmin/mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
header('Location: /admin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+13 -1
View File
@@ -2,7 +2,19 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
protect_route(['admin']); if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
header('Location: /domainadmin/mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
header('Location: /admin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$js_minifier->add('/web/js/site/queue.js'); $js_minifier->add('/web/js/site/queue.js');
+12 -1
View File
@@ -2,7 +2,18 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
protect_route(['admin']); if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
header('Location: /domainadmin/mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
header('Location: /admin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
-84
View File
@@ -2454,90 +2454,6 @@ paths:
type: object type: object
type: object type: object
summary: Delete mails in Quarantine summary: Delete mails in Quarantine
/api/v1/edit/qitem:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
release:
value:
- log:
- quarantine
- edit
- id:
- "33"
action: release
msg:
- item_released
- "33"
type: success
learnham:
value:
- log:
- quarantine
- edit
- id:
- "34"
action: learnham
msg:
- item_learned
- "34"
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- error
type: string
type: object
description: OK
headers: {}
tags:
- Quarantine
description: >-
Using this endpoint you can perform actions on quarantine items. It is possible to release
emails from quarantine into to the inbox, or learn them as ham to improve Rspamd filtering.
You must provide the quarantine item IDs. You can get the IDs using the GET method.
operationId: Edit mails in Quarantine
requestBody:
content:
application/json:
schema:
example:
items:
- "33"
- "34"
attr:
action: release
properties:
items:
description: contains list of quarantine item IDs to release or learn as ham
type: object
attr:
description: attributes for the action
type: object
properties:
action:
type: string
enum:
- release
- learnham
description: "release - return email to inbox; learnham - learn as ham to improve filtering"
type: object
summary: Edit mails in Quarantine
/api/v1/delete/recipient_map: /api/v1/delete/recipient_map:
post: post:
responses: responses:
+2 -2
View File
@@ -29,8 +29,8 @@ header('Content-Type: application/xml');
<clientConfig version="1.1"> <clientConfig version="1.1">
<emailProvider id="<?=$mailcow_hostname; ?>"> <emailProvider id="<?=$mailcow_hostname; ?>">
<domain>%EMAILDOMAIN%</domain> <domain>%EMAILDOMAIN%</domain>
<displayName><?=$autodiscover_config['displayName']; ?></displayName> <displayName>A mailcow mail server</displayName>
<displayShortName><?=$autodiscover_config['displayShortName']; ?></displayShortName> <displayShortName>mail server</displayShortName>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname><?=$autodiscover_config['imap']['server']; ?></hostname> <hostname><?=$autodiscover_config['imap']['server']; ?></hostname>
+54 -117
View File
@@ -60,31 +60,46 @@ $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
$iam_provider = identity_provider('init'); $iam_provider = identity_provider('init');
$iam_settings = identity_provider('get'); $iam_settings = identity_provider('get');
// Passwordless autodiscover - no authentication required $login_user = strtolower(trim($_SERVER['PHP_AUTH_USER']));
// Email will be extracted from the request body $login_pass = trim(htmlspecialchars_decode($_SERVER['PHP_AUTH_PW']));
$login_user = null;
$login_role = null;
header("Content-Type: application/xml"); if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) {
echo '<?xml version="1.0" encoding="utf-8" ?>' . PHP_EOL;
?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<?php
if(!$data) {
try {
$json = json_encode( $json = json_encode(
array( array(
"time" => time(), "time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'], "ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => "none", "user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'], "ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: must be authenticated"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
header('WWW-Authenticate: Basic realm="' . $_SERVER['HTTP_HOST'] . '"');
header('HTTP/1.0 401 Unauthorized');
exit(0);
}
$login_role = check_login($login_user, $login_pass, array('eas' => TRUE));
if ($login_role === "user") {
header("Content-Type: application/xml");
echo '<?xml version="1.0" encoding="utf-8" ?>' . PHP_EOL;
?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<?php
if(!$data) {
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $_SERVER['PHP_AUTH_USER'],
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: invalid or missing request data" "service" => "Error: invalid or missing request data"
) )
); );
$redis->lPush('AUTODISCOVER_LOG', $json); $redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100); $redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid request by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Invalid request by " . $_SERVER['REMOTE_ADDR']);
} }
catch (RedisException $e) { catch (RedisException $e) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
@@ -96,7 +111,7 @@ if(!$data) {
list($usec, $sec) = explode(' ', microtime()); list($usec, $sec) = explode(' ', microtime());
?> ?>
<Response> <Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>"> <Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="2477272013">
<ErrorCode>600</ErrorCode> <ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message> <Message>Invalid Request</Message>
<DebugData /> <DebugData />
@@ -105,131 +120,50 @@ if(!$data) {
</Autodiscover> </Autodiscover>
<?php <?php
exit(0); exit(0);
} }
try { try {
$discover = new SimpleXMLElement($data); $discover = new SimpleXMLElement($data);
$email = $discover->Request->EMailAddress; $email = $discover->Request->EMailAddress;
} catch (Exception $e) { } catch (Exception $e) {
// If parsing fails, return error $email = $_SERVER['PHP_AUTH_USER'];
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: could not parse email from request"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$redis->publish("F2B_CHANNEL", "Autodiscover: Malformed XML by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Malformed XML by " . $_SERVER['REMOTE_ADDR']);
} }
catch (RedisException $e) {
// Silently fail
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
$username = trim((string)$email); $username = trim($email);
try { try {
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox` $stmt = $pdo->prepare("SELECT `name` FROM `mailbox` WHERE `username`= :username");
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`username` = :username
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'");
$stmt->execute(array(':username' => $username)); $stmt->execute(array(':username' => $username));
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC); $MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
} }
catch(PDOException $e) { catch(PDOException $e) {
// Database error - return error response with complete XML die("Failed to determine name from SQL");
list($usec, $sec) = explode(' ', microtime()); }
?> if (!empty($MailboxData['name'])) {
<Response> $displayname = $MailboxData['name'];
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>"> }
<ErrorCode>500</ErrorCode> else {
<Message>Database Error</Message> $displayname = $email;
<DebugData /> }
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
// Mailbox not found or not active - return generic error to prevent user enumeration
if (empty($MailboxData)) {
try { try {
$json = json_encode( $json = json_encode(
array( array(
"time" => time(), "time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'], "ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email, "user" => $_SERVER['PHP_AUTH_USER'],
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: mailbox not found or inactive"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
}
catch (RedisException $e) {
// Silently fail
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
if (!empty($MailboxData['name'])) {
$displayname = $MailboxData['name'];
}
else {
$displayname = $email;
}
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"ip" => $_SERVER['REMOTE_ADDR'], "ip" => $_SERVER['REMOTE_ADDR'],
"service" => $autodiscover_config['autodiscoverType'] "service" => $autodiscover_config['autodiscoverType']
) )
); );
$redis->lPush('AUTODISCOVER_LOG', $json); $redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100); $redis->lTrim('AUTODISCOVER_LOG', 0, 100);
} }
catch (RedisException $e) { catch (RedisException $e) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'danger', 'type' => 'danger',
'msg' => 'Redis: '.$e 'msg' => 'Redis: '.$e
); );
return false; return false;
} }
if ($autodiscover_config['autodiscoverType'] == 'imap') { if ($autodiscover_config['autodiscoverType'] == 'imap') {
?> ?>
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a"> <Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
<User> <User>
@@ -304,3 +238,6 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
} }
?> ?>
</Autodiscover> </Autodiscover>
<?php
}
?>
-3
View File
@@ -3,11 +3,8 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') { if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
// Only redirect to mailbox if NO pending actions
if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
header('Location: /domainadmin/mailbox'); header('Location: /domainadmin/mailbox');
exit(); exit();
}
} }
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') { elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
header('Location: /admin/dashboard'); header('Location: /admin/dashboard');
+12 -1
View File
@@ -2,7 +2,18 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
protect_route(['domainadmin']); if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
header('Location: /admin/dashboard');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "domainadmin") {
header('Location: /domainadmin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+25 -12
View File
@@ -2,20 +2,20 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
/* if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
/ DOMAIN ADMIN
*/
protect_route(['domainadmin']); /*
/ DOMAIN ADMIN
*/
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
$tfa_data = get_tfa(); $tfa_data = get_tfa();
$fido2_data = fido2(array("action" => "get_friendly_names")); $fido2_data = fido2(array("action" => "get_friendly_names"));
$username = $_SESSION['mailcow_cc_username']; $username = $_SESSION['mailcow_cc_username'];
$template = 'domainadmin.twig'; $template = 'domainadmin.twig';
$template_data = [ $template_data = [
'acl' => $_SESSION['acl'], 'acl' => $_SESSION['acl'],
'acl_json' => json_encode($_SESSION['acl']), 'acl_json' => json_encode($_SESSION['acl']),
'user_spam_score' => mailbox('get', 'spam_score', $username), 'user_spam_score' => mailbox('get', 'spam_score', $username),
@@ -23,7 +23,20 @@ $template_data = [
'fido2_data' => $fido2_data, 'fido2_data' => $fido2_data,
'lang_user' => json_encode($lang['user']), 'lang_user' => json_encode($lang['user']),
'lang_datatables' => json_encode($lang['datatables']), 'lang_datatables' => json_encode($lang['datatables']),
]; ];
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
header('Location: /admin/dashboard');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
else {
header('Location: /domainadmin');
exit();
}
$js_minifier->add('/web/js/site/user.js'); $js_minifier->add('/web/js/site/user.js');
$js_minifier->add('/web/js/site/pwgen.js'); $js_minifier->add('/web/js/site/pwgen.js');
+5 -3
View File
@@ -1,8 +1,10 @@
<?php <?php
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
$AuthUsers = array("admin", "domainadmin", "user");
protect_route(); if (!isset($_SESSION['mailcow_cc_role']) OR !in_array($_SESSION['mailcow_cc_role'], $AuthUsers)) {
header('Location: /');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$template = 'edit.twig'; $template = 'edit.twig';
+46 -22
View File
@@ -1,4 +1,6 @@
<?php <?php
// Block requests by checking the 'Sec-Fetch-Dest' header.
if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] !== 'empty') { if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] !== 'empty') {
header('HTTP/1.1 403 Forbidden'); header('HTTP/1.1 403 Forbidden');
exit; exit;
@@ -9,27 +11,49 @@ if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'adm
exit(); exit();
} }
if (!preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'] ?? '')) { if (preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'])) {
exit(); if ($_GET['action'] == "start") {
header('Content-Type: text/html; charset=utf-8');
$retry = 0;
while (docker('info', $_GET['service'])['State']['Running'] != 1 && $retry <= 3) {
$response = docker('post', $_GET['service'], 'start');
$response = json_decode($response, true);
$last_response = ($response['type'] == "success") ? '<b><span class="pull-right text-success">OK</span></b>' : '<b><span class="pull-right text-danger">Error: ' . $response['msg'] . '</span></b>';
if ($response['type'] == "success") {
break;
}
usleep(1500000);
$retry++;
}
echo (!isset($last_response)) ? '<b><span class="pull-right text-warning">Already running</span></b>' : $last_response;
}
if ($_GET['action'] == "stop") {
header('Content-Type: text/html; charset=utf-8');
$retry = 0;
while (docker('info', $_GET['service'])['State']['Running'] == 1 && $retry <= 3) {
$response = docker('post', $_GET['service'], 'stop');
$response = json_decode($response, true);
$last_response = ($response['type'] == "success") ? '<b><span class="pull-right text-success">OK</span></b>' : '<b><span class="pull-right text-danger">Error: ' . $response['msg'] . '</span></b>';
if ($response['type'] == "success") {
break;
}
usleep(1500000);
$retry++;
}
echo (!isset($last_response)) ? '<b><span class="pull-right text-warning">Not running</span></b>' : $last_response;
}
if ($_GET['action'] == "restart") {
header('Content-Type: text/html; charset=utf-8');
$response = docker('post', $_GET['service'], 'restart');
$response = json_decode($response, true);
$last_response = ($response['type'] == "success") ? '<b><span class="pull-right text-success">OK</span></b>' : '<b><span class="pull-right text-danger">Error: ' . $response['msg'] . '</span></b>';
echo (!isset($last_response)) ? '<b><span class="pull-right text-warning">Cannot restart container</span></b>' : $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('logs', $_GET['service'], $lines)));
}
} }
if (($_GET['action'] ?? '') !== 'restart') { ?>
exit();
}
$service = preg_replace('/-mailcow$/', '', $_GET['service']);
$node = isset($_GET['node']) ? preg_replace('/[^a-zA-Z0-9._\-]/', '', $_GET['node']) : '';
$args = ($node !== '') ? array('target_node' => $node) : array();
$resp = agent('request', $service, 'restart', $args, 60);
header('Content-Type: text/html; charset=utf-8');
if (agent('ok', $resp)) {
echo '<b><span class="pull-right text-success">' . htmlspecialchars($lang['success']['service_restart_ok']) . '</span></b>';
}
else {
$err_key = agent('error_lang', $resp);
$err_msg = isset($lang['danger'][$err_key])
? sprintf($lang['danger'][$err_key], $service)
: $lang['danger']['agent_unknown_error'];
echo '<b><span class="pull-right text-danger">' . htmlspecialchars($err_msg) . '</span></b>';
}
+1 -10
View File
@@ -129,16 +129,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
); );
} }
// Check if domain is an alias domain and get target domain's MTA-STS $mta_sts = mailbox('get', 'mta_sts', $domain);
$alias_domain_details = mailbox('get', 'alias_domain_details', $domain);
$mta_sts_domain = $domain;
if ($alias_domain_details !== false && !empty($alias_domain_details['target_domain'])) {
// This is an alias domain, check target domain for MTA-STS
$mta_sts_domain = $alias_domain_details['target_domain'];
}
$mta_sts = mailbox('get', 'mta_sts', $mta_sts_domain);
if (count($mta_sts) > 0 && $mta_sts['active'] == 1) { if (count($mta_sts) > 0 && $mta_sts['active'] == 1) {
if (!in_array($domain, $alias_domains)) { if (!in_array($domain, $alias_domains)) {
$records[] = array( $records[] = array(
-2
View File
@@ -64,8 +64,6 @@ $globalVariables = [
'pending_tfa_methods' => @$_SESSION['pending_tfa_methods'], 'pending_tfa_methods' => @$_SESSION['pending_tfa_methods'],
'pending_tfa_authmechs' => $pending_tfa_authmechs, 'pending_tfa_authmechs' => $pending_tfa_authmechs,
'pending_mailcow_cc_username' => @$_SESSION['pending_mailcow_cc_username'], 'pending_mailcow_cc_username' => @$_SESSION['pending_mailcow_cc_username'],
'pending_tfa_setup' => !empty($_SESSION['pending_tfa_setup']),
'pending_pw_update_modal' => !empty($_SESSION['pending_pw_update']),
'lang_footer' => json_encode($lang['footer']), 'lang_footer' => json_encode($lang['footer']),
'lang_acl' => json_encode($lang['acl']), 'lang_acl' => json_encode($lang['acl']),
'lang_tfa' => json_encode($lang['tfa']), 'lang_tfa' => json_encode($lang['tfa']),
+13 -37
View File
@@ -121,56 +121,34 @@ function admin($_action, $_data = null) {
continue; continue;
} }
} }
// Check if this is a self password change via forced update
if ($username == $_SESSION['mailcow_cc_username'] && !empty($_SESSION['pending_pw_update'])) {
// Forced password update: only change password and clear force_pw_update flag
if (!empty($password)) {
if (password_check($password, $_data['password2']) !== true) {
return false;
}
$password_hashed = hash_password($password);
$stmt = $pdo->prepare("UPDATE `admin` SET `password` = :password_hashed,
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_pw_update', '0')
WHERE `username` = :username");
$stmt->execute(array(
':password_hashed' => $password_hashed,
':username' => $username
));
unset($_SESSION['pending_pw_update']);
}
} else {
// Normal admin edit: update all attributes
$force_tfa = intval($_data['force_tfa'] ?? 0) ? 1 : 0;
$force_pw_update = intval($_data['force_pw_update'] ?? 0) ? 1 : 0;
if (!empty($password)) { if (!empty($password)) {
if (password_check($password, $password2) !== true) { if (password_check($password, $password2) !== true) {
return false; return false;
} }
$password_hashed = hash_password($password); $password_hashed = hash_password($password);
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed, $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed WHERE `username` = :username");
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
WHERE `username` = :username");
$stmt->execute(array( $stmt->execute(array(
':password_hashed' => $password_hashed, ':password_hashed' => $password_hashed,
':username_new' => $username_new, ':username_new' => $username_new,
':username' => $username, ':username' => $username,
':active' => $active, ':active' => $active
':force_tfa' => strval($force_tfa),
':force_pw_update' => strval($force_pw_update)
)); ));
if (isset($_data['disable_tfa'])) {
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
$stmt->execute(array(':username' => $username));
} }
else { else {
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, $stmt = $pdo->prepare("UPDATE `tfa` SET `username` = :username_new WHERE `username` = :username");
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update) $stmt->execute(array(':username_new' => $username_new, ':username' => $username));
WHERE `username` = :username"); }
}
else {
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active WHERE `username` = :username");
$stmt->execute(array( $stmt->execute(array(
':username_new' => $username_new, ':username_new' => $username_new,
':username' => $username, ':username' => $username,
':active' => $active, ':active' => $active
':force_tfa' => strval($force_tfa),
':force_pw_update' => strval($force_pw_update)
)); ));
}
if (isset($_data['disable_tfa'])) { if (isset($_data['disable_tfa'])) {
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username"); $stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
$stmt->execute(array(':username' => $username)); $stmt->execute(array(':username' => $username));
@@ -245,8 +223,7 @@ function admin($_action, $_data = null) {
`tfa`.`active` AS `tfa_active`, `tfa`.`active` AS `tfa_active`,
`admin`.`username`, `admin`.`username`,
`admin`.`created`, `admin`.`created`,
`admin`.`active` AS `active`, `admin`.`active` AS `active`
`admin`.`attributes` AS `attributes`
FROM `admin` FROM `admin`
LEFT OUTER JOIN `tfa` ON `tfa`.`username`=`admin`.`username` LEFT OUTER JOIN `tfa` ON `tfa`.`username`=`admin`.`username`
WHERE `admin`.`username`= :admin AND `superadmin` = '1'"); WHERE `admin`.`username`= :admin AND `superadmin` = '1'");
@@ -263,7 +240,6 @@ function admin($_action, $_data = null) {
$admindata['active'] = $row['active']; $admindata['active'] = $row['active'];
$admindata['active_int'] = $row['active']; $admindata['active_int'] = $row['active'];
$admindata['created'] = $row['created']; $admindata['created'] = $row['created'];
$admindata['attributes'] = json_decode($row['attributes'], true) ?? array('force_tfa' => '0', 'force_pw_update' => '0');
return $admindata; return $admindata;
break; break;
} }
-281
View File
@@ -1,281 +0,0 @@
<?php
define('AGENT_ERR_NOT_FOUND', 'not_found');
define('AGENT_ERR_TIMEOUT', 'timeout');
define('AGENT_ERR_VALIDATION', 'validation');
define('AGENT_ERR_UNSUPPORTED', 'unsupported_command');
define('AGENT_ERR_INTERNAL', 'internal');
function agent($_action, $_service = null, $_data = null, $_args = array(), $_timeout = 10) {
global $redis;
switch ($_action) {
case 'request_id':
return sprintf('%013d%s', (int)(microtime(true) * 1000), substr(bin2hex(random_bytes(10)), 0, 16));
break;
case 'services':
$list = array(
'unbound', 'clamd', 'rspamd', 'php-fpm', 'sogo',
'dovecot', 'postfix', 'postfix-tlspol', 'nginx', 'acme',
'netfilter', 'watchdog', 'olefy', 'host'
);
if (preg_match('/^([yY][eE][sS]|[yY])+$/', isset($_ENV['SKIP_CLAMD']) ? $_ENV['SKIP_CLAMD'] : '')) {
$list = array_values(array_diff($list, array('clamd')));
}
if (preg_match('/^([yY][eE][sS]|[yY])+$/', isset($_ENV['SKIP_OLEFY']) ? $_ENV['SKIP_OLEFY'] : '')) {
$list = array_values(array_diff($list, array('olefy')));
}
sort($list);
return $list;
break;
case 'live_nodes':
try {
$members = $redis->zRangeByScore('mailcow.nodes.' . $_service, (string)(time() - 30), '+inf');
}
catch (RedisException $e) {
return array();
}
return is_array($members) ? $members : array();
break;
case 'node_meta':
try {
$h = $redis->hGetAll('mailcow.node.' . $_service . '.' . $_data);
}
catch (RedisException $e) {
return null;
}
return $h ?: null;
break;
case 'node_stats':
try {
$h = $redis->hGetAll('mailcow.stats.' . $_service . '.' . $_data);
}
catch (RedisException $e) {
return null;
}
return $h ?: null;
break;
case 'stats':
$out = array();
foreach (agent('live_nodes', $_service) as $node_id) {
$stats = agent('node_stats', $_service, $node_id);
if ($stats) {
$out[$node_id] = $stats;
}
}
return $out;
break;
case 'publish':
$env = array(
'cmd' => $_data,
'request_id' => agent('request_id'),
'args' => (object)(is_array($_args) ? $_args : array()),
'issued_by' => 'mailcow-php'
);
try {
$redis->publish('mailcow.control.' . $_service, json_encode($env));
}
catch (RedisException $e) {
return false;
}
return true;
break;
case 'request':
$rid = agent('request_id');
$reply_to = 'mailcow.reply.' . $rid;
$env = array(
'cmd' => $_data,
'request_id' => $rid,
'args' => (object)(is_array($_args) ? $_args : array()),
'reply_to' => $reply_to,
'deadline' => gmdate('Y-m-d\TH:i:s\Z', time() + $_timeout),
'issued_by' => 'mailcow-php'
);
try {
$subs = $redis->publish('mailcow.control.' . $_service, json_encode($env));
if ($subs === 0) {
return array('ok' => false, 'result' => null, 'error' => $_service, 'error_code' => AGENT_ERR_NOT_FOUND, 'node' => '', 'duration_ms' => 0);
}
$popped = $redis->blPop(array($reply_to), $_timeout);
}
catch (RedisException $e) {
return array('ok' => false, 'result' => null, 'error' => $e->getMessage(), 'error_code' => AGENT_ERR_INTERNAL, 'node' => '', 'duration_ms' => 0);
}
if (!$popped || count($popped) < 2) {
return array('ok' => false, 'result' => null, 'error' => '', 'error_code' => AGENT_ERR_TIMEOUT, 'node' => '', 'duration_ms' => 0);
}
$resp = json_decode($popped[1], true);
if (!is_array($resp)) {
return array('ok' => false, 'result' => null, 'error' => 'malformed reply', 'error_code' => AGENT_ERR_INTERNAL, 'node' => '', 'duration_ms' => 0);
}
return array(
'ok' => !empty($resp['ok']),
'result' => isset($resp['result']) ? $resp['result'] : null,
'error' => isset($resp['error']) ? $resp['error'] : '',
'error_code' => isset($resp['error_code']) ? $resp['error_code'] : '',
'node' => isset($resp['node']) ? $resp['node'] : '',
'duration_ms' => isset($resp['duration_ms']) ? $resp['duration_ms'] : 0
);
break;
case 'request_all':
$rid = agent('request_id');
$reply_to = 'mailcow.reply.' . $rid;
$env = array(
'cmd' => $_data,
'request_id' => $rid,
'args' => (object)(is_array($_args) ? $_args : array()),
'reply_to' => $reply_to,
'deadline' => gmdate('Y-m-d\TH:i:s\Z', time() + $_timeout),
'issued_by' => 'mailcow-php'
);
$expected = max(1, count(agent('live_nodes', $_service)));
try {
$subs = (int)$redis->publish('mailcow.control.' . $_service, json_encode($env));
}
catch (RedisException $e) {
return array('responses' => array(), 'expected_nodes' => $expected, 'received_nodes' => array(), 'missing_nodes' => array(), 'error' => $e->getMessage());
}
if ($subs === 0) {
return array('responses' => array(), 'expected_nodes' => 0, 'received_nodes' => array(), 'missing_nodes' => array());
}
$responses = array();
$deadline = microtime(true) + $_timeout;
for ($i = 0; $i < $subs; $i++) {
$remaining = (int)ceil($deadline - microtime(true));
if ($remaining <= 0) break;
try {
$popped = $redis->blPop(array($reply_to), $remaining);
}
catch (RedisException $e) {
break;
}
if (!$popped || count($popped) < 2) break;
$resp = json_decode($popped[1], true);
if (is_array($resp)) {
$responses[] = array(
'ok' => !empty($resp['ok']),
'result' => isset($resp['result']) ? $resp['result'] : null,
'error' => isset($resp['error']) ? $resp['error'] : '',
'error_code' => isset($resp['error_code']) ? $resp['error_code'] : '',
'node' => isset($resp['node']) ? $resp['node'] : '',
'duration_ms' => isset($resp['duration_ms']) ? $resp['duration_ms'] : 0
);
}
}
$received_nodes = array();
foreach ($responses as $r) {
if (!empty($r['node'])) {
$received_nodes[] = $r['node'];
}
}
$live = agent('live_nodes', $_service);
return array(
'responses' => $responses,
'expected_nodes' => $expected,
'received_nodes' => array_values(array_unique($received_nodes)),
'missing_nodes' => array_values(array_diff($live, $received_nodes))
);
break;
case 'ok':
if (isset($_service['responses'])) {
foreach ($_service['responses'] as $r) {
if (!empty($r['ok'])) return true;
}
return false;
}
return !empty($_service['ok']);
break;
case 'first_error':
foreach (isset($_service['responses']) ? $_service['responses'] : array() as $r) {
if (empty($r['ok']) && !empty($r['error'])) return $r['error'];
}
return '';
break;
case 'error_lang':
$code = is_array($_service) && isset($_service['error_code']) ? $_service['error_code'] : '';
switch ($code) {
case AGENT_ERR_NOT_FOUND:
return 'no_live_agent';
case AGENT_ERR_TIMEOUT:
return 'agent_timeout';
default:
return 'agent_unknown_error';
}
break;
}
}
function infra($_action, $_service = null) {
global $redis;
global $pdo;
switch ($_action) {
case 'health':
switch ($_service) {
case 'redis':
try {
if ($redis instanceof Redis && $redis->ping()) {
$info = $redis->info('server');
$ver = is_array($info) && isset($info['redis_version']) ? $info['redis_version'] : '';
return array('ok' => true, 'image' => 'redis ' . $ver, 'error' => '');
}
}
catch (RedisException $e) {
return array('ok' => false, 'image' => 'redis', 'error' => $e->getMessage());
}
return array('ok' => false, 'image' => 'redis', 'error' => 'PING returned false');
break;
case 'mysql':
try {
if ($pdo instanceof PDO) {
$row = $pdo->query('SELECT VERSION() AS v')->fetch(PDO::FETCH_ASSOC);
$ver = $row && isset($row['v']) ? $row['v'] : '';
return array('ok' => true, 'image' => 'mariadb/mysql ' . $ver, 'error' => '');
}
}
catch (Exception $e) {
return array('ok' => false, 'image' => 'mariadb/mysql', 'error' => $e->getMessage());
}
return array('ok' => false, 'image' => 'mariadb/mysql', 'error' => 'no PDO handle');
break;
case 'memcached':
$sock = @fsockopen('memcached', 11211, $errno, $errstr, 2);
if (!$sock) {
return array('ok' => false, 'image' => 'memcached', 'error' => $errstr ?: 'connection refused');
}
stream_set_timeout($sock, 2);
fwrite($sock, "version\r\n");
$line = fgets($sock, 64);
fclose($sock);
if (is_string($line) && strpos($line, 'VERSION') === 0) {
return array('ok' => true, 'image' => 'memcached ' . trim(substr($line, strlen('VERSION '))), 'error' => '');
}
return array('ok' => false, 'image' => 'memcached', 'error' => 'no VERSION reply');
break;
}
break;
case 'status':
$out = array();
$defs = array(
'redis-mailcow' => 'redis',
'mysql-mailcow' => 'mysql',
'memcached-mailcow' => 'memcached'
);
foreach ($defs as $key => $svc) {
$h = infra('health', $svc);
$out[$key] = array(
'Service' => $svc,
'State' => array(
'Running' => $h['ok'] ? 1 : 0,
'NodeCount' => $h['ok'] ? 1 : 0,
'StartedAt' => '',
'StartedAtHR' => '—',
'Error' => $h['error']
),
'Config' => array('Image' => $h['image']),
'Id' => $svc,
'Nodes' => array(),
'External' => true
);
}
return $out;
break;
}
}
+43 -56
View File
@@ -1,11 +1,10 @@
<?php <?php
function check_login($user, $pass, $extra = null) { function check_login($user, $pass, $app_passwd_data = false, $extra = null) {
global $pdo; global $pdo;
global $redis; global $redis;
$is_internal = $extra['is_internal']; $is_internal = $extra['is_internal'];
$role = $extra['role']; $role = $extra['role'];
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
// Try validate admin // Try validate admin
if (!isset($role) || $role == "admin") { if (!isset($role) || $role == "admin") {
@@ -26,20 +25,34 @@ function check_login($user, $pass, $extra = null) {
// Try validate app password // Try validate app password
if (!isset($role) || $role == "app") { if (!isset($role) || $role == "app") {
$result = apppass_login($user, $pass, $extra); $result = apppass_login($user, $pass, $app_passwd_data);
if ($result !== false) { if ($result !== false) {
if ($app_passwd_data['eas'] === true) {
$service = 'EAS';
} elseif ($app_passwd_data['dav'] === true) {
$service = 'DAV';
} else {
$service = 'NONE';
}
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']); $real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
set_sasl_log($user, $real_rip, $extra['service'], $pass); set_sasl_log($user, $real_rip, $service, $pass);
return $result; return $result;
} }
} }
// Try validate user // Try validate user
if (!isset($role) || $role == "user") { if (!isset($role) || $role == "user") {
$result = user_login($user, $pass, $extra); $result = user_login($user, $pass);
if ($result !== false) { if ($result !== false) {
if ($app_passwd_data['eas'] === true) {
$service = 'EAS';
} elseif ($app_passwd_data['dav'] === true) {
$service = 'DAV';
} else {
$service = 'MAILCOWUI';
}
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']); $real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
set_sasl_log($user, $real_rip, $extra['service']); set_sasl_log($user, $real_rip, $service);
return $result; return $result;
} }
} }
@@ -82,7 +95,7 @@ function admin_login($user, $pass){
} }
$user = strtolower(trim($user)); $user = strtolower(trim($user));
$stmt = $pdo->prepare("SELECT `password`, `attributes` FROM `admin` $stmt = $pdo->prepare("SELECT `password` FROM `admin`
WHERE `superadmin` = '1' WHERE `superadmin` = '1'
AND `active` = '1' AND `active` = '1'
AND `username` = :user"); AND `username` = :user");
@@ -91,13 +104,6 @@ function admin_login($user, $pass){
// verify password // verify password
if (verify_hash($row['password'], $pass)) { if (verify_hash($row['password'], $pass)) {
$admin_attrs = json_decode($row['attributes'], true) ?? [];
// Check force_pw_update
if (intval($admin_attrs['force_pw_update'] ?? 0) == 1) {
$_SESSION['pending_pw_update'] = true;
}
// check for tfa authenticators // check for tfa authenticators
$authenticators = get_tfa($user); $authenticators = get_tfa($user);
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) { if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
@@ -117,10 +123,6 @@ function admin_login($user, $pass){
// Reactivate TFA if it was set to "deactivate TFA for next login" // Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user"); $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
// Check force_tfa: only force setup if NO TFA exists at all
if (intval($admin_attrs['force_tfa'] ?? 0) == 1 && !tfa_exists($user)) {
$_SESSION['pending_tfa_setup'] = true;
}
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'success', 'type' => 'success',
'log' => array(__FUNCTION__, $user, '*'), 'log' => array(__FUNCTION__, $user, '*'),
@@ -146,7 +148,7 @@ function domainadmin_login($user, $pass){
return false; return false;
} }
$stmt = $pdo->prepare("SELECT `password`, `attributes` FROM `admin` $stmt = $pdo->prepare("SELECT `password` FROM `admin`
WHERE `superadmin` = '0' WHERE `superadmin` = '0'
AND `active`='1' AND `active`='1'
AND `username` = :user"); AND `username` = :user");
@@ -155,13 +157,6 @@ function domainadmin_login($user, $pass){
// verify password // verify password
if (verify_hash($row['password'], $pass) !== false) { if (verify_hash($row['password'], $pass) !== false) {
$admin_attrs = json_decode($row['attributes'], true) ?? [];
// Check force_pw_update
if (intval($admin_attrs['force_pw_update'] ?? 0) == 1) {
$_SESSION['pending_pw_update'] = true;
}
// check for tfa authenticators // check for tfa authenticators
$authenticators = get_tfa($user); $authenticators = get_tfa($user);
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) { if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
@@ -181,10 +176,6 @@ function domainadmin_login($user, $pass){
// Reactivate TFA if it was set to "deactivate TFA for next login" // Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user"); $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
// Check force_tfa: only force setup if NO TFA exists at all
if (intval($admin_attrs['force_tfa'] ?? 0) == 1 && !tfa_exists($user)) {
$_SESSION['pending_tfa_setup'] = true;
}
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'success', 'type' => 'success',
'log' => array(__FUNCTION__, $user, '*'), 'log' => array(__FUNCTION__, $user, '*'),
@@ -202,7 +193,7 @@ function user_login($user, $pass, $extra = null){
global $iam_settings; global $iam_settings;
$is_internal = $extra['is_internal']; $is_internal = $extra['is_internal'];
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service']; $service = $extra['service'];
if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) { if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) {
if (!$is_internal){ if (!$is_internal){
@@ -245,10 +236,10 @@ function user_login($user, $pass, $extra = null){
$row = $stmt->fetch(PDO::FETCH_ASSOC); $row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!empty($row)) { if (!empty($row)) {
// check if user has access to service (imap, smtp, pop3, sieve, dav, eas) if service is set // check if user has access to service (imap, smtp, pop3, sieve) if service is set
$row['attributes'] = json_decode($row['attributes'], true); $row['attributes'] = json_decode($row['attributes'], true);
if ($extra['service'] != 'NONE') { if (isset($service)) {
$key = strtolower($extra['service']) . "_access"; $key = strtolower($service) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
return false; return false;
} }
@@ -262,8 +253,8 @@ function user_login($user, $pass, $extra = null){
// check if user has access to service (imap, smtp, pop3, sieve) if service is set // check if user has access to service (imap, smtp, pop3, sieve) if service is set
$row['attributes'] = json_decode($row['attributes'], true); $row['attributes'] = json_decode($row['attributes'], true);
if ($extra['service'] != 'NONE') { if (isset($service)) {
$key = strtolower($extra['service']) . "_access"; $key = strtolower($service) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') { if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
return false; return false;
} }
@@ -287,8 +278,6 @@ function user_login($user, $pass, $extra = null){
return false; return false;
} }
$row['attributes'] = json_decode($row['attributes'], true);
// check for tfa authenticators // check for tfa authenticators
$authenticators = get_tfa($user); $authenticators = get_tfa($user);
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) { if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
@@ -310,10 +299,6 @@ function user_login($user, $pass, $extra = null){
// Reactivate TFA if it was set to "deactivate TFA for next login" // Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user"); $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
// Check force_tfa: only force setup if NO TFA exists at all
if (intval($row['attributes']['force_tfa']) == 1 && !tfa_exists($user)) {
$_SESSION['pending_tfa_setup'] = true;
}
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'success', 'type' => 'success',
'log' => array(__FUNCTION__, $user, '*', 'Provider: Keycloak'), 'log' => array(__FUNCTION__, $user, '*', 'Provider: Keycloak'),
@@ -345,8 +330,6 @@ function user_login($user, $pass, $extra = null){
return false; return false;
} }
$row['attributes'] = json_decode($row['attributes'], true);
// check for tfa authenticators // check for tfa authenticators
$authenticators = get_tfa($user); $authenticators = get_tfa($user);
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) { if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
@@ -368,10 +351,6 @@ function user_login($user, $pass, $extra = null){
// Reactivate TFA if it was set to "deactivate TFA for next login" // Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user"); $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
// Check force_tfa: only force setup if NO TFA exists at all
if (intval($row['attributes']['force_tfa']) == 1 && !tfa_exists($user)) {
$_SESSION['pending_tfa_setup'] = true;
}
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'success', 'type' => 'success',
'log' => array(__FUNCTION__, $user, '*', 'Provider: LDAP'), 'log' => array(__FUNCTION__, $user, '*', 'Provider: LDAP'),
@@ -415,10 +394,6 @@ function user_login($user, $pass, $extra = null){
// Reactivate TFA if it was set to "deactivate TFA for next login" // Reactivate TFA if it was set to "deactivate TFA for next login"
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user"); $stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :user");
$stmt->execute(array(':user' => $user)); $stmt->execute(array(':user' => $user));
// Check force_tfa: only force setup if NO TFA exists at all
if (intval($row['attributes']['force_tfa']) == 1 && !tfa_exists($user)) {
$_SESSION['pending_tfa_setup'] = true;
}
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'success', 'type' => 'success',
'log' => array(__FUNCTION__, $user, '*', 'Provider: mailcow'), 'log' => array(__FUNCTION__, $user, '*', 'Provider: mailcow'),
@@ -433,7 +408,7 @@ function user_login($user, $pass, $extra = null){
return false; return false;
} }
function apppass_login($user, $pass, $extra = null){ function apppass_login($user, $pass, $app_passwd_data, $extra = null){
global $pdo; global $pdo;
$is_internal = $extra['is_internal']; $is_internal = $extra['is_internal'];
@@ -449,8 +424,20 @@ function apppass_login($user, $pass, $extra = null){
return false; return false;
} }
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service']; $protocol = false;
if (!$is_internal && $extra['service'] == 'NONE') { if ($app_passwd_data['eas']){
$protocol = 'eas';
} else if ($app_passwd_data['dav']){
$protocol = 'dav';
} else if ($app_passwd_data['smtp']){
$protocol = 'smtp';
} else if ($app_passwd_data['imap']){
$protocol = 'imap';
} else if ($app_passwd_data['sieve']){
$protocol = 'sieve';
} else if ($app_passwd_data['pop3']){
$protocol = 'pop3';
} else if (!$is_internal) {
return false; return false;
} }
@@ -471,7 +458,7 @@ function apppass_login($user, $pass, $extra = null){
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) { foreach ($rows as $row) {
if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){ if ($protocol && $row[$protocol . '_access'] != '1'){
continue; continue;
} }
+207
View File
@@ -0,0 +1,207 @@
<?php
function docker($action, $service_name = null, $attr1 = null, $attr2 = null, $extra_headers = null) {
global $DOCKER_TIMEOUT;
global $redis;
$curl = curl_init();
curl_setopt($curl, CURLOPT_HTTPHEADER,array('Content-Type: application/json' ));
// We are using our mail certificates for dockerapi, the names will not match, the certs are trusted anyway
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
switch($action) {
case 'get_id':
curl_setopt($curl, CURLOPT_URL, 'https://dockerapi:443/containers/json');
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_POST, 0);
curl_setopt($curl, CURLOPT_TIMEOUT, $DOCKER_TIMEOUT);
$response = curl_exec($curl);
if ($response === false) {
$err = curl_error($curl);
curl_close($curl);
return $err;
}
else {
curl_close($curl);
$containers = json_decode($response, true);
if (!empty($containers)) {
foreach ($containers as $container) {
if (isset($container['Config']['Labels']['com.docker.compose.service'])
&& $container['Config']['Labels']['com.docker.compose.service'] == $service_name
&& strtolower($container['Config']['Labels']['com.docker.compose.project']) == strtolower(getenv('COMPOSE_PROJECT_NAME'))) {
return trim($container['Id']);
}
}
}
}
return false;
break;
case 'containers':
curl_setopt($curl, CURLOPT_URL, 'https://dockerapi:443/containers/json');
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_POST, 0);
curl_setopt($curl, CURLOPT_TIMEOUT, $DOCKER_TIMEOUT);
$response = curl_exec($curl);
if ($response === false) {
$err = curl_error($curl);
curl_close($curl);
return $err;
}
else {
curl_close($curl);
$containers = json_decode($response, true);
if (!empty($containers)) {
foreach ($containers as $container) {
if (strtolower($container['Config']['Labels']['com.docker.compose.project']) == strtolower(getenv('COMPOSE_PROJECT_NAME'))) {
$out[$container['Config']['Labels']['com.docker.compose.service']]['State'] = $container['State'];
$out[$container['Config']['Labels']['com.docker.compose.service']]['Config'] = $container['Config'];
$out[$container['Config']['Labels']['com.docker.compose.service']]['Id'] = trim($container['Id']);
}
}
}
return (!empty($out)) ? $out : false;
}
return false;
break;
case 'info':
if (empty($service_name)) {
curl_setopt($curl, CURLOPT_URL, 'https://dockerapi:443/containers/json');
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_POST, 0);
curl_setopt($curl, CURLOPT_TIMEOUT, $DOCKER_TIMEOUT);
}
else {
$container_id = docker('get_id', $service_name);
if (ctype_xdigit($container_id)) {
curl_setopt($curl, CURLOPT_URL, 'https://dockerapi:443/containers/' . $container_id . '/json');
}
else {
return false;
}
}
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_POST, 0);
curl_setopt($curl, CURLOPT_TIMEOUT, $DOCKER_TIMEOUT);
$response = curl_exec($curl);
if ($response === false) {
$err = curl_error($curl);
curl_close($curl);
return $err;
}
else {
curl_close($curl);
$decoded_response = json_decode($response, true);
if (!empty($decoded_response)) {
if (empty($service_name)) {
foreach ($decoded_response as $container) {
if (isset($container['Config']['Labels']['com.docker.compose.project'])
&& strtolower($container['Config']['Labels']['com.docker.compose.project']) == strtolower(getenv('COMPOSE_PROJECT_NAME'))) {
unset($container['Config']['Env']);
$out[$container['Config']['Labels']['com.docker.compose.service']]['State'] = $container['State'];
$out[$container['Config']['Labels']['com.docker.compose.service']]['Config'] = $container['Config'];
$out[$container['Config']['Labels']['com.docker.compose.service']]['Id'] = trim($container['Id']);
}
}
}
else {
if (isset($decoded_response['Config']['Labels']['com.docker.compose.project'])
&& strtolower($decoded_response['Config']['Labels']['com.docker.compose.project']) == strtolower(getenv('COMPOSE_PROJECT_NAME'))) {
unset($container['Config']['Env']);
$out[$decoded_response['Config']['Labels']['com.docker.compose.service']]['State'] = $decoded_response['State'];
$out[$decoded_response['Config']['Labels']['com.docker.compose.service']]['Config'] = $decoded_response['Config'];
$out[$decoded_response['Config']['Labels']['com.docker.compose.service']]['Id'] = trim($decoded_response['Id']);
}
}
}
if (empty($response)) {
return true;
}
else {
return (!empty($out)) ? $out : false;
}
}
break;
case 'post':
if (!empty($attr1)) {
$container_id = docker('get_id', $service_name);
if (ctype_xdigit($container_id) && ctype_alnum($attr1)) {
curl_setopt($curl, CURLOPT_URL, 'https://dockerapi:443/containers/' . $container_id . '/' . $attr1);
curl_setopt($curl, CURLOPT_POST, 1);
curl_setopt($curl, CURLOPT_TIMEOUT, $DOCKER_TIMEOUT);
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);
if ($response === false) {
$err = curl_error($curl);
curl_close($curl);
return $err;
}
else {
curl_close($curl);
if (empty($response)) {
return true;
}
else {
return $response;
}
}
}
}
break;
case 'container_stats':
if (empty($service_name)){
return false;
}
$container_id = $service_name;
curl_setopt($curl, CURLOPT_URL, 'https://dockerapi:443/container/' . $container_id . '/stats/update');
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_POST, 1);
curl_setopt($curl, CURLOPT_TIMEOUT, $DOCKER_TIMEOUT);
$response = curl_exec($curl);
if ($response === false) {
$err = curl_error($curl);
curl_close($curl);
return $err;
}
else {
curl_close($curl);
$stats = json_decode($response, true);
if (!empty($stats)) return $stats;
}
return false;
break;
case 'host_stats':
curl_setopt($curl, CURLOPT_URL, 'https://dockerapi:443/host/stats');
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_POST, 0);
curl_setopt($curl, CURLOPT_TIMEOUT, $DOCKER_TIMEOUT);
$response = curl_exec($curl);
if ($response === false) {
$err = curl_error($curl);
curl_close($curl);
return $err;
}
else {
curl_close($curl);
$stats = json_decode($response, true);
if (!empty($stats)) return $stats;
}
return false;
break;
case 'broadcast':
$request = array(
"api_call" => "container_post",
"container_name" => $service_name,
"post_action" => $attr1,
"request" => $attr2
);
$redis->publish("MC_CHANNEL", json_encode($request));
return true;
break;
}
}
+7 -26
View File
@@ -195,23 +195,17 @@ function domain_admin($_action, $_data = null) {
)); ));
} }
} }
$force_tfa = intval($_data['force_tfa'] ?? 0) ? 1 : 0;
$force_pw_update = intval($_data['force_pw_update'] ?? 0) ? 1 : 0;
if (!empty($password)) { if (!empty($password)) {
if (password_check($password, $password2) !== true) { if (password_check($password, $password2) !== true) {
return false; return false;
} }
$password_hashed = hash_password($password); $password_hashed = hash_password($password);
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed, $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed WHERE `username` = :username");
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
WHERE `username` = :username");
$stmt->execute(array( $stmt->execute(array(
':password_hashed' => $password_hashed, ':password_hashed' => $password_hashed,
':username_new' => $username_new, ':username_new' => $username_new,
':username' => $username, ':username' => $username,
':active' => $active, ':active' => $active
':force_tfa' => strval($force_tfa),
':force_pw_update' => strval($force_pw_update)
)); ));
if (isset($_data['disable_tfa'])) { if (isset($_data['disable_tfa'])) {
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username"); $stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
@@ -223,15 +217,11 @@ function domain_admin($_action, $_data = null) {
} }
} }
else { else {
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, $stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active WHERE `username` = :username");
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
WHERE `username` = :username");
$stmt->execute(array( $stmt->execute(array(
':username_new' => $username_new, ':username_new' => $username_new,
':username' => $username, ':username' => $username,
':active' => $active, ':active' => $active
':force_tfa' => strval($force_tfa),
':force_pw_update' => strval($force_pw_update)
)); ));
if (isset($_data['disable_tfa'])) { if (isset($_data['disable_tfa'])) {
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username"); $stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
@@ -254,12 +244,10 @@ function domain_admin($_action, $_data = null) {
// Can only edit itself // Can only edit itself
elseif ($_SESSION['mailcow_cc_role'] == "domainadmin") { elseif ($_SESSION['mailcow_cc_role'] == "domainadmin") {
$username = $_SESSION['mailcow_cc_username']; $username = $_SESSION['mailcow_cc_username'];
$password_old = $_data['user_old_pass'] ?? ''; $password_old = $_data['user_old_pass'];
$password_new = $_data['user_new_pass']; $password_new = $_data['user_new_pass'];
$password_new2 = $_data['user_new_pass2']; $password_new2 = $_data['user_new_pass2'];
// Only verify old password if this is NOT a forced password update
if (empty($_SESSION['pending_pw_update'])) {
$stmt = $pdo->prepare("SELECT `password` FROM `admin` $stmt = $pdo->prepare("SELECT `password` FROM `admin`
WHERE `username` = :user"); WHERE `username` = :user");
$stmt->execute(array(':user' => $username)); $stmt->execute(array(':user' => $username));
@@ -272,19 +260,15 @@ function domain_admin($_action, $_data = null) {
); );
return false; return false;
} }
}
if (password_check($password_new, $password_new2) !== true) { if (password_check($password_new, $password_new2) !== true) {
return false; return false;
} }
$password_hashed = hash_password($password_new); $password_hashed = hash_password($password_new);
$stmt = $pdo->prepare("UPDATE `admin` SET `password` = :password_hashed, $stmt = $pdo->prepare("UPDATE `admin` SET `password` = :password_hashed WHERE `username` = :username");
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_pw_update', '0')
WHERE `username` = :username");
$stmt->execute(array( $stmt->execute(array(
':password_hashed' => $password_hashed, ':password_hashed' => $password_hashed,
':username' => $username ':username' => $username
)); ));
unset($_SESSION['pending_pw_update']);
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'success', 'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_data_log), 'log' => array(__FUNCTION__, $_action, $_data_log),
@@ -376,11 +360,9 @@ function domain_admin($_action, $_data = null) {
`tfa`.`active` AS `tfa_active`, `tfa`.`active` AS `tfa_active`,
`domain_admins`.`username`, `domain_admins`.`username`,
`domain_admins`.`created`, `domain_admins`.`created`,
`domain_admins`.`active` AS `active`, `domain_admins`.`active` AS `active`
`admin`.`attributes` AS `attributes`
FROM `domain_admins` FROM `domain_admins`
LEFT OUTER JOIN `tfa` ON `tfa`.`username`=`domain_admins`.`username` LEFT OUTER JOIN `tfa` ON `tfa`.`username`=`domain_admins`.`username`
LEFT OUTER JOIN `admin` ON `admin`.`username`=`domain_admins`.`username`
WHERE `domain_admins`.`username`= :domain_admin"); WHERE `domain_admins`.`username`= :domain_admin");
$stmt->execute(array( $stmt->execute(array(
':domain_admin' => $_data ':domain_admin' => $_data
@@ -395,7 +377,6 @@ function domain_admin($_action, $_data = null) {
$domainadmindata['active'] = $row['active']; $domainadmindata['active'] = $row['active'];
$domainadmindata['active_int'] = $row['active']; $domainadmindata['active_int'] = $row['active'];
$domainadmindata['created'] = $row['created']; $domainadmindata['created'] = $row['created'];
$domainadmindata['attributes'] = json_decode($row['attributes'], true) ?? array('force_tfa' => '0', 'force_pw_update' => '0');
// GET SELECTED // GET SELECTED
$stmt = $pdo->prepare("SELECT `domain` FROM `domain` $stmt = $pdo->prepare("SELECT `domain` FROM `domain`
WHERE `domain` IN ( WHERE `domain` IN (
+2 -2
View File
@@ -109,7 +109,7 @@ function fail2ban($_action, $_data = null, $_extra = null) {
return false; return false;
} }
// Rules will also be recreated on log events, but rules may seem empty for a second in the UI // Rules will also be recreated on log events, but rules may seem empty for a second in the UI
agent('request', 'netfilter', 'restart', array(), 30); docker('post', 'netfilter-mailcow', 'restart');
$fail_count = 0; $fail_count = 0;
$regex_result = json_decode($redis->Get('F2B_REGEX'), true); $regex_result = json_decode($redis->Get('F2B_REGEX'), true);
while (empty($regex_result) && $fail_count < 10) { while (empty($regex_result) && $fail_count < 10) {
@@ -206,7 +206,7 @@ function fail2ban($_action, $_data = null, $_extra = null) {
try { try {
$redis->hSet('F2B_BLACKLIST', $network, 1); $redis->hSet('F2B_BLACKLIST', $network, 1);
$redis->hDel('F2B_WHITELIST', $network, 1); $redis->hDel('F2B_WHITELIST', $network, 1);
// netfilter picks up the redis changes //$response = docker('post', 'netfilter-mailcow', 'restart');
} }
catch (RedisException $e) { catch (RedisException $e) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(

Some files were not shown because too many files have changed in this diff Show More