mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-06-12 17:40:25 +00:00
Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 689d753264 | |||
| 4ddcee28e4 | |||
| b0d16bbcee | |||
| 9175e5f086 | |||
| ff3d571054 | |||
| a8e945f3da | |||
| 33547d1d73 | |||
| 539c32d99c | |||
| d42c3823c4 | |||
| 886dbcc419 | |||
| dc15994d40 | |||
| ec24825280 | |||
| 5a00b5124b | |||
| 8c039f694f | |||
| 95bf46c1e4 | |||
| edde35156d | |||
| 84e3c32f13 | |||
| ecb848493b | |||
| 8a65b9d1c6 | |||
| ed9264fd2a | |||
| 7817dda43f | |||
| 018e292854 | |||
| 127fb1e8f5 | |||
| 09f09cb850 | |||
| d4bf377a96 | |||
| abd6fe8c79 | |||
| 5f8382ef44 | |||
| 03eccd4e42 | |||
| 1da8d1c894 | |||
| d1feebf164 | |||
| 293b885a85 | |||
| 1e0850193a | |||
| 33acf56526 | |||
| bea9ad7e8f | |||
| e7ea3aa608 | |||
| 5888e248c3 | |||
| 2e176339ba | |||
| 8f883f3d37 | |||
| 709117fe19 | |||
| 82ea418423 | |||
| fd24163c6e | |||
| 3caef45a12 | |||
| 8760e7e5db | |||
| e848226062 | |||
| efaeb77e13 | |||
| 569b4cf985 | |||
| 6c857243ec | |||
| 905e93627c | |||
| 598ea21827 | |||
| 6012cf4486 | |||
| fe71f84c82 | |||
| 763ecbc93e | |||
| 97312c1a9d | |||
| baf0e7e658 | |||
| a4ca5f9363 | |||
| dcbea71e67 | |||
| 524f19ff77 | |||
| 4e33c7143f | |||
| 4c184b25f4 | |||
| ddc2309f1e | |||
| bccfcefd17 | |||
| d40252ccce | |||
| 4abb5cbfab | |||
| b695936273 | |||
| d9463c7950 | |||
| 579542381a | |||
| ce5659f300 | |||
| 1967cb642f | |||
| ba0eb04ebe | |||
| 609ce6b0d6 | |||
| af61e2d303 | |||
| c6e3f517e1 | |||
| 6eaa8d43e9 | |||
| c033cd6254 | |||
| 7562578b74 | |||
| 43f570e761 | |||
| 5e478a32df | |||
| 22c94a4d2f | |||
| 99dc0f6616 | |||
| 4732f568fa | |||
| 96d4802cb2 | |||
| ad5b94af5e | |||
| 7fce984cac | |||
| 404e2f0190 | |||
| 1c52eaa3a4 | |||
| c7e04b4146 | |||
| 075959aea9 | |||
| 885ba2510e | |||
| 87563249f1 | |||
| fb1686065d | |||
| 0428f5c9bd | |||
| a70c23065c | |||
| caaa4a414d | |||
| c3d841340c | |||
| 2e8897c2cf | |||
| b8cd00111f | |||
| 81cda80651 | |||
| c1d4f04c22 | |||
| 82276cd1ca | |||
| 56ea4302ed | |||
| c06112b26e | |||
| aa5a4f0998 | |||
| bf4f471cfd | |||
| 978bff9dbc | |||
| 869d9af7dd | |||
| af10499ecb | |||
| a1a4d8ff98 | |||
| 95d61e8aa2 | |||
| ec8dd1a54f | |||
| 382ee34d0e | |||
| 0999c9e9ab | |||
| c485968e7f | |||
| e727620bd3 | |||
| 71fa3ecebc | |||
| 70101d1187 | |||
| c060c205d3 | |||
| 5ca900749c | |||
| b005803fe0 | |||
| ec77406dba | |||
| ee15721550 | |||
| 038b2efb75 | |||
| 1fe4cd03e9 | |||
| 12e02e67ff | |||
| b6f57dfb78 | |||
| 3ebf2c2d2d | |||
| 1bac6f1ee7 | |||
| 67e7acd6bd | |||
| 910ce573d6 | |||
| 689336b3e1 | |||
| 01cf72cdef | |||
| 4cdb97c699 | |||
| 1bd795a9c6 | |||
| 39f29e6c30 | |||
| 1ab6af21e3 | |||
| 5d95c48e0d | |||
| 890295bbfc | |||
| a52e977b89 | |||
| 95e0608749 |
@@ -14,7 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Mark/Close Stale Issues and Pull Requests 🗑️
|
||||
uses: actions/stale@v10.1.1
|
||||
uses: actions/stale@v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.STALE_ACTION_PAT }}
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -16,21 +16,21 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
bash helper-scripts/update_postscreen_whitelist.sh
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.mailcow_action_Update_postscreen_access_cidr_pat }}
|
||||
commit-message: update postscreen_access.cidr
|
||||
|
||||
@@ -51,6 +51,7 @@ data/conf/sogo/cron.creds
|
||||
data/conf/sogo/custom-fulllogo.svg
|
||||
data/conf/sogo/custom-shortlogo.svg
|
||||
data/conf/sogo/custom-fulllogo.png
|
||||
data/conf/acme/dns-01.conf
|
||||
data/gitea/
|
||||
data/gogs/
|
||||
data/hooks/dovecot/*
|
||||
|
||||
@@ -57,11 +57,14 @@ adapt_new_options() {
|
||||
"DISABLE_NETFILTER_ISOLATION_RULE"
|
||||
"HTTP_REDIRECT"
|
||||
"ENABLE_IPV6"
|
||||
"ACME_DNS_CHALLENGE"
|
||||
"ACME_DNS_PROVIDER"
|
||||
"ACME_ACCOUNT_EMAIL"
|
||||
)
|
||||
|
||||
sed -i --follow-symlinks '$a\' mailcow.conf
|
||||
for option in ${CONFIG_ARRAY[@]}; do
|
||||
if grep -q "${option}" mailcow.conf; then
|
||||
if grep -q "^#\?${option}=" mailcow.conf; then
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -292,6 +295,20 @@ adapt_new_options() {
|
||||
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
|
||||
;;
|
||||
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
|
||||
;;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
FROM alpine:3.21
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -14,14 +18,35 @@ RUN apk upgrade --no-cache \
|
||||
tini \
|
||||
tzdata \
|
||||
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 functions.sh /srv/functions.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 expand6.sh /srv/expand6.sh
|
||||
|
||||
RUN chmod +x /srv/*.sh
|
||||
|
||||
CMD ["/sbin/tini", "-g", "--", "/srv/acme.sh"]
|
||||
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
|
||||
|
||||
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 []
|
||||
|
||||
@@ -14,6 +14,17 @@ until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
|
||||
sleep 2
|
||||
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
|
||||
# Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6
|
||||
source /srv/expand6.sh
|
||||
@@ -42,17 +53,21 @@ if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
ENABLE_SSL_SNI=y
|
||||
fi
|
||||
|
||||
if [[ "${ACME_DNS_CHALLENGE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
ACME_DNS_CHALLENGE=y
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..."
|
||||
sleep 365d
|
||||
exec $(readlink -f "$0")
|
||||
fi
|
||||
|
||||
log_f "Waiting for Docker API..."
|
||||
until ping dockerapi -c1 > /dev/null; do
|
||||
log_f "Waiting for Redis control bus..."
|
||||
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
|
||||
sleep 1
|
||||
done
|
||||
log_f "Docker API OK"
|
||||
log_f "Redis control bus OK"
|
||||
|
||||
log_f "Waiting for Postfix..."
|
||||
until ping postfix -c1 > /dev/null; do
|
||||
@@ -238,14 +253,46 @@ while true; do
|
||||
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
|
||||
if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
|
||||
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}")
|
||||
fi
|
||||
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
|
||||
|
||||
# Skip if subdomain matches MAILCOW_HOSTNAME
|
||||
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
|
||||
continue
|
||||
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
|
||||
done
|
||||
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
|
||||
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
|
||||
|
||||
if check_domain ${MAILCOW_HOSTNAME}; then
|
||||
@@ -274,13 +321,31 @@ while true; do
|
||||
done
|
||||
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
|
||||
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
|
||||
# create certificate for server name and fqdn SANs only
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
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))
|
||||
fi
|
||||
else
|
||||
# create certificate for all domains, including all subdomains from other domains [*]
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
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))
|
||||
fi
|
||||
fi
|
||||
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
|
||||
CERT_NAME=${SERVER_SAN_VALIDATED[0]}
|
||||
|
||||
@@ -80,6 +80,11 @@ check_domain(){
|
||||
return 1
|
||||
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
|
||||
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=
|
||||
@@ -130,3 +135,32 @@ verify_challenge_path(){
|
||||
return 1
|
||||
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
|
||||
}
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/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,6 +20,10 @@ if [[ "${TYPE}" != "rsa" ]]; then
|
||||
log_f "Unknown certificate type '${TYPE}' requested"
|
||||
exit 5
|
||||
fi
|
||||
|
||||
if [[ "${ACME_DNS_CHALLENGE}" == "y" ]]; then
|
||||
exec /srv/obtain-certificate-dns.sh "$@"
|
||||
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
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
#!/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.
|
||||
|
||||
# Reading container IDs
|
||||
# Wrapping as array to ensure trimmed content when calling $NGINX etc.
|
||||
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" " "))
|
||||
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" " "))
|
||||
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" " "))
|
||||
|
||||
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} ; }
|
||||
reload_service() {
|
||||
local svc="$1"
|
||||
echo "Reloading ${svc} via mailcow-agent..."
|
||||
if ! mailcow-agent-cli send "${svc}" reload >/dev/null; then
|
||||
echo "Could not publish reload to ${svc}, attempting restart..."
|
||||
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
reload_dovecot(){
|
||||
echo "Reloading Dovecot..."
|
||||
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)
|
||||
[[ ${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
|
||||
restart_service() {
|
||||
local svc="$1"
|
||||
echo "Restarting ${svc} via mailcow-agent..."
|
||||
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
||||
}
|
||||
|
||||
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
|
||||
restart_container ${NGINX}
|
||||
restart_container ${DOVECOT}
|
||||
restart_container ${POSTFIX}
|
||||
restart_service nginx
|
||||
restart_service dovecot
|
||||
restart_service postfix
|
||||
else
|
||||
reload_nginx
|
||||
#reload_dovecot
|
||||
restart_container ${DOVECOT}
|
||||
#reload_postfix
|
||||
restart_container ${POSTFIX}
|
||||
reload_service nginx
|
||||
restart_service dovecot
|
||||
restart_service postfix
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# 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
|
||||
@@ -0,0 +1,16 @@
|
||||
# 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).
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
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=
|
||||
@@ -0,0 +1,175 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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"
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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:])
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/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
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
@@ -41,7 +45,7 @@ RUN wget -P /src https://www.clamav.net/downloads/production/clamav-${CLAMD_VERS
|
||||
-D ENABLE_MILTER=ON \
|
||||
-D ENABLE_MAN_PAGES=OFF \
|
||||
-D ENABLE_STATIC_LIB=OFF \
|
||||
-D ENABLE_JSON_SHARED=ON \
|
||||
-D ENABLE_JSON_SHARED=ON \
|
||||
&& cmake --build . \
|
||||
&& make DESTDIR="/clamav" -j$(($(nproc) - 1)) install \
|
||||
&& rm -r "/clamav/usr/lib/pkgconfig/" \
|
||||
@@ -104,7 +108,15 @@ COPY healthcheck.sh /healthcheck.sh
|
||||
COPY clamdcheck.sh /usr/local/bin
|
||||
RUN chmod +x /healthcheck.sh
|
||||
RUN chmod +x /usr/local/bin/clamdcheck.sh
|
||||
HEALTHCHECK --start-period=6m CMD "/healthcheck.sh"
|
||||
|
||||
ENTRYPOINT []
|
||||
CMD ["/sbin/tini", "-g", "--", "/clamd.sh"]
|
||||
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
|
||||
|
||||
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 []
|
||||
@@ -1,27 +0,0 @@
|
||||
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"]
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,261 +0,0 @@
|
||||
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"
|
||||
)
|
||||
@@ -1,626 +0,0 @@
|
||||
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")
|
||||
@@ -1,9 +1,13 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.17
|
||||
ARG GOSU_VERSION=1.19
|
||||
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
@@ -135,5 +139,14 @@ COPY quota_notify.py /usr/local/bin/quota_notify.py
|
||||
COPY repl_health.sh /usr/local/bin/repl_health.sh
|
||||
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
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
|
||||
|
||||
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 == "":
|
||||
max_score = 9999.0
|
||||
|
||||
def query_mysql(query, headers = True, update = False):
|
||||
def query_mysql(query, params = None, headers = True, update = False):
|
||||
while True:
|
||||
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")
|
||||
@@ -57,7 +57,10 @@ try:
|
||||
else:
|
||||
break
|
||||
cur = cnx.cursor()
|
||||
cur.execute(query)
|
||||
if params:
|
||||
cur.execute(query, params)
|
||||
else:
|
||||
cur.execute(query)
|
||||
if not update:
|
||||
result = []
|
||||
columns = tuple( [d[0] for d in cur.description] )
|
||||
@@ -76,7 +79,7 @@ try:
|
||||
|
||||
def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
|
||||
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 < %f 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 < %s 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))
|
||||
if len(meta_query) == 0:
|
||||
return
|
||||
@@ -130,7 +133,7 @@ try:
|
||||
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
|
||||
server.quit()
|
||||
for res in meta_query:
|
||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%d"' % (res['id']), update = True)
|
||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = %s', (res['id'],), update = True)
|
||||
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
|
||||
break
|
||||
except Exception as ex:
|
||||
@@ -138,7 +141,7 @@ try:
|
||||
print('%s' % (ex))
|
||||
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 < %f 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 < %s AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt', (max_score,))
|
||||
|
||||
for record in records:
|
||||
attrs = ''
|
||||
@@ -156,7 +159,7 @@ try:
|
||||
except Exception as ex:
|
||||
print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
|
||||
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']
|
||||
if isinstance(attrs, str):
|
||||
# if attr is str then just load it
|
||||
|
||||
@@ -24,13 +24,12 @@ fi
|
||||
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
|
||||
CONTAINER_NAME=rspamd-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(\"${CONTAINER_NAME}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
|
||||
if [[ ! -z ${CONTAINER_ID} ]]; then
|
||||
curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
|
||||
fi
|
||||
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
|
||||
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
|
||||
REQ_ID="$(date +%s%N)"
|
||||
PAYLOAD="{\"cmd\":\"restart\",\"request_id\":\"${REQ_ID}\",\"issued_by\":\"dovecot-sa-rules\"}"
|
||||
redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning \
|
||||
PUBLISH mailcow.control.rspamd "${PAYLOAD}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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"]
|
||||
@@ -1,4 +1,8 @@
|
||||
FROM alpine:3.21
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -40,4 +44,14 @@ COPY ./docker-entrypoint.sh /app/
|
||||
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"]
|
||||
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
|
||||
|
||||
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 []
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM nginx:alpine
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -14,5 +18,14 @@ RUN mkdir -p /etc/nginx/includes
|
||||
COPY ./bootstrap.py /
|
||||
COPY ./docker-entrypoint.sh /
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
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
|
||||
|
||||
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,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
@@ -18,6 +22,16 @@ ADD olefy.py /app/
|
||||
|
||||
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
|
||||
|
||||
CMD ["python3", "-u", "/app/olefy.py"]
|
||||
ENV MAILCOW_AGENT_SERVICE=olefy \
|
||||
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 []
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM php:8.2-fpm-alpine3.21
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG APCU_PECL_VERSION=5.1.27
|
||||
ARG APCU_PECL_VERSION=5.1.28
|
||||
# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG IMAGICK_PECL_VERSION=3.8.0
|
||||
ARG IMAGICK_PECL_VERSION=3.8.1
|
||||
# renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG MAILPARSE_PECL_VERSION=3.1.9
|
||||
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG MEMCACHED_PECL_VERSION=3.3.0
|
||||
ARG MEMCACHED_PECL_VERSION=3.4.0
|
||||
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG REDIS_PECL_VERSION=6.2.0
|
||||
ARG REDIS_PECL_VERSION=6.3.0
|
||||
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG COMPOSER_VERSION=2.8.6
|
||||
ARG COMPOSER_VERSION=2.9.5
|
||||
|
||||
RUN apk add -U --no-cache autoconf \
|
||||
aspell-dev \
|
||||
@@ -72,7 +76,7 @@ RUN apk add -U --no-cache autoconf \
|
||||
&& pecl clear-cache \
|
||||
&& docker-php-ext-configure intl \
|
||||
&& docker-php-ext-configure exif \
|
||||
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
||||
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
||||
--with-jpeg=/usr/include/ \
|
||||
--with-webp \
|
||||
--with-xpm \
|
||||
@@ -109,6 +113,14 @@ RUN apk add -U --no-cache autoconf \
|
||||
|
||||
COPY ./docker-entrypoint.sh /
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
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
|
||||
|
||||
CMD ["php-fpm"]
|
||||
ENV MAILCOW_AGENT_SERVICE=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 []
|
||||
|
||||
@@ -29,63 +29,35 @@ session.save_handler = redis
|
||||
session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'"
|
||||
' > /usr/local/etc/php/conf.d/session_store.ini
|
||||
|
||||
# Check mysql_upgrade (master and slave)
|
||||
CONTAINER_ID=
|
||||
until [[ ! -z "${CONTAINER_ID}" ]] && [[ "${CONTAINER_ID}" =~ ^[[:alnum:]]*$ ]]; do
|
||||
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)
|
||||
echo "Could not get mysql-mailcow container id... trying again"
|
||||
sleep 2
|
||||
done
|
||||
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)"
|
||||
# Wait for MariaDB. The upstream mariadb image already runs mariadb-upgrade
|
||||
# itself on startup when needed
|
||||
echo "Waiting for MariaDB socket at /var/run/mysqld/mysqld.sock..."
|
||||
WAIT_C=0
|
||||
until mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} -e "SELECT 1" >/dev/null 2>&1; do
|
||||
WAIT_C=$((WAIT_C+1))
|
||||
if [ ${WAIT_C} -gt 60 ]; then
|
||||
echo "MariaDB did not respond after 60s — continuing anyway."
|
||||
break
|
||||
fi
|
||||
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
|
||||
sleep 1
|
||||
done
|
||||
echo "MariaDB is ready."
|
||||
|
||||
# doing post-installation stuff, if SQL was upgraded (master and slave)
|
||||
if [ ${SQL_CHANGED} -eq 1 ]; then
|
||||
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)
|
||||
# Timezone tables — check if CONVERT_TZ works, import if it returns NULL.
|
||||
# Some Alpine builds drop mariadb-tzinfo-to-sql; fall back to a Python
|
||||
# emitter that produces the same INSERT statements from /usr/share/zoneinfo.
|
||||
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
|
||||
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')
|
||||
echo "MySQL mysql_tzinfo_to_sql - debug output:"
|
||||
echo ${SQL_FULL_TZINFO_IMPORT_RETURN}
|
||||
echo "Importing timezone data into mysql.time_zone_* …"
|
||||
if command -v mariadb-tzinfo-to-sql >/dev/null 2>&1; then
|
||||
mariadb-tzinfo-to-sql /usr/share/zoneinfo 2>/dev/null \
|
||||
| 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
|
||||
|
||||
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
@@ -45,6 +49,14 @@ RUN chmod +x /opt/postfix-tlspol.sh \
|
||||
/docker-entrypoint.sh
|
||||
RUN rm -rf /tmp/* /var/tmp/*
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
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
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
ENV MAILCOW_AGENT_SERVICE=postfix-tlspol \
|
||||
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")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
filter f_mail { facility(mail); };
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
filter f_mail { facility(mail); };
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
@@ -58,6 +62,14 @@ RUN rm -rf /tmp/* /var/tmp/*
|
||||
|
||||
EXPOSE 588
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
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
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
ENV MAILCOW_AGENT_SERVICE=postfix \
|
||||
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 []
|
||||
|
||||
@@ -329,14 +329,17 @@ query = SELECT goto FROM alias
|
||||
SELECT id FROM alias
|
||||
WHERE address='%s'
|
||||
AND (active='1' OR active='2')
|
||||
AND sender_allowed='1'
|
||||
), (
|
||||
SELECT id FROM alias
|
||||
WHERE address='@%d'
|
||||
AND (active='1' OR active='2')
|
||||
AND sender_allowed='1'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND active='1'
|
||||
AND sender_allowed='1'
|
||||
AND (domain IN
|
||||
(SELECT domain FROM domain
|
||||
WHERE domain='%d'
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
FROM debian:bookworm-slim
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM debian:trixie-slim
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG RSPAMD_VER=rspamd_3.13.2-1~8bf602278
|
||||
ARG CODENAME=bookworm
|
||||
ARG RSPAMD_VER=rspamd_3.14.3-1~236eb65
|
||||
ARG CODENAME=trixie
|
||||
ENV LC_ALL=C
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
@@ -33,8 +37,16 @@ COPY settings.conf /etc/rspamd/settings.conf
|
||||
COPY set_worker_password.sh /set_worker_password.sh
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
CMD ["/usr/bin/rspamd", "-f", "-u", "_rspamd", "-g", "_rspamd"]
|
||||
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
|
||||
|
||||
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 []
|
||||
|
||||
@@ -1,47 +1,171 @@
|
||||
FROM debian:bookworm-slim
|
||||
# SOGo built from source to enable security patch application
|
||||
# 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>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG DEBIAN_VERSION=bookworm
|
||||
ARG SOGO_DEBIAN_REPOSITORY=https://packagingv2.sogo.nu/sogo-nightly-debian/
|
||||
ARG SOGO_VERSION=SOGo-5.12.8
|
||||
ARG SOPE_VERSION=SOPE-5.12.8
|
||||
# Security patches to apply (space-separated commit hashes)
|
||||
ARG SOGO_SECURITY_PATCHES=""
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.17
|
||||
ARG GOSU_VERSION=1.19
|
||||
ENV LC_ALL=C
|
||||
|
||||
# Prerequisites
|
||||
RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
apt-transport-https \
|
||||
ca-certificates \
|
||||
gettext \
|
||||
gnupg \
|
||||
mariadb-client \
|
||||
rsync \
|
||||
supervisor \
|
||||
syslog-ng \
|
||||
syslog-ng-core \
|
||||
syslog-ng-mod-redis \
|
||||
dirmngr \
|
||||
netcat-traditional \
|
||||
psmisc \
|
||||
wget \
|
||||
patch \
|
||||
# Install dependencies, build SOPE and SOGo, then clean up (all in one layer to minimize image size)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Build dependencies
|
||||
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 \
|
||||
gettext \
|
||||
gnupg \
|
||||
mariadb-client \
|
||||
rsync \
|
||||
supervisor \
|
||||
syslog-ng \
|
||||
syslog-ng-core \
|
||||
syslog-ng-mod-redis \
|
||||
dirmngr \
|
||||
netcat-traditional \
|
||||
psmisc \
|
||||
wget \
|
||||
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 }')" \
|
||||
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
|
||||
&& chmod +x /usr/local/bin/gosu \
|
||||
&& gosu nobody true \
|
||||
&& mkdir /usr/share/doc/sogo \
|
||||
&& 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 \
|
||||
# Build SOPE
|
||||
&& 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 /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 syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
||||
@@ -54,6 +178,14 @@ COPY docker-entrypoint.sh /
|
||||
RUN chmod +x /bootstrap-sogo.sh \
|
||||
/usr/local/sbin/stop-supervisor.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
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
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
ENV MAILCOW_AGENT_SERVICE=sogo \
|
||||
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 []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
--- /usr/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:35.918291298 +0200
|
||||
--- /usr/local/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
|
||||
@@ -46,7 +46,7 @@
|
||||
</md-item-template>
|
||||
</md-autocomplete>
|
||||
|
||||
@@ -130,18 +130,22 @@ chmod 600 /var/lib/sogo/GNUstep/Defaults/sogod.plist
|
||||
# Patch ACLs
|
||||
#if [[ ${ACL_ANYONE} == 'allow' ]]; then
|
||||
# #enable any or authenticated targets for ACL
|
||||
# if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||
# patch -R /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||
# if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||
# patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||
# fi
|
||||
#else
|
||||
# #disable any or authenticated targets for ACL
|
||||
# if patch -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||
# patch /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||
# if patch -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||
# patch /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||
# fi
|
||||
#fi
|
||||
|
||||
if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
|
||||
patch -R /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
|
||||
# Apply custom UI patch (reverse patch to ADD buttons)
|
||||
if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
|
||||
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
|
||||
|
||||
# Rename custom logo, if any
|
||||
@@ -149,7 +153,7 @@ fi
|
||||
|
||||
# Rsync web content
|
||||
echo "Syncing web content with named volume"
|
||||
rsync -a /usr/lib/GNUstep/SOGo/. /sogo_web/
|
||||
rsync -a /usr/local/lib/GNUstep/SOGo/. /sogo_web/
|
||||
|
||||
# Chown backup path
|
||||
chown -R sogo:sogo /sogo_backup
|
||||
|
||||
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "SOGO_LOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "SOGO_LOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "SOGO_LOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "SOGO_LOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
FROM alpine:3.21
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -29,8 +33,15 @@ COPY supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.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'
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
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
|
||||
|
||||
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 []
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
FROM alpine:3.21
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -37,5 +41,16 @@ RUN apk add --update \
|
||||
COPY watchdog.sh /watchdog.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 client.cnf /etc/my.cnf.d/client.cnf
|
||||
|
||||
CMD ["/watchdog.sh"]
|
||||
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
|
||||
|
||||
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 []
|
||||
|
||||
@@ -19,19 +19,19 @@ if [ -z "$HOST" ]; then
|
||||
fi
|
||||
|
||||
# run dig and measure the time it takes to run
|
||||
START_TIME=$(date +%s%3N)
|
||||
START_TIME=$(perl -MTime::HiRes -e 'print Time::HiRes::time')
|
||||
dig_output=$(dig +short +timeout=2 +tries=1 "$HOST" @"$SERVER" 2>/dev/null)
|
||||
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 ',' -)
|
||||
END_TIME=$(date +%s%3N)
|
||||
ELAPSED_TIME=$((END_TIME - START_TIME))
|
||||
ELAPSED_TIME=$(perl -e "printf('%.3f', $END_TIME - $START_TIME)")
|
||||
|
||||
# validate and perform nagios like output and exit codes
|
||||
if [ $dig_rc -ne 0 ] || [ -z "$dig_output" ]; then
|
||||
echo "Domain $HOST was not found by the server"
|
||||
exit 2
|
||||
elif [ $dig_rc -eq 0 ]; then
|
||||
echo "DNS OK: $ELAPSED_TIME ms response time. $HOST returns $dig_output_ips"
|
||||
echo "DNS OK: $ELAPSED_TIME seconds response time. $HOST returns $dig_output_ips"
|
||||
exit 0
|
||||
else
|
||||
echo "Unknown error"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[client]
|
||||
ssl = false
|
||||
ssl-verify-server-cert = false
|
||||
@@ -38,7 +38,7 @@ if [[ ! -p /tmp/com_pipe ]]; then
|
||||
fi
|
||||
|
||||
# Wait for containers
|
||||
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
|
||||
while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
|
||||
echo "Waiting for SQL..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -188,44 +188,6 @@ function notify_error() {
|
||||
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
|
||||
if grep -qi "$(echo ${IPV6_NETWORK} | cut -d: -f1-3)" <<< "$(ip a s)"; then
|
||||
@@ -267,295 +229,6 @@ external_checks() {
|
||||
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() {
|
||||
err_count=0
|
||||
diff_c=0
|
||||
@@ -564,11 +237,9 @@ cert_checks() {
|
||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
||||
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}
|
||||
/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 ${host_ip_dovecot} -p 993 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${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_imap -H 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} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
||||
progress "Primary certificate expiry check" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
||||
@@ -578,31 +249,6 @@ cert_checks() {
|
||||
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() {
|
||||
err_count=0
|
||||
@@ -736,90 +382,63 @@ acme_checks() {
|
||||
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
|
||||
if [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
notify_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
|
||||
fi
|
||||
|
||||
# Create watchdog agents
|
||||
# Health checks run inside each container (mailcow-agent healthcheck + heartbeat).
|
||||
# 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
|
||||
if ! nginx_checks; then
|
||||
log_msg "Nginx hit error limit"
|
||||
echo nginx-mailcow > /tmp/com_pipe
|
||||
fi
|
||||
for svc in "${HEALTH_WATCHED_SERVICES[@]}"; do
|
||||
[[ -z "$svc" ]] && continue
|
||||
nodes=$(${REDIS_CMDLINE_FULL} ZRANGEBYSCORE "mailcow.nodes.${svc}" "$(( $(date +%s) - 30 ))" "+inf" 2>/dev/null)
|
||||
[[ -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
|
||||
else
|
||||
HEALTH_FAIL_COUNT[$key]=0
|
||||
fi
|
||||
done <<< "${nodes}"
|
||||
done
|
||||
sleep 15
|
||||
done
|
||||
) &
|
||||
PID=$!
|
||||
echo "Spawned nginx_checks with PID ${PID}"
|
||||
echo "Spawned registry-based health monitor with PID ${PID}"
|
||||
BACKGROUND_TASKS+=(${PID})
|
||||
|
||||
if [[ ${WATCHDOG_EXTERNAL_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
@@ -836,110 +455,6 @@ echo "Spawned external_checks with PID ${PID}"
|
||||
BACKGROUND_TASKS+=(${PID})
|
||||
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
|
||||
if ! mailq_checks; then
|
||||
@@ -952,54 +467,6 @@ PID=$!
|
||||
echo "Spawned mailq_checks with PID ${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
|
||||
if ! ratelimit_checks; then
|
||||
@@ -1036,20 +503,6 @@ PID=$!
|
||||
echo "Spawned cert_checks with PID ${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
|
||||
if ! acme_checks; then
|
||||
@@ -1075,15 +528,19 @@ while true; do
|
||||
done
|
||||
) &
|
||||
|
||||
# Monitor dockerapi
|
||||
# Pause background checks while Redis (the control bus) is unreachable, otherwise
|
||||
# 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 nc -z dockerapi 443; do
|
||||
while ping_bus; do
|
||||
sleep 3
|
||||
done
|
||||
log_msg "Cannot find dockerapi-mailcow, waiting to recover..."
|
||||
log_msg "Cannot reach redis-mailcow (control bus), waiting to recover..."
|
||||
kill -STOP ${BACKGROUND_TASKS[*]}
|
||||
until nc -z dockerapi 443; do
|
||||
until ping_bus; do
|
||||
sleep 3
|
||||
done
|
||||
kill -CONT ${BACKGROUND_TASKS[*]}
|
||||
@@ -1143,24 +600,33 @@ while true; do
|
||||
elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then
|
||||
kill -STOP ${BACKGROUND_TASKS[*]}
|
||||
sleep 10
|
||||
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")
|
||||
if [[ ! -z ${CONTAINER_ID} ]]; then
|
||||
if [[ "${com_pipe_answer}" == "php-fpm-mailcow" ]]; then
|
||||
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)
|
||||
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
|
||||
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
|
||||
# "<service>-mailcow|<node>" restarts a single replica; bare "<service>-mailcow"
|
||||
# broadcasts the restart to every replica of the service.
|
||||
AGENT_NODE=""
|
||||
AGENT_SVC="${com_pipe_answer%-mailcow}"
|
||||
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
|
||||
if [ ${S_RUNNING} -lt 360 ]; then
|
||||
log_msg "Container is running for less than 360 seconds, skipping action..."
|
||||
else
|
||||
if [[ -n "${AGENT_NODE}" ]]; then
|
||||
log_msg "Sending restart to ${AGENT_SVC} node ${AGENT_NODE} via control bus..."
|
||||
mailcow-agent-cli send "${AGENT_SVC}" restart "{\"target_node\":\"${AGENT_NODE}\"}" >/dev/null || true
|
||||
else
|
||||
log_msg "Sending restart command to ${CONTAINER_ID}..."
|
||||
curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
|
||||
notify_error "${com_pipe_answer}"
|
||||
log_msg "Wait for restarted container to settle and continue watching..."
|
||||
sleep 35
|
||||
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}"
|
||||
log_msg "Wait for restarted container to settle and continue watching..."
|
||||
sleep 35
|
||||
fi
|
||||
kill -CONT ${BACKGROUND_TASKS[*]}
|
||||
sleep 1
|
||||
|
||||
@@ -80,14 +80,21 @@ if ($isSOGoRequest) {
|
||||
}
|
||||
if ($result === false){
|
||||
// If it's a SOGo Request, don't check for protocol access
|
||||
$service = ($isSOGoRequest) ? false : array($post['service'] => true);
|
||||
$result = apppass_login($post['username'], $post['password'], $service, array(
|
||||
if ($isSOGoRequest) {
|
||||
$service = 'SOGO';
|
||||
$post['service'] = 'NONE';
|
||||
} else {
|
||||
$service = $post['service'];
|
||||
}
|
||||
|
||||
$result = apppass_login($post['username'], $post['password'], array(
|
||||
'service' => $post['service'],
|
||||
'is_internal' => true,
|
||||
'remote_addr' => $post['real_rip']
|
||||
));
|
||||
if ($result) {
|
||||
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'], $post['service']);
|
||||
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']);
|
||||
set_sasl_log($post['username'], $post['real_rip'], $service);
|
||||
}
|
||||
}
|
||||
if ($result === false){
|
||||
|
||||
@@ -261,19 +261,19 @@ location ~* /sogo$ {
|
||||
}
|
||||
|
||||
location /SOGo.woa/WebServerResources/ {
|
||||
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
|
||||
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
|
||||
}
|
||||
|
||||
location /.woa/WebServerResources/ {
|
||||
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
|
||||
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
|
||||
}
|
||||
|
||||
location /SOGo/WebServerResources/ {
|
||||
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
|
||||
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
|
||||
}
|
||||
|
||||
location (^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\.(jpg|png|gif|css|js)$) {
|
||||
alias /usr/lib/GNUstep/SOGo/$1.SOGo/Resources/$2;
|
||||
alias /usr/local/lib/GNUstep/SOGo/$1.SOGo/Resources/$2;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Whitelist generated by Postwhite v3.4 on Mon Dec 1 00:24:43 UTC 2025
|
||||
# Whitelist generated by Postwhite v3.4 on Fri May 1 00:43:37 UTC 2026
|
||||
# https://github.com/stevejenkins/postwhite/
|
||||
# 2186 total rules
|
||||
# 2249 total rules
|
||||
2a00:1450:4000::/36 permit
|
||||
2a00:1450:4864::/56 permit
|
||||
2a01:111:f400::/48 permit
|
||||
2a01:111:f403:2800::/53 permit
|
||||
2a01:111:f403:8000::/51 permit
|
||||
@@ -31,8 +32,11 @@
|
||||
2a02:a60:0:5::/64 permit
|
||||
2a0f:f640::/56 permit
|
||||
2c0f:fb50:4000::/36 permit
|
||||
2c0f:fb50:4864::/56 permit
|
||||
2.207.151.32/27 permit
|
||||
2.207.151.53 permit
|
||||
2.207.217.30 permit
|
||||
2.207.223.160/27 permit
|
||||
3.64.237.68 permit
|
||||
3.65.3.180 permit
|
||||
3.70.123.177 permit
|
||||
@@ -52,10 +56,14 @@
|
||||
8.25.194.0/23 permit
|
||||
8.25.196.0/23 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.40.222.0/23 permit
|
||||
8.40.222.250/31 permit
|
||||
12.130.86.238 permit
|
||||
13.107.213.69 permit
|
||||
13.107.246.69 permit
|
||||
13.107.213.40 permit
|
||||
13.107.246.40 permit
|
||||
13.108.16.0/20 permit
|
||||
13.110.208.0/21 permit
|
||||
13.110.209.0/24 permit
|
||||
@@ -169,6 +177,7 @@
|
||||
34.215.104.144 permit
|
||||
34.218.115.239 permit
|
||||
34.225.212.172 permit
|
||||
34.241.242.183 permit
|
||||
35.83.148.184 permit
|
||||
35.155.198.111 permit
|
||||
35.158.23.94 permit
|
||||
@@ -192,6 +201,7 @@
|
||||
40.233.64.216 permit
|
||||
40.233.83.78 permit
|
||||
40.233.88.28 permit
|
||||
43.239.212.33 permit
|
||||
44.206.138.57 permit
|
||||
44.210.169.44 permit
|
||||
44.217.45.156 permit
|
||||
@@ -271,8 +281,10 @@
|
||||
50.56.130.221 permit
|
||||
50.56.130.222 permit
|
||||
50.112.246.219 permit
|
||||
51.83.17.38 permit
|
||||
52.1.14.157 permit
|
||||
52.5.230.59 permit
|
||||
52.6.74.205 permit
|
||||
52.12.53.23 permit
|
||||
52.13.214.179 permit
|
||||
52.26.1.71 permit
|
||||
@@ -296,14 +308,6 @@
|
||||
52.94.124.0/28 permit
|
||||
52.95.48.152/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.102.0.0/16 permit
|
||||
52.103.0.0/17 permit
|
||||
@@ -337,6 +341,7 @@
|
||||
54.244.54.130 permit
|
||||
54.244.242.0/24 permit
|
||||
54.255.61.23 permit
|
||||
56.124.6.228 permit
|
||||
57.103.64.0/18 permit
|
||||
57.129.93.249 permit
|
||||
62.13.128.0/24 permit
|
||||
@@ -368,6 +373,7 @@
|
||||
64.132.88.0/23 permit
|
||||
64.132.92.0/24 permit
|
||||
64.181.194.190 permit
|
||||
64.181.213.254 permit
|
||||
64.207.219.7 permit
|
||||
64.207.219.8 permit
|
||||
64.207.219.9 permit
|
||||
@@ -397,22 +403,12 @@
|
||||
64.207.219.143 permit
|
||||
64.233.160.0/19 permit
|
||||
65.52.80.137 permit
|
||||
65.54.121.120/29 permit
|
||||
65.55.29.77 permit
|
||||
65.55.33.64/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.123.29.213 permit
|
||||
65.123.29.220 permit
|
||||
65.154.166.0/24 permit
|
||||
65.212.180.36 permit
|
||||
66.102.0.0/20 permit
|
||||
66.119.150.192/26 permit
|
||||
@@ -463,7 +459,11 @@
|
||||
66.218.75.252/31 permit
|
||||
66.218.75.254 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.178 permit
|
||||
66.220.155.179 permit
|
||||
66.220.157.0/25 permit
|
||||
66.231.80.0/20 permit
|
||||
66.240.227.0/24 permit
|
||||
@@ -528,8 +528,9 @@
|
||||
69.162.98.0/24 permit
|
||||
69.169.224.0/20 permit
|
||||
69.171.232.0/24 permit
|
||||
69.171.232.180 permit
|
||||
69.171.232.181 permit
|
||||
69.171.244.0/23 permit
|
||||
70.37.151.128/25 permit
|
||||
70.42.149.35 permit
|
||||
72.3.185.0/24 permit
|
||||
72.14.192.0/18 permit
|
||||
@@ -650,18 +651,28 @@
|
||||
77.238.189.148/30 permit
|
||||
79.135.106.0/24 permit
|
||||
79.135.107.0/24 permit
|
||||
80.225.160.128/25 permit
|
||||
81.169.146.243 permit
|
||||
81.169.146.245 permit
|
||||
81.169.146.246 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.13 permit
|
||||
82.165.159.14 permit
|
||||
82.165.159.34 permit
|
||||
82.165.159.35 permit
|
||||
82.165.159.40 permit
|
||||
82.165.159.41 permit
|
||||
82.165.159.42 permit
|
||||
82.165.159.45 permit
|
||||
82.165.159.130 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.210.45 permit
|
||||
85.158.136.0/21 permit
|
||||
@@ -712,11 +723,11 @@
|
||||
87.248.117.205 permit
|
||||
87.253.232.0/21 permit
|
||||
89.22.108.0/24 permit
|
||||
91.198.2.0/24 permit
|
||||
91.198.2.177 permit
|
||||
91.198.2.217 permit
|
||||
91.198.2.222 permit
|
||||
91.211.240.0/22 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.217.114.154 permit
|
||||
96.43.144.0/20 permit
|
||||
@@ -1211,6 +1222,9 @@
|
||||
99.78.197.208/28 permit
|
||||
103.9.96.0/22 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.168.172.128/27 permit
|
||||
103.237.104.0/22 permit
|
||||
@@ -1354,11 +1368,6 @@
|
||||
108.179.144.0/20 permit
|
||||
109.224.244.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.242.64/29 permit
|
||||
116.214.12.47 permit
|
||||
@@ -1376,6 +1385,9 @@
|
||||
117.120.16.0/21 permit
|
||||
119.42.242.52/31 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
|
||||
124.108.96.24/31 permit
|
||||
124.108.96.28/31 permit
|
||||
@@ -1412,6 +1424,20 @@
|
||||
129.146.88.28 permit
|
||||
129.146.147.105 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.153.62.216 permit
|
||||
129.153.104.71 permit
|
||||
@@ -1420,19 +1446,23 @@
|
||||
129.153.194.228 permit
|
||||
129.154.255.129 permit
|
||||
129.158.56.255 permit
|
||||
129.158.62.153 permit
|
||||
129.159.22.159 permit
|
||||
129.159.87.137 permit
|
||||
129.213.195.191 permit
|
||||
130.35.116.0/25 permit
|
||||
130.61.9.72 permit
|
||||
130.162.39.83 permit
|
||||
130.248.172.0/24 permit
|
||||
130.248.173.0/24 permit
|
||||
131.186.12.0/25 permit
|
||||
131.253.30.0/24 permit
|
||||
131.253.121.0/26 permit
|
||||
132.145.13.209 permit
|
||||
132.226.26.225 permit
|
||||
132.226.49.32 permit
|
||||
132.226.56.24 permit
|
||||
134.98.248.128/25 permit
|
||||
134.128.64.0/19 permit
|
||||
134.128.96.0/19 permit
|
||||
134.170.27.8 permit
|
||||
@@ -1440,6 +1470,10 @@
|
||||
134.170.141.64/26 permit
|
||||
134.170.143.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
|
||||
136.143.160.0/24 permit
|
||||
136.143.161.0/24 permit
|
||||
@@ -1459,6 +1493,10 @@
|
||||
136.147.182.0/24 permit
|
||||
136.147.224.0/20 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.138.35.44 permit
|
||||
139.138.46.121 permit
|
||||
@@ -1466,8 +1504,12 @@
|
||||
139.138.46.219 permit
|
||||
139.138.57.55 permit
|
||||
139.138.58.119 permit
|
||||
139.167.79.86 permit
|
||||
139.177.108.0/25 permit
|
||||
139.180.17.0/24 permit
|
||||
140.238.148.191 permit
|
||||
141.148.55.217 permit
|
||||
141.148.91.244 permit
|
||||
141.148.159.229 permit
|
||||
141.193.32.0/23 permit
|
||||
141.193.184.32/27 permit
|
||||
@@ -1502,6 +1544,9 @@
|
||||
146.88.28.0/24 permit
|
||||
146.148.116.76 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.48 permit
|
||||
147.243.1.153 permit
|
||||
@@ -1513,33 +1558,32 @@
|
||||
149.72.234.184 permit
|
||||
149.72.248.236 permit
|
||||
149.97.173.180 permit
|
||||
149.118.160.128/25 permit
|
||||
150.136.21.199 permit
|
||||
150.230.98.160 permit
|
||||
151.145.38.14 permit
|
||||
152.67.105.195 permit
|
||||
152.69.200.236 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.220.138 permit
|
||||
155.248.234.149 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.232.0/21 permit
|
||||
157.56.240.0/20 permit
|
||||
157.56.248.0/21 permit
|
||||
157.58.30.128/25 permit
|
||||
157.58.196.96/29 permit
|
||||
157.58.249.3 permit
|
||||
157.137.32.128/25 permit
|
||||
157.137.96.128/25 permit
|
||||
157.151.208.65 permit
|
||||
157.255.1.64/29 permit
|
||||
158.101.211.207 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.155.0/24 permit
|
||||
159.92.157.0/24 permit
|
||||
@@ -1561,8 +1605,10 @@
|
||||
159.135.224.0/20 permit
|
||||
159.135.228.10 permit
|
||||
159.183.0.0/16 permit
|
||||
159.183.14.233 permit
|
||||
159.183.68.71 permit
|
||||
159.183.79.38 permit
|
||||
159.183.121.182 permit
|
||||
159.183.129.172 permit
|
||||
160.1.62.192 permit
|
||||
161.38.192.0/20 permit
|
||||
@@ -1571,6 +1617,7 @@
|
||||
161.71.64.0/20 permit
|
||||
162.88.4.0/23 permit
|
||||
162.88.8.0/24 permit
|
||||
162.88.24.0/23 permit
|
||||
162.88.24.0/24 permit
|
||||
162.88.25.0/24 permit
|
||||
162.88.36.0/24 permit
|
||||
@@ -1582,9 +1629,18 @@
|
||||
163.114.135.16 permit
|
||||
163.116.128.0/17 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.25.241 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.221 permit
|
||||
166.78.69.169 permit
|
||||
@@ -1603,9 +1659,12 @@
|
||||
167.89.75.126 permit
|
||||
167.89.75.136 permit
|
||||
167.89.75.164 permit
|
||||
167.89.101.2 permit
|
||||
167.89.101.192/28 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.73.51 permit
|
||||
168.138.77.31 permit
|
||||
@@ -1614,6 +1673,20 @@
|
||||
168.245.12.252 permit
|
||||
168.245.46.9 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.129.0/24 permit
|
||||
170.10.132.56/29 permit
|
||||
@@ -1623,7 +1696,6 @@
|
||||
173.0.84.224/27 permit
|
||||
173.0.94.244/30 permit
|
||||
173.194.0.0/16 permit
|
||||
173.194.0.0/17 permit
|
||||
173.203.79.182 permit
|
||||
173.203.81.39 permit
|
||||
173.224.161.128/25 permit
|
||||
@@ -1647,8 +1719,7 @@
|
||||
182.50.78.64/28 permit
|
||||
183.240.219.64/29 permit
|
||||
185.4.120.0/22 permit
|
||||
185.11.253.128/27 permit
|
||||
185.11.255.0/24 permit
|
||||
185.11.255.144 permit
|
||||
185.12.80.0/22 permit
|
||||
185.28.196.0/22 permit
|
||||
185.58.84.93 permit
|
||||
@@ -1662,8 +1733,16 @@
|
||||
185.138.56.128/25 permit
|
||||
185.189.236.0/22 permit
|
||||
185.211.120.0/22 permit
|
||||
185.233.188.0/23 permit
|
||||
185.233.190.0/23 permit
|
||||
185.233.188.68 permit
|
||||
185.233.188.75 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.239.148 permit
|
||||
185.250.239.168 permit
|
||||
@@ -1722,8 +1801,24 @@
|
||||
192.18.139.154 permit
|
||||
192.18.145.36 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.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.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.161.144.0/20 permit
|
||||
192.162.87.0/24 permit
|
||||
@@ -1731,15 +1826,12 @@
|
||||
192.237.159.42 permit
|
||||
192.237.159.43 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.122.128.100 permit
|
||||
193.123.56.63 permit
|
||||
193.142.157.0/24 permit
|
||||
193.142.157.15 permit
|
||||
193.142.157.125 permit
|
||||
193.142.157.158 permit
|
||||
193.142.157.191 permit
|
||||
193.142.157.198 permit
|
||||
194.19.134.0/25 permit
|
||||
@@ -1799,7 +1891,16 @@
|
||||
199.16.156.0/22 permit
|
||||
199.33.145.1 permit
|
||||
199.33.145.32 permit
|
||||
199.34.22.36 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.162.0/25 permit
|
||||
199.122.120.0/21 permit
|
||||
@@ -1852,10 +1953,11 @@
|
||||
204.14.232.64/28 permit
|
||||
204.14.234.64/28 permit
|
||||
204.75.142.0/24 permit
|
||||
204.79.197.212 permit
|
||||
204.92.114.187 permit
|
||||
204.92.114.203 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.220.160.0/21 permit
|
||||
204.220.168.0/21 permit
|
||||
@@ -1878,23 +1980,13 @@
|
||||
206.165.246.80/29 permit
|
||||
206.191.224.0/19 permit
|
||||
206.246.157.1 permit
|
||||
207.46.4.128/25 permit
|
||||
207.46.22.35 permit
|
||||
207.46.50.72 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.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.98.192/27 permit
|
||||
207.68.176.0/26 permit
|
||||
207.68.176.96/27 permit
|
||||
207.97.204.96/29 permit
|
||||
207.126.144.0/20 permit
|
||||
207.171.160.0/19 permit
|
||||
@@ -1902,6 +1994,7 @@
|
||||
207.211.30.128/25 permit
|
||||
207.211.31.0/25 permit
|
||||
207.211.41.113 permit
|
||||
207.211.132.0/25 permit
|
||||
207.218.90.0/24 permit
|
||||
207.218.90.122 permit
|
||||
207.250.68.0/24 permit
|
||||
@@ -1909,6 +2002,8 @@
|
||||
208.43.21.28/30 permit
|
||||
208.43.21.64/29 permit
|
||||
208.43.21.72/30 permit
|
||||
208.56.9.224 permit
|
||||
208.56.13.196 permit
|
||||
208.64.132.0/22 permit
|
||||
208.71.40.63 permit
|
||||
208.71.40.64/31 permit
|
||||
@@ -1935,6 +2030,7 @@
|
||||
208.71.42.214 permit
|
||||
208.72.249.240/29 permit
|
||||
208.75.120.0/22 permit
|
||||
208.76.62.0/23 permit
|
||||
208.76.62.0/24 permit
|
||||
208.76.63.0/24 permit
|
||||
208.82.237.96/29 permit
|
||||
@@ -1993,11 +2089,19 @@
|
||||
212.82.111.228/31 permit
|
||||
212.82.111.230 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.8 permit
|
||||
212.227.15.14 permit
|
||||
212.227.15.15 permit
|
||||
212.227.15.18 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.45 permit
|
||||
212.227.15.46 permit
|
||||
@@ -2005,11 +2109,17 @@
|
||||
212.227.15.50 permit
|
||||
212.227.15.52 permit
|
||||
212.227.15.53 permit
|
||||
212.227.15.54 permit
|
||||
212.227.15.55 permit
|
||||
212.227.17.1 permit
|
||||
212.227.17.2 permit
|
||||
212.227.17.7 permit
|
||||
212.227.17.11 permit
|
||||
212.227.17.12 permit
|
||||
212.227.17.16 permit
|
||||
212.227.17.17 permit
|
||||
212.227.17.18 permit
|
||||
212.227.17.19 permit
|
||||
212.227.17.20 permit
|
||||
212.227.17.21 permit
|
||||
212.227.17.22 permit
|
||||
@@ -2029,14 +2139,10 @@
|
||||
212.227.126.225 permit
|
||||
212.227.126.226 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.145 permit
|
||||
213.199.138.181 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.251 permit
|
||||
216.24.224.0/20 permit
|
||||
@@ -2064,7 +2170,6 @@
|
||||
216.39.62.60/31 permit
|
||||
216.39.62.136/29 permit
|
||||
216.39.62.144/31 permit
|
||||
216.58.192.0/19 permit
|
||||
216.66.217.240/29 permit
|
||||
216.71.138.33 permit
|
||||
216.71.152.207 permit
|
||||
@@ -2086,6 +2191,7 @@
|
||||
216.136.168.80/28 permit
|
||||
216.139.64.0/19 permit
|
||||
216.145.221.0/24 permit
|
||||
216.146.32.0/23 permit
|
||||
216.146.32.0/24 permit
|
||||
216.146.33.0/24 permit
|
||||
216.198.0.0/18 permit
|
||||
@@ -2094,6 +2200,8 @@
|
||||
216.205.24.0/24 permit
|
||||
216.221.160.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.59 permit
|
||||
217.175.194.0/24 permit
|
||||
@@ -2104,6 +2212,7 @@
|
||||
223.165.120.0/23 permit
|
||||
2001:0868:0100:0600::/64 permit
|
||||
2001:4860:4000::/36 permit
|
||||
2001:4860:4864::/56 permit
|
||||
2001:748:100:40::2:0/112 permit
|
||||
2001:748:400:1300::3 permit
|
||||
2001:748:400:1300::4 permit
|
||||
@@ -2121,12 +2230,13 @@
|
||||
2001:748:400:3301::3 permit
|
||||
2001:748:400:3301::4 permit
|
||||
2404:6800:4000::/36 permit
|
||||
2603:1010:3:3::5b permit
|
||||
2603:1020:201:10::10f permit
|
||||
2603:1030:20e:3::23c permit
|
||||
2603:1030:b:3::152 permit
|
||||
2603:1030:c02:8::14 permit
|
||||
2404:6800:4864::/56 permit
|
||||
2603:1061:14:72::1 permit
|
||||
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
|
||||
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
|
||||
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
|
||||
2607:f8b0:4000::/36 permit
|
||||
2607:f8b0:4864::/56 permit
|
||||
2620:109:c003:104::/64 permit
|
||||
2620:109:c003:104::215 permit
|
||||
2620:109:c006:104::/64 permit
|
||||
@@ -2139,5 +2249,6 @@
|
||||
2620:119:50c0:207::/64 permit
|
||||
2620:119:50c0:207::215 permit
|
||||
2800:3f0:4000::/36 permit
|
||||
2800:3f0:4864::/56 permit
|
||||
49.12.4.251 permit # checks.mailcow.email
|
||||
2a01:4f8:c17:7906::10 permit # checks.mailcow.email
|
||||
|
||||
@@ -146,8 +146,171 @@ rspamd_config:register_symbol({
|
||||
return false
|
||||
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)
|
||||
ip_check_table = {from_ip_string}
|
||||
local ip_check_table = {}
|
||||
|
||||
-- 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 minbits = 32
|
||||
@@ -155,10 +318,18 @@ rspamd_config:register_symbol({
|
||||
maxbits = 32
|
||||
minbits = 8
|
||||
end
|
||||
|
||||
-- Add all CIDR notations with variants
|
||||
for i=maxbits,minbits,-1 do
|
||||
local nip = ip:apply_mask(i):to_string() .. "/" .. i
|
||||
table.insert(ip_check_table, nip)
|
||||
local masked_ip = ip:apply_mask(i)
|
||||
local cidr_base = masked_ip:to_string()
|
||||
|
||||
for _, variant in ipairs(get_ipv6_variants(cidr_base)) do
|
||||
local cidr = variant .. "/" .. i
|
||||
table.insert(ip_check_table, cidr)
|
||||
end
|
||||
end
|
||||
|
||||
local function keep_spam_cb(err, data)
|
||||
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)
|
||||
@@ -166,12 +337,15 @@ rspamd_config:register_symbol({
|
||||
else
|
||||
for k,v in pairs(data) do
|
||||
if (v and v ~= userdata and v == '1') then
|
||||
rspamd_logger.infox(rspamd_config, "found ip in keep_spam map, setting pre-result")
|
||||
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])
|
||||
task:set_pre_result('accept', 'ip matched with forward hosts', 'keep_spam')
|
||||
task:set_flag('no_stat')
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(ip_check_table, 1, 'KEEP_SPAM')
|
||||
local redis_ret_user = rspamd_redis_make_request(task,
|
||||
redis_params, -- connect params
|
||||
@@ -210,6 +384,7 @@ rspamd_config:register_symbol({
|
||||
rspamd_config:register_symbol({
|
||||
name = 'TAG_MOO',
|
||||
type = 'postfilter',
|
||||
flags = 'ignore_passthrough',
|
||||
callback = function(task)
|
||||
local util = require("rspamd_util")
|
||||
local rspamd_logger = require "rspamd_logger"
|
||||
@@ -217,9 +392,7 @@ rspamd_config:register_symbol({
|
||||
local rspamd_http = require "rspamd_http"
|
||||
local rcpts = task:get_recipients('smtp')
|
||||
local lua_util = require "lua_util"
|
||||
|
||||
local tagged_rcpt = task:get_symbol("TAGGED_RCPT")
|
||||
local mailcow_domain = task:get_symbol("RCPT_MAILCOW_DOMAIN")
|
||||
|
||||
local function remove_moo_tag()
|
||||
local moo_tag_header = task:get_header('X-Moo-Tag', false)
|
||||
@@ -231,101 +404,147 @@ rspamd_config:register_symbol({
|
||||
return true
|
||||
end
|
||||
|
||||
if tagged_rcpt and tagged_rcpt[1].options and mailcow_domain then
|
||||
local tag = tagged_rcpt[1].options[1]
|
||||
rspamd_logger.infox("found tag: %s", tag)
|
||||
local action = task:get_metric_action('default')
|
||||
rspamd_logger.infox("metric action now: %s", action)
|
||||
-- Check if we have exactly one recipient
|
||||
if not (rcpts and #rcpts == 1) then
|
||||
rspamd_logger.infox("TAG_MOO: not exactly one rcpt (%s), removing moo tag", rcpts and #rcpts or 0)
|
||||
remove_moo_tag()
|
||||
return
|
||||
end
|
||||
|
||||
if action ~= 'no action' and action ~= 'greylist' then
|
||||
rspamd_logger.infox("skipping tag handler for action: %s", action)
|
||||
remove_moo_tag()
|
||||
return true
|
||||
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')
|
||||
rspamd_logger.infox("TAG_MOO: metric action: %s", action)
|
||||
|
||||
-- Check if we have a pre-result (e.g., from KEEP_SPAM or POSTMASTER_HANDLER)
|
||||
local allow_processing = false
|
||||
|
||||
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
|
||||
|
||||
local function http_callback(err_message, code, body, headers)
|
||||
if body ~= nil and body ~= "" then
|
||||
rspamd_logger.infox(rspamd_config, "expanding rcpt to \"%s\"", body)
|
||||
-- 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()
|
||||
return true
|
||||
end
|
||||
|
||||
local function tag_callback_subject(err, data)
|
||||
if err or type(data) ~= 'string' then
|
||||
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)
|
||||
rspamd_logger.infox("TAG_MOO: processing allowed")
|
||||
|
||||
local function tag_callback_subfolder(err, data)
|
||||
if err or type(data) ~= 'string' then
|
||||
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()
|
||||
else
|
||||
rspamd_logger.infox("Add X-Moo-Tag header")
|
||||
task:set_milter_reply({
|
||||
add_headers = {['X-Moo-Tag'] = 'YES'}
|
||||
})
|
||||
end
|
||||
end
|
||||
local function http_callback(err_message, code, body, headers)
|
||||
if body ~= nil and body ~= "" then
|
||||
rspamd_logger.infox(rspamd_config, "TAG_MOO: expanding rcpt to \"%s\"", body)
|
||||
|
||||
local redis_ret_subfolder = rspamd_redis_make_request(task,
|
||||
redis_params, -- connect params
|
||||
body, -- hash key
|
||||
false, -- is write
|
||||
tag_callback_subfolder, --callback
|
||||
'HGET', -- command
|
||||
{'RCPT_WANTS_SUBFOLDER_TAG', body} -- arguments
|
||||
)
|
||||
if not redis_ret_subfolder then
|
||||
rspamd_logger.infox(rspamd_config, "cannot make request to load tag handler for rcpt")
|
||||
local function tag_callback_subject(err, data)
|
||||
if err or type(data) ~= 'string' or data == '' 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)
|
||||
|
||||
local function tag_callback_subfolder(err, data)
|
||||
if err or type(data) ~= 'string' or data == '' 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)
|
||||
remove_moo_tag()
|
||||
else
|
||||
rspamd_logger.infox("TAG_MOO: User wants subfolder tag, adding X-Moo-Tag header")
|
||||
task:set_milter_reply({
|
||||
add_headers = {['X-Moo-Tag'] = 'YES'}
|
||||
})
|
||||
end
|
||||
|
||||
else
|
||||
rspamd_logger.infox("user wants subject modified for tagged mail")
|
||||
local sbj = task:get_header('Subject')
|
||||
new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?='
|
||||
task:set_milter_reply({
|
||||
remove_headers = {
|
||||
['Subject'] = 1,
|
||||
['X-Moo-Tag'] = 0
|
||||
},
|
||||
add_headers = {['Subject'] = new_sbj}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local redis_ret_subject = rspamd_redis_make_request(task,
|
||||
redis_params, -- connect params
|
||||
body, -- hash key
|
||||
false, -- is write
|
||||
tag_callback_subject, --callback
|
||||
'HGET', -- command
|
||||
{'RCPT_WANTS_SUBJECT_TAG', body} -- arguments
|
||||
)
|
||||
if not redis_ret_subject then
|
||||
rspamd_logger.infox(rspamd_config, "cannot make request to load tag handler for rcpt")
|
||||
remove_moo_tag()
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
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[1] == 'postmaster' then
|
||||
rspamd_logger.infox(rspamd_config, "not expanding postmaster alias")
|
||||
local redis_ret_subfolder = rspamd_redis_make_request(task,
|
||||
redis_params, -- connect params
|
||||
body, -- hash key
|
||||
false, -- is write
|
||||
tag_callback_subfolder, --callback
|
||||
'HGET', -- command
|
||||
{'RCPT_WANTS_SUBFOLDER_TAG', body} -- arguments
|
||||
)
|
||||
if not redis_ret_subfolder then
|
||||
rspamd_logger.infox(rspamd_config, "TAG_MOO: cannot make request to load tag handler for rcpt")
|
||||
remove_moo_tag()
|
||||
else
|
||||
rspamd_http.request({
|
||||
task=task,
|
||||
url='http://nginx:8081/aliasexp.php',
|
||||
body='',
|
||||
callback=http_callback,
|
||||
headers={Rcpt=rcpt['addr']},
|
||||
})
|
||||
end
|
||||
|
||||
else
|
||||
rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail")
|
||||
local sbj = task:get_header('Subject') or ''
|
||||
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_value .. '] ' .. sbj)) .. '?='
|
||||
task:set_milter_reply({
|
||||
remove_headers = {
|
||||
['Subject'] = 1,
|
||||
['X-Moo-Tag'] = 0
|
||||
},
|
||||
add_headers = {['Subject'] = new_sbj}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local redis_ret_subject = rspamd_redis_make_request(task,
|
||||
redis_params, -- connect params
|
||||
body, -- hash key
|
||||
false, -- is write
|
||||
tag_callback_subject, --callback
|
||||
'HGET', -- command
|
||||
{'RCPT_WANTS_SUBJECT_TAG', body} -- arguments
|
||||
)
|
||||
if not redis_ret_subject then
|
||||
rspamd_logger.infox(rspamd_config, "TAG_MOO: cannot make request to load tag handler for rcpt")
|
||||
remove_moo_tag()
|
||||
end
|
||||
else
|
||||
rspamd_logger.infox("TAG_MOO: alias expansion returned empty body")
|
||||
remove_moo_tag()
|
||||
end
|
||||
end
|
||||
|
||||
local rcpt_split = rspamd_str_split(rcpt_addr, '@')
|
||||
if #rcpt_split == 2 then
|
||||
if rcpt_split[1]:match('^postmaster') then
|
||||
rspamd_logger.infox(rspamd_config, "TAG_MOO: not expanding postmaster alias")
|
||||
remove_moo_tag()
|
||||
else
|
||||
rspamd_logger.infox("TAG_MOO: requesting alias expansion for %s", rcpt_addr)
|
||||
rspamd_http.request({
|
||||
task=task,
|
||||
url='http://nginx:8081/aliasexp.php',
|
||||
body='',
|
||||
callback=http_callback,
|
||||
headers={Rcpt=rcpt_addr},
|
||||
})
|
||||
end
|
||||
else
|
||||
rspamd_logger.infox("TAG_MOO: invalid rcpt format")
|
||||
remove_moo_tag()
|
||||
end
|
||||
end,
|
||||
@@ -335,6 +554,7 @@ rspamd_config:register_symbol({
|
||||
rspamd_config:register_symbol({
|
||||
name = 'BCC',
|
||||
type = 'postfilter',
|
||||
flags = 'ignore_passthrough',
|
||||
callback = function(task)
|
||||
local util = require("rspamd_util")
|
||||
local rspamd_http = require "rspamd_http"
|
||||
@@ -363,11 +583,13 @@ rspamd_config:register_symbol({
|
||||
local email_content = tostring(task:get_content())
|
||||
email_content = string.gsub(email_content, "\r\n%.", "\r\n..")
|
||||
-- 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({
|
||||
task = task,
|
||||
host = os.getenv("IPV4_NETWORK") .. '.253',
|
||||
port = 591,
|
||||
from = task:get_from(stp)[1].addr,
|
||||
from = from_addr,
|
||||
recipients = bcc_dest,
|
||||
helo = 'bcc',
|
||||
timeout = 20,
|
||||
@@ -397,27 +619,41 @@ rspamd_config:register_symbol({
|
||||
end
|
||||
|
||||
local action = task:get_metric_action('default')
|
||||
rspamd_logger.infox("metric action now: %s", action)
|
||||
rspamd_logger.infox("BCC: metric action: %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)
|
||||
if err_message == nil and code == 201 and body ~= nil then
|
||||
if action == 'no action' or action == 'add header' or action == 'rewrite subject' then
|
||||
send_mail(task, body)
|
||||
end
|
||||
rspamd_logger.infox("BCC: sending BCC to %s for rcpt match", body)
|
||||
send_mail(task, body)
|
||||
end
|
||||
end
|
||||
|
||||
local function from_callback(err_message, code, body, headers)
|
||||
if err_message == nil and code == 201 and body ~= nil then
|
||||
if action == 'no action' or action == 'add header' or action == 'rewrite subject' then
|
||||
send_mail(task, body)
|
||||
end
|
||||
rspamd_logger.infox("BCC: sending BCC to %s for from match", body)
|
||||
send_mail(task, body)
|
||||
end
|
||||
end
|
||||
|
||||
if rcpt_table then
|
||||
for _,e in ipairs(rcpt_table) do
|
||||
rspamd_logger.infox(rspamd_config, "checking bcc for rcpt address %s", e)
|
||||
rspamd_logger.infox(rspamd_config, "BCC: checking bcc for rcpt address %s", e)
|
||||
rspamd_http.request({
|
||||
task=task,
|
||||
url='http://nginx:8081/bcc.php',
|
||||
@@ -430,7 +666,7 @@ rspamd_config:register_symbol({
|
||||
|
||||
if from_table then
|
||||
for _,e in ipairs(from_table) do
|
||||
rspamd_logger.infox(rspamd_config, "checking bcc for from address %s", e)
|
||||
rspamd_logger.infox(rspamd_config, "BCC: checking bcc for from address %s", e)
|
||||
rspamd_http.request({
|
||||
task=task,
|
||||
url='http://nginx:8081/bcc.php',
|
||||
@@ -441,7 +677,7 @@ rspamd_config:register_symbol({
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
-- Don't return true to avoid symbol being logged
|
||||
end,
|
||||
priority = 20
|
||||
})
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
|
||||
|
||||
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();
|
||||
}
|
||||
protect_route(['admin']);
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
@@ -27,46 +16,92 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
|
||||
|
||||
$js_minifier->add('/web/js/site/dashboard.js');
|
||||
|
||||
// vmail df
|
||||
$exec_fields = array('cmd' => 'system', 'task' => 'df', 'dir' => '/var/vmail');
|
||||
$vmail_df = explode(',', (string)json_decode(docker('post', 'dovecot-mailcow', 'exec', $exec_fields), true));
|
||||
$vmail_df_resp = agent('request', 'dovecot', 'exec.df', array('dir' => '/var/vmail'), 5);
|
||||
$vmail_df = (!empty($vmail_df_resp['ok']) && is_string($vmail_df_resp['result']))
|
||||
? explode(',', $vmail_df_resp['result'])
|
||||
: array('', '', '', '', '', '/var/vmail');
|
||||
|
||||
// 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();
|
||||
foreach ($containers_info as $container => $container_info) {
|
||||
if (!isset($container_info['State']) || !is_array($container_info['State']) || !isset($container_info['State']['StartedAt'])){
|
||||
continue;
|
||||
}
|
||||
date_default_timezone_set('UTC');
|
||||
$StartedAt = date_parse($container_info['State']['StartedAt']);
|
||||
if ($StartedAt['hour'] !== false) {
|
||||
$date = new \DateTime();
|
||||
$date->setTimestamp(mktime(
|
||||
$StartedAt['hour'],
|
||||
$StartedAt['minute'],
|
||||
$StartedAt['second'],
|
||||
$StartedAt['month'],
|
||||
$StartedAt['day'],
|
||||
$StartedAt['year']));
|
||||
try {
|
||||
$user_tz = new DateTimeZone(getenv('TZ'));
|
||||
$date->setTimezone($user_tz);
|
||||
$container_info['State']['StartedAtHR'] = $date->format('r');
|
||||
} catch(Exception $e) {
|
||||
$container_info['State']['StartedAtHR'] = '?';
|
||||
}
|
||||
}
|
||||
else {
|
||||
$container_info['State']['StartedAtHR'] = '?';
|
||||
}
|
||||
$containers[$container] = $container_info;
|
||||
$known_services = agent('services');
|
||||
|
||||
try {
|
||||
$tz_obj = new DateTimeZone(getenv('TZ') ?: 'UTC');
|
||||
}
|
||||
catch (Exception $e) {
|
||||
$tz_obj = new DateTimeZone('UTC');
|
||||
}
|
||||
|
||||
// get mailcow data
|
||||
$containers = array();
|
||||
foreach ($known_services as $svc) {
|
||||
$live_nodes = agent('live_nodes', $svc);
|
||||
$running = !empty($live_nodes);
|
||||
$first_node = $running ? $live_nodes[0] : '';
|
||||
$first_meta = $running ? (agent('node_meta', $svc, $first_node) ?: array()) : array();
|
||||
|
||||
$started_at_hr = '—';
|
||||
$started_at_iso = isset($first_meta['started_at']) ? $first_meta['started_at'] : '';
|
||||
if ($started_at_iso !== '') {
|
||||
try {
|
||||
$d = new DateTime($started_at_iso);
|
||||
$d->setTimezone($tz_obj);
|
||||
$started_at_hr = $d->format('r');
|
||||
}
|
||||
catch (Exception $e) {}
|
||||
}
|
||||
|
||||
$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(
|
||||
'NodeId' => $n,
|
||||
'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
|
||||
);
|
||||
}
|
||||
|
||||
$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');
|
||||
ksort($containers);
|
||||
|
||||
$hostname = getenv('MAILCOW_HOSTNAME');
|
||||
$timezone = getenv('TZ');
|
||||
|
||||
@@ -81,6 +116,7 @@ $template_data = [
|
||||
'clamd_status' => $clamd_status,
|
||||
'olefy_status' => $olefy_status,
|
||||
'containers' => $containers,
|
||||
'infra_containers' => $infra_containers,
|
||||
'ip_check' => customize('get', 'ip_check'),
|
||||
'lang_admin' => json_encode($lang['admin']),
|
||||
'lang_debug' => json_encode($lang['debug']),
|
||||
@@ -88,5 +124,3 @@ $template_data = [
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
|
||||
|
||||
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
|
||||
header('Location: /admin/dashboard');
|
||||
exit();
|
||||
// Only redirect to dashboard if NO pending actions
|
||||
if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
|
||||
header('Location: /admin/dashboard');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
|
||||
header('Location: /domainadmin/mailbox');
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
|
||||
|
||||
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();
|
||||
}
|
||||
protect_route(['admin']);
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
@@ -2,19 +2,7 @@
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
protect_route(['admin']);
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
$js_minifier->add('/web/js/site/queue.js');
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
|
||||
|
||||
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();
|
||||
}
|
||||
protect_route(['admin']);
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
@@ -2454,6 +2454,90 @@ paths:
|
||||
type: object
|
||||
type: object
|
||||
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:
|
||||
post:
|
||||
responses:
|
||||
|
||||
@@ -29,8 +29,8 @@ header('Content-Type: application/xml');
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="<?=$mailcow_hostname; ?>">
|
||||
<domain>%EMAILDOMAIN%</domain>
|
||||
<displayName>A mailcow mail server</displayName>
|
||||
<displayShortName>mail server</displayShortName>
|
||||
<displayName><?=$autodiscover_config['displayName']; ?></displayName>
|
||||
<displayShortName><?=$autodiscover_config['displayShortName']; ?></displayShortName>
|
||||
|
||||
<incomingServer type="imap">
|
||||
<hostname><?=$autodiscover_config['imap']['server']; ?></hostname>
|
||||
|
||||
+148
-85
@@ -60,101 +60,31 @@ $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
$iam_provider = identity_provider('init');
|
||||
$iam_settings = identity_provider('get');
|
||||
|
||||
$login_user = strtolower(trim($_SERVER['PHP_AUTH_USER']));
|
||||
$login_pass = trim(htmlspecialchars_decode($_SERVER['PHP_AUTH_PW']));
|
||||
// Passwordless autodiscover - no authentication required
|
||||
// Email will be extracted from the request body
|
||||
$login_user = null;
|
||||
$login_role = null;
|
||||
|
||||
if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) {
|
||||
$json = json_encode(
|
||||
array(
|
||||
"time" => time(),
|
||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||
"user" => "none",
|
||||
"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;
|
||||
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"
|
||||
)
|
||||
);
|
||||
$redis->lPush('AUTODISCOVER_LOG', $json);
|
||||
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'msg' => 'Redis: '.$e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
list($usec, $sec) = explode(' ', microtime());
|
||||
?>
|
||||
<Response>
|
||||
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="2477272013">
|
||||
<ErrorCode>600</ErrorCode>
|
||||
<Message>Invalid Request</Message>
|
||||
<DebugData />
|
||||
</Error>
|
||||
</Response>
|
||||
</Autodiscover>
|
||||
<?php
|
||||
exit(0);
|
||||
}
|
||||
try {
|
||||
$discover = new SimpleXMLElement($data);
|
||||
$email = $discover->Request->EMailAddress;
|
||||
} catch (Exception $e) {
|
||||
$email = $_SERVER['PHP_AUTH_USER'];
|
||||
}
|
||||
|
||||
$username = trim($email);
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT `name` FROM `mailbox` WHERE `username`= :username");
|
||||
$stmt->execute(array(':username' => $username));
|
||||
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
catch(PDOException $e) {
|
||||
die("Failed to determine name from SQL");
|
||||
}
|
||||
if (!empty($MailboxData['name'])) {
|
||||
$displayname = $MailboxData['name'];
|
||||
}
|
||||
else {
|
||||
$displayname = $email;
|
||||
}
|
||||
if(!$data) {
|
||||
try {
|
||||
$json = json_encode(
|
||||
array(
|
||||
"time" => time(),
|
||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||
"user" => $_SERVER['PHP_AUTH_USER'],
|
||||
"user" => "none",
|
||||
"ip" => $_SERVER['REMOTE_ADDR'],
|
||||
"service" => $autodiscover_config['autodiscoverType']
|
||||
"service" => "Error: invalid or missing request data"
|
||||
)
|
||||
);
|
||||
$redis->lPush('AUTODISCOVER_LOG', $json);
|
||||
$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) {
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -163,7 +93,143 @@ if ($login_role === "user") {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
||||
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);
|
||||
}
|
||||
try {
|
||||
$discover = new SimpleXMLElement($data);
|
||||
$email = $discover->Request->EMailAddress;
|
||||
} catch (Exception $e) {
|
||||
// If parsing fails, return error
|
||||
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);
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
|
||||
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));
|
||||
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
catch(PDOException $e) {
|
||||
// Database error - return error response with complete XML
|
||||
list($usec, $sec) = explode(' ', microtime());
|
||||
?>
|
||||
<Response>
|
||||
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
|
||||
<ErrorCode>500</ErrorCode>
|
||||
<Message>Database Error</Message>
|
||||
<DebugData />
|
||||
</Error>
|
||||
</Response>
|
||||
</Autodiscover>
|
||||
<?php
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// Mailbox not found or not active - return generic error to prevent user enumeration
|
||||
if (empty($MailboxData)) {
|
||||
try {
|
||||
$json = json_encode(
|
||||
array(
|
||||
"time" => time(),
|
||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||
"user" => $email,
|
||||
"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'],
|
||||
"service" => $autodiscover_config['autodiscoverType']
|
||||
)
|
||||
);
|
||||
$redis->lPush('AUTODISCOVER_LOG', $json);
|
||||
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'msg' => 'Redis: '.$e
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
||||
?>
|
||||
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
|
||||
<User>
|
||||
@@ -238,6 +304,3 @@ if ($login_role === "user") {
|
||||
}
|
||||
?>
|
||||
</Autodiscover>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -3,8 +3,11 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
|
||||
|
||||
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
|
||||
header('Location: /domainadmin/mailbox');
|
||||
exit();
|
||||
// Only redirect to mailbox if NO pending actions
|
||||
if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
|
||||
header('Location: /domainadmin/mailbox');
|
||||
exit();
|
||||
}
|
||||
}
|
||||
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
|
||||
header('Location: /admin/dashboard');
|
||||
|
||||
@@ -2,18 +2,7 @@
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.domainadmin.inc.php';
|
||||
|
||||
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();
|
||||
}
|
||||
protect_route(['domainadmin']);
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
@@ -2,41 +2,28 @@
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.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
|
||||
*/
|
||||
|
||||
/*
|
||||
/ DOMAIN ADMIN
|
||||
*/
|
||||
protect_route(['domainadmin']);
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
$tfa_data = get_tfa();
|
||||
$fido2_data = fido2(array("action" => "get_friendly_names"));
|
||||
$username = $_SESSION['mailcow_cc_username'];
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
$_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
$tfa_data = get_tfa();
|
||||
$fido2_data = fido2(array("action" => "get_friendly_names"));
|
||||
$username = $_SESSION['mailcow_cc_username'];
|
||||
|
||||
$template = 'domainadmin.twig';
|
||||
$template_data = [
|
||||
'acl' => $_SESSION['acl'],
|
||||
'acl_json' => json_encode($_SESSION['acl']),
|
||||
'user_spam_score' => mailbox('get', 'spam_score', $username),
|
||||
'tfa_data' => $tfa_data,
|
||||
'fido2_data' => $fido2_data,
|
||||
'lang_user' => json_encode($lang['user']),
|
||||
'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();
|
||||
}
|
||||
$template = 'domainadmin.twig';
|
||||
$template_data = [
|
||||
'acl' => $_SESSION['acl'],
|
||||
'acl_json' => json_encode($_SESSION['acl']),
|
||||
'user_spam_score' => mailbox('get', 'spam_score', $username),
|
||||
'tfa_data' => $tfa_data,
|
||||
'fido2_data' => $fido2_data,
|
||||
'lang_user' => json_encode($lang['user']),
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
];
|
||||
|
||||
$js_minifier->add('/web/js/site/user.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
|
||||
+3
-5
@@ -1,10 +1,8 @@
|
||||
<?php
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
$AuthUsers = array("admin", "domainadmin", "user");
|
||||
if (!isset($_SESSION['mailcow_cc_role']) OR !in_array($_SESSION['mailcow_cc_role'], $AuthUsers)) {
|
||||
header('Location: /');
|
||||
exit();
|
||||
}
|
||||
|
||||
protect_route();
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
|
||||
|
||||
$template = 'edit.twig';
|
||||
|
||||
@@ -1,59 +1,35 @@
|
||||
<?php
|
||||
|
||||
// Block requests by checking the 'Sec-Fetch-Dest' header.
|
||||
if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] !== 'empty') {
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'admin') {
|
||||
exit();
|
||||
}
|
||||
|
||||
if (preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'])) {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
<?php
|
||||
if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] !== 'empty') {
|
||||
header('HTTP/1.1 403 Forbidden');
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'admin') {
|
||||
exit();
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'] ?? '')) {
|
||||
exit();
|
||||
}
|
||||
|
||||
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>';
|
||||
}
|
||||
|
||||
@@ -129,7 +129,16 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
|
||||
);
|
||||
}
|
||||
|
||||
$mta_sts = mailbox('get', 'mta_sts', $domain);
|
||||
// Check if domain is an alias domain and get target domain's MTA-STS
|
||||
$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 (!in_array($domain, $alias_domains)) {
|
||||
$records[] = array(
|
||||
|
||||
@@ -64,6 +64,8 @@ $globalVariables = [
|
||||
'pending_tfa_methods' => @$_SESSION['pending_tfa_methods'],
|
||||
'pending_tfa_authmechs' => $pending_tfa_authmechs,
|
||||
'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_acl' => json_encode($lang['acl']),
|
||||
'lang_tfa' => json_encode($lang['tfa']),
|
||||
|
||||
@@ -121,34 +121,56 @@ function admin($_action, $_data = null) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!empty($password)) {
|
||||
if (password_check($password, $password2) !== true) {
|
||||
return false;
|
||||
// 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']);
|
||||
}
|
||||
$password_hashed = hash_password($password);
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':password_hashed' => $password_hashed,
|
||||
':username_new' => $username_new,
|
||||
':username' => $username,
|
||||
':active' => $active
|
||||
));
|
||||
if (isset($_data['disable_tfa'])) {
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
|
||||
$stmt->execute(array(':username' => $username));
|
||||
} 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 (password_check($password, $password2) !== true) {
|
||||
return false;
|
||||
}
|
||||
$password_hashed = hash_password($password);
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed,
|
||||
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
|
||||
WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':password_hashed' => $password_hashed,
|
||||
':username_new' => $username_new,
|
||||
':username' => $username,
|
||||
':active' => $active,
|
||||
':force_tfa' => strval($force_tfa),
|
||||
':force_pw_update' => strval($force_pw_update)
|
||||
));
|
||||
}
|
||||
else {
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `username` = :username_new WHERE `username` = :username");
|
||||
$stmt->execute(array(':username_new' => $username_new, ':username' => $username));
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active,
|
||||
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
|
||||
WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':username_new' => $username_new,
|
||||
':username' => $username,
|
||||
':active' => $active,
|
||||
':force_tfa' => strval($force_tfa),
|
||||
':force_pw_update' => strval($force_pw_update)
|
||||
));
|
||||
}
|
||||
}
|
||||
else {
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':username_new' => $username_new,
|
||||
':username' => $username,
|
||||
':active' => $active
|
||||
));
|
||||
if (isset($_data['disable_tfa'])) {
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `active` = '0' WHERE `username` = :username");
|
||||
$stmt->execute(array(':username' => $username));
|
||||
@@ -223,7 +245,8 @@ function admin($_action, $_data = null) {
|
||||
`tfa`.`active` AS `tfa_active`,
|
||||
`admin`.`username`,
|
||||
`admin`.`created`,
|
||||
`admin`.`active` AS `active`
|
||||
`admin`.`active` AS `active`,
|
||||
`admin`.`attributes` AS `attributes`
|
||||
FROM `admin`
|
||||
LEFT OUTER JOIN `tfa` ON `tfa`.`username`=`admin`.`username`
|
||||
WHERE `admin`.`username`= :admin AND `superadmin` = '1'");
|
||||
@@ -240,6 +263,7 @@ function admin($_action, $_data = null) {
|
||||
$admindata['active'] = $row['active'];
|
||||
$admindata['active_int'] = $row['active'];
|
||||
$admindata['created'] = $row['created'];
|
||||
$admindata['attributes'] = json_decode($row['attributes'], true) ?? array('force_tfa' => '0', 'force_pw_update' => '0');
|
||||
return $admindata;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
<?php
|
||||
function check_login($user, $pass, $app_passwd_data = false, $extra = null) {
|
||||
function check_login($user, $pass, $extra = null) {
|
||||
global $pdo;
|
||||
global $redis;
|
||||
|
||||
$is_internal = $extra['is_internal'];
|
||||
$role = $extra['role'];
|
||||
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
|
||||
|
||||
// Try validate admin
|
||||
if (!isset($role) || $role == "admin") {
|
||||
@@ -25,34 +26,20 @@ function check_login($user, $pass, $app_passwd_data = false, $extra = null) {
|
||||
|
||||
// Try validate app password
|
||||
if (!isset($role) || $role == "app") {
|
||||
$result = apppass_login($user, $pass, $app_passwd_data);
|
||||
$result = apppass_login($user, $pass, $extra);
|
||||
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']);
|
||||
set_sasl_log($user, $real_rip, $service, $pass);
|
||||
set_sasl_log($user, $real_rip, $extra['service'], $pass);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
// Try validate user
|
||||
if (!isset($role) || $role == "user") {
|
||||
$result = user_login($user, $pass);
|
||||
$result = user_login($user, $pass, $extra);
|
||||
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']);
|
||||
set_sasl_log($user, $real_rip, $service);
|
||||
set_sasl_log($user, $real_rip, $extra['service']);
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -95,7 +82,7 @@ function admin_login($user, $pass){
|
||||
}
|
||||
|
||||
$user = strtolower(trim($user));
|
||||
$stmt = $pdo->prepare("SELECT `password` FROM `admin`
|
||||
$stmt = $pdo->prepare("SELECT `password`, `attributes` FROM `admin`
|
||||
WHERE `superadmin` = '1'
|
||||
AND `active` = '1'
|
||||
AND `username` = :user");
|
||||
@@ -104,6 +91,13 @@ function admin_login($user, $pass){
|
||||
|
||||
// verify password
|
||||
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
|
||||
$authenticators = get_tfa($user);
|
||||
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
|
||||
@@ -123,6 +117,10 @@ function admin_login($user, $pass){
|
||||
// Reactivate TFA if it was set to "deactivate TFA for next login"
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :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(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $user, '*'),
|
||||
@@ -148,7 +146,7 @@ function domainadmin_login($user, $pass){
|
||||
return false;
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare("SELECT `password` FROM `admin`
|
||||
$stmt = $pdo->prepare("SELECT `password`, `attributes` FROM `admin`
|
||||
WHERE `superadmin` = '0'
|
||||
AND `active`='1'
|
||||
AND `username` = :user");
|
||||
@@ -157,6 +155,13 @@ function domainadmin_login($user, $pass){
|
||||
|
||||
// verify password
|
||||
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
|
||||
$authenticators = get_tfa($user);
|
||||
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0) {
|
||||
@@ -176,6 +181,10 @@ function domainadmin_login($user, $pass){
|
||||
// Reactivate TFA if it was set to "deactivate TFA for next login"
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :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(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $user, '*'),
|
||||
@@ -193,7 +202,7 @@ function user_login($user, $pass, $extra = null){
|
||||
global $iam_settings;
|
||||
|
||||
$is_internal = $extra['is_internal'];
|
||||
$service = $extra['service'];
|
||||
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
|
||||
|
||||
if (!filter_var($user, FILTER_VALIDATE_EMAIL) && !ctype_alnum(str_replace(array('_', '.', '-'), '', $user))) {
|
||||
if (!$is_internal){
|
||||
@@ -236,10 +245,10 @@ function user_login($user, $pass, $extra = null){
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!empty($row)) {
|
||||
// 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, dav, eas) if service is set
|
||||
$row['attributes'] = json_decode($row['attributes'], true);
|
||||
if (isset($service)) {
|
||||
$key = strtolower($service) . "_access";
|
||||
if ($extra['service'] != 'NONE') {
|
||||
$key = strtolower($extra['service']) . "_access";
|
||||
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
|
||||
return false;
|
||||
}
|
||||
@@ -253,8 +262,8 @@ function user_login($user, $pass, $extra = null){
|
||||
|
||||
// check if user has access to service (imap, smtp, pop3, sieve) if service is set
|
||||
$row['attributes'] = json_decode($row['attributes'], true);
|
||||
if (isset($service)) {
|
||||
$key = strtolower($service) . "_access";
|
||||
if ($extra['service'] != 'NONE') {
|
||||
$key = strtolower($extra['service']) . "_access";
|
||||
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
|
||||
return false;
|
||||
}
|
||||
@@ -278,6 +287,8 @@ function user_login($user, $pass, $extra = null){
|
||||
return false;
|
||||
}
|
||||
|
||||
$row['attributes'] = json_decode($row['attributes'], true);
|
||||
|
||||
// check for tfa authenticators
|
||||
$authenticators = get_tfa($user);
|
||||
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
|
||||
@@ -299,6 +310,10 @@ function user_login($user, $pass, $extra = null){
|
||||
// Reactivate TFA if it was set to "deactivate TFA for next login"
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :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(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $user, '*', 'Provider: Keycloak'),
|
||||
@@ -330,6 +345,8 @@ function user_login($user, $pass, $extra = null){
|
||||
return false;
|
||||
}
|
||||
|
||||
$row['attributes'] = json_decode($row['attributes'], true);
|
||||
|
||||
// check for tfa authenticators
|
||||
$authenticators = get_tfa($user);
|
||||
if (isset($authenticators['additional']) && is_array($authenticators['additional']) && count($authenticators['additional']) > 0 && !$is_internal) {
|
||||
@@ -351,6 +368,10 @@ function user_login($user, $pass, $extra = null){
|
||||
// Reactivate TFA if it was set to "deactivate TFA for next login"
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :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(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $user, '*', 'Provider: LDAP'),
|
||||
@@ -394,6 +415,10 @@ function user_login($user, $pass, $extra = null){
|
||||
// Reactivate TFA if it was set to "deactivate TFA for next login"
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `active`='1' WHERE `username` = :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(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $user, '*', 'Provider: mailcow'),
|
||||
@@ -408,7 +433,7 @@ function user_login($user, $pass, $extra = null){
|
||||
|
||||
return false;
|
||||
}
|
||||
function apppass_login($user, $pass, $app_passwd_data, $extra = null){
|
||||
function apppass_login($user, $pass, $extra = null){
|
||||
global $pdo;
|
||||
|
||||
$is_internal = $extra['is_internal'];
|
||||
@@ -424,20 +449,8 @@ function apppass_login($user, $pass, $app_passwd_data, $extra = null){
|
||||
return false;
|
||||
}
|
||||
|
||||
$protocol = false;
|
||||
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) {
|
||||
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
|
||||
if (!$is_internal && $extra['service'] == 'NONE') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -458,7 +471,7 @@ function apppass_login($user, $pass, $app_passwd_data, $extra = null){
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($protocol && $row[$protocol . '_access'] != '1'){
|
||||
if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -195,17 +195,23 @@ 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 (password_check($password, $password2) !== true) {
|
||||
return false;
|
||||
}
|
||||
$password_hashed = hash_password($password);
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed WHERE `username` = :username");
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active, `password` = :password_hashed,
|
||||
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
|
||||
WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':password_hashed' => $password_hashed,
|
||||
':username_new' => $username_new,
|
||||
':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");
|
||||
@@ -217,11 +223,15 @@ function domain_admin($_action, $_data = null) {
|
||||
}
|
||||
}
|
||||
else {
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active WHERE `username` = :username");
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `username` = :username_new, `active` = :active,
|
||||
`attributes` = JSON_SET(COALESCE(`attributes`, '{}'), '$.force_tfa', :force_tfa, '$.force_pw_update', :force_pw_update)
|
||||
WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':username_new' => $username_new,
|
||||
':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");
|
||||
@@ -244,31 +254,37 @@ function domain_admin($_action, $_data = null) {
|
||||
// Can only edit itself
|
||||
elseif ($_SESSION['mailcow_cc_role'] == "domainadmin") {
|
||||
$username = $_SESSION['mailcow_cc_username'];
|
||||
$password_old = $_data['user_old_pass'];
|
||||
$password_old = $_data['user_old_pass'] ?? '';
|
||||
$password_new = $_data['user_new_pass'];
|
||||
$password_new2 = $_data['user_new_pass2'];
|
||||
|
||||
$stmt = $pdo->prepare("SELECT `password` FROM `admin`
|
||||
WHERE `username` = :user");
|
||||
$stmt->execute(array(':user' => $username));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!verify_hash($row['password'], $password_old)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
// 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`
|
||||
WHERE `username` = :user");
|
||||
$stmt->execute(array(':user' => $username));
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!verify_hash($row['password'], $password_old)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (password_check($password_new, $password_new2) !== true) {
|
||||
return false;
|
||||
}
|
||||
$password_hashed = hash_password($password_new);
|
||||
$stmt = $pdo->prepare("UPDATE `admin` SET `password` = :password_hashed WHERE `username` = :username");
|
||||
$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']);
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
@@ -360,9 +376,11 @@ function domain_admin($_action, $_data = null) {
|
||||
`tfa`.`active` AS `tfa_active`,
|
||||
`domain_admins`.`username`,
|
||||
`domain_admins`.`created`,
|
||||
`domain_admins`.`active` AS `active`
|
||||
`domain_admins`.`active` AS `active`,
|
||||
`admin`.`attributes` AS `attributes`
|
||||
FROM `domain_admins`
|
||||
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");
|
||||
$stmt->execute(array(
|
||||
':domain_admin' => $_data
|
||||
@@ -377,6 +395,7 @@ function domain_admin($_action, $_data = null) {
|
||||
$domainadmindata['active'] = $row['active'];
|
||||
$domainadmindata['active_int'] = $row['active'];
|
||||
$domainadmindata['created'] = $row['created'];
|
||||
$domainadmindata['attributes'] = json_decode($row['attributes'], true) ?? array('force_tfa' => '0', 'force_pw_update' => '0');
|
||||
// GET SELECTED
|
||||
$stmt = $pdo->prepare("SELECT `domain` FROM `domain`
|
||||
WHERE `domain` IN (
|
||||
|
||||
@@ -109,7 +109,7 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
return false;
|
||||
}
|
||||
// Rules will also be recreated on log events, but rules may seem empty for a second in the UI
|
||||
docker('post', 'netfilter-mailcow', 'restart');
|
||||
agent('request', 'netfilter', 'restart', array(), 30);
|
||||
$fail_count = 0;
|
||||
$regex_result = json_decode($redis->Get('F2B_REGEX'), true);
|
||||
while (empty($regex_result) && $fail_count < 10) {
|
||||
@@ -206,7 +206,7 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
||||
try {
|
||||
$redis->hSet('F2B_BLACKLIST', $network, 1);
|
||||
$redis->hDel('F2B_WHITELIST', $network, 1);
|
||||
//$response = docker('post', 'netfilter-mailcow', 'restart');
|
||||
// netfilter picks up the redis changes
|
||||
}
|
||||
catch (RedisException $e) {
|
||||
$_SESSION['return'][] = array(
|
||||
|
||||
@@ -108,6 +108,14 @@ function fwdhost($_action, $_data = null) {
|
||||
}
|
||||
break;
|
||||
case 'delete':
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$hosts = (array)$_data['forwardinghost'];
|
||||
foreach ($hosts as $host) {
|
||||
try {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user