mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-06-19 04:50:40 +00:00
Compare commits
52 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 | |||
| 4e33c7143f |
@@ -19,7 +19,7 @@ jobs:
|
|||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ adapt_new_options() {
|
|||||||
|
|
||||||
sed -i --follow-symlinks '$a\' mailcow.conf
|
sed -i --follow-symlinks '$a\' mailcow.conf
|
||||||
for option in ${CONFIG_ARRAY[@]}; do
|
for option in ${CONFIG_ARRAY[@]}; do
|
||||||
if grep -q "${option}" mailcow.conf; then
|
if grep -q "^#\?${option}=" mailcow.conf; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ adapt_new_options() {
|
|||||||
;;
|
;;
|
||||||
ACME_DNS_PROVIDER)
|
ACME_DNS_PROVIDER)
|
||||||
echo '# DNS provider for DNS-01 challenge (e.g. dns_cf, dns_azure, dns_gd, etc.)' >> mailcow.conf
|
echo '# DNS provider for DNS-01 challenge (e.g. dns_cf, dns_azure, dns_gd, etc.)' >> mailcow.conf
|
||||||
echo '# See the dns-101 provider documentation for more information.' >> mailcow.conf
|
echo '# See the dns-01 provider documentation for more information.' >> mailcow.conf
|
||||||
echo 'ACME_DNS_PROVIDER=dns_xxx' >> mailcow.conf
|
echo 'ACME_DNS_PROVIDER=dns_xxx' >> mailcow.conf
|
||||||
;;
|
;;
|
||||||
ACME_ACCOUNT_EMAIL)
|
ACME_ACCOUNT_EMAIL)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||||
|
|
||||||
|
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
@@ -35,4 +39,14 @@ COPY expand6.sh /srv/expand6.sh
|
|||||||
|
|
||||||
RUN chmod +x /srv/*.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 []
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
|||||||
exec $(readlink -f "$0")
|
exec $(readlink -f "$0")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_f "Waiting for Docker API..."
|
log_f "Waiting for Redis control bus..."
|
||||||
until ping dockerapi -c1 > /dev/null; do
|
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
|
sleep 1
|
||||||
done
|
done
|
||||||
log_f "Docker API OK"
|
log_f "Redis control bus OK"
|
||||||
|
|
||||||
log_f "Waiting for Postfix..."
|
log_f "Waiting for Postfix..."
|
||||||
until ping postfix -c1 > /dev/null; do
|
until ping postfix -c1 > /dev/null; do
|
||||||
@@ -253,10 +253,20 @@ while true; do
|
|||||||
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||||
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||||
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
|
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
|
||||||
if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then
|
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
|
||||||
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
|
|
||||||
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}")
|
# Skip if subdomain matches MAILCOW_HOSTNAME
|
||||||
fi
|
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
|
fi
|
||||||
done
|
done
|
||||||
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
|
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
|
||||||
@@ -273,7 +283,10 @@ while true; do
|
|||||||
fi
|
fi
|
||||||
# Only add mta-sts subdomain for alias domains
|
# Only add mta-sts subdomain for alias domains
|
||||||
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
|
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||||
if check_domain "mta-sts.${alias_domain}"; 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}")
|
VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -308,13 +321,31 @@ while true; do
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Check if MAILCOW_HOSTNAME is covered by a wildcard in ADDITIONAL_SAN
|
||||||
|
MAILCOW_HOSTNAME_COVERED=0
|
||||||
|
if [[ ! -z ${VALIDATED_MAILCOW_HOSTNAME} ]]; then
|
||||||
|
if is_covered_by_wildcard "${VALIDATED_MAILCOW_HOSTNAME}"; then
|
||||||
|
MAILCOW_PARENT_DOMAIN=$(echo ${VALIDATED_MAILCOW_HOSTNAME} | cut -d. -f2-)
|
||||||
|
log_f "MAILCOW_HOSTNAME '${VALIDATED_MAILCOW_HOSTNAME}' is covered by wildcard '*.${MAILCOW_PARENT_DOMAIN}' - skipping explicit hostname"
|
||||||
|
MAILCOW_HOSTNAME_COVERED=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Unique domains for server certificate
|
# Unique domains for server certificate
|
||||||
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
|
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
|
||||||
# create certificate for server name and fqdn SANs only
|
# create certificate for server name and fqdn SANs only
|
||||||
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
|
else
|
||||||
# create certificate for all domains, including all subdomains from other domains [*]
|
# 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
|
fi
|
||||||
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
|
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
|
||||||
CERT_NAME=${SERVER_SAN_VALIDATED[0]}
|
CERT_NAME=${SERVER_SAN_VALIDATED[0]}
|
||||||
|
|||||||
@@ -135,3 +135,32 @@ verify_challenge_path(){
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check if a domain is covered by a wildcard (*.example.com) in ADDITIONAL_SAN
|
||||||
|
# Usage: is_covered_by_wildcard "subdomain.example.com"
|
||||||
|
# Returns: 0 if covered, 1 if not covered
|
||||||
|
# Note: Only returns 0 (covered) when DNS-01 challenge is enabled,
|
||||||
|
# as wildcards cannot be validated with HTTP-01 challenge
|
||||||
|
is_covered_by_wildcard() {
|
||||||
|
local DOMAIN=$1
|
||||||
|
|
||||||
|
# Only skip if DNS challenge is enabled (wildcards require DNS-01)
|
||||||
|
if [[ ${ACME_DNS_CHALLENGE} != "y" ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Return early if no ADDITIONAL_SAN is set
|
||||||
|
if [[ -z ${ADDITIONAL_SAN} ]]; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Extract parent domain (e.g., mail.example.com -> example.com)
|
||||||
|
local PARENT_DOMAIN=$(echo ${DOMAIN} | cut -d. -f2-)
|
||||||
|
|
||||||
|
# Check if ADDITIONAL_SAN contains a wildcard for this parent domain
|
||||||
|
if [[ "${ADDITIONAL_SAN}" == *"*.${PARENT_DOMAIN}"* ]]; then
|
||||||
|
return 0 # Covered by wildcard
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1 # Not covered
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ else
|
|||||||
__dns_loader_standalone=0
|
__dns_loader_standalone=0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
CONFIG_PATH="${ACME_DNS_CONFIG_FILE:-/etc/acme/dns-101.conf}"
|
CONFIG_PATH="${ACME_DNS_CONFIG_FILE:-/etc/acme/dns-01.conf}"
|
||||||
|
|
||||||
if [[ ! -f "${CONFIG_PATH}" ]]; then
|
if [[ ! -f "${CONFIG_PATH}" ]]; then
|
||||||
if [[ $__dns_loader_standalone -eq 1 ]]; then
|
if [[ $__dns_loader_standalone -eq 1 ]]; then
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ CERT_DOMAINS=(${DOMAINS[@]})
|
|||||||
CERT_DOMAIN=${CERT_DOMAINS[0]}
|
CERT_DOMAIN=${CERT_DOMAINS[0]}
|
||||||
ACME_BASE=/var/lib/acme
|
ACME_BASE=/var/lib/acme
|
||||||
|
|
||||||
# Load optional DNS provider secrets from /etc/acme/dns-101.conf
|
# Load optional DNS provider secrets from /etc/acme/dns-01.conf
|
||||||
if [[ -f /srv/load-dns-config.sh ]]; then
|
if [[ -f /srv/load-dns-config.sh ]]; then
|
||||||
source /srv/load-dns-config.sh
|
source /srv/load-dns-config.sh
|
||||||
if declare -F log_f >/dev/null; then
|
if declare -F log_f >/dev/null; then
|
||||||
|
|||||||
@@ -1,45 +1,29 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Tell every live replica of nginx / dovecot / postfix to reload (or restart
|
||||||
|
# on cert-amount change) via the mailcow-agent control bus. Replaces the old
|
||||||
|
# dockerapi-based container_id lookup + exec dance.
|
||||||
|
|
||||||
# Reading container IDs
|
reload_service() {
|
||||||
# Wrapping as array to ensure trimmed content when calling $NGINX etc.
|
local svc="$1"
|
||||||
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" " "))
|
echo "Reloading ${svc} via mailcow-agent..."
|
||||||
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" " "))
|
if ! mailcow-agent-cli send "${svc}" reload >/dev/null; 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}" | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
|
echo "Could not publish reload to ${svc}, attempting restart..."
|
||||||
|
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
||||||
reload_nginx(){
|
fi
|
||||||
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_dovecot(){
|
restart_service() {
|
||||||
echo "Reloading Dovecot..."
|
local svc="$1"
|
||||||
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)
|
echo "Restarting ${svc} via mailcow-agent..."
|
||||||
[[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; }
|
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
||||||
}
|
|
||||||
|
|
||||||
reload_postfix(){
|
|
||||||
echo "Reloading Postfix..."
|
|
||||||
POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/exec -d '{"cmd":"reload", "task":"postfix"}' --silent -H 'Content-type: application/json' | jq -r .type)
|
|
||||||
[[ ${POSTFIX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Postfix, restarting container..."; restart_container ${POSTFIX} ; }
|
|
||||||
}
|
|
||||||
|
|
||||||
restart_container(){
|
|
||||||
for container in $*; do
|
|
||||||
echo "Restarting ${container}..."
|
|
||||||
C_REST_OUT=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${container}/restart --silent | jq -r '.msg')
|
|
||||||
echo "${C_REST_OUT}"
|
|
||||||
done
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
|
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
|
||||||
restart_container ${NGINX}
|
restart_service nginx
|
||||||
restart_container ${DOVECOT}
|
restart_service dovecot
|
||||||
restart_container ${POSTFIX}
|
restart_service postfix
|
||||||
else
|
else
|
||||||
reload_nginx
|
reload_service nginx
|
||||||
#reload_dovecot
|
restart_service dovecot
|
||||||
restart_container ${DOVECOT}
|
restart_service postfix
|
||||||
#reload_postfix
|
|
||||||
restart_container ${POSTFIX}
|
|
||||||
fi
|
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
|
FROM alpine:3.21 AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -104,7 +108,15 @@ COPY healthcheck.sh /healthcheck.sh
|
|||||||
COPY clamdcheck.sh /usr/local/bin
|
COPY clamdcheck.sh /usr/local/bin
|
||||||
RUN chmod +x /healthcheck.sh
|
RUN chmod +x /healthcheck.sh
|
||||||
RUN chmod +x /usr/local/bin/clamdcheck.sh
|
RUN chmod +x /usr/local/bin/clamdcheck.sh
|
||||||
HEALTHCHECK --start-period=6m CMD "/healthcheck.sh"
|
|
||||||
|
|
||||||
ENTRYPOINT []
|
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||||
CMD ["/sbin/tini", "-g", "--", "/clamd.sh"]
|
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.23
|
|
||||||
|
|
||||||
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(all: bool = False):
|
|
||||||
global dockerapi
|
|
||||||
|
|
||||||
containers = {}
|
|
||||||
try:
|
|
||||||
for container in (await dockerapi.async_docker_client.containers.list(all=all)):
|
|
||||||
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,4 +1,8 @@
|
|||||||
FROM alpine:3.22
|
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>"
|
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
@@ -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 repl_health.sh /usr/local/bin/repl_health.sh
|
||||||
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
|
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
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 []
|
||||||
|
|||||||
@@ -44,109 +44,90 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
|||||||
else
|
else
|
||||||
QUOTA_TABLE=quota2replica
|
QUOTA_TABLE=quota2replica
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat <<EOF > /etc/dovecot/conf.d/12-mysql.conf
|
|
||||||
# Autogenerated by mailcow - DO NOT TOUCH!
|
|
||||||
mysql /var/run/mysqld/mysqld.sock {
|
|
||||||
dbname=${DBNAME}
|
|
||||||
user=${DBUSER}
|
|
||||||
password=${DBPASS}
|
|
||||||
|
|
||||||
ssl = no
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
|
|
||||||
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-quota.conf
|
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-quota.conf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
dict_map priv/quota/storage {
|
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
|
||||||
sql_table = ${QUOTA_TABLE}
|
map {
|
||||||
|
pattern = priv/quota/storage
|
||||||
|
table = ${QUOTA_TABLE}
|
||||||
username_field = username
|
username_field = username
|
||||||
value_field bytes {
|
value_field = bytes
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
map {
|
||||||
dict_map priv/quota/messages {
|
pattern = priv/quota/messages
|
||||||
sql_table = ${QUOTA_TABLE}
|
table = ${QUOTA_TABLE}
|
||||||
username_field = username
|
username_field = username
|
||||||
value_field messages {
|
value_field = messages
|
||||||
}
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Create dict used for sieve pre and postfilters
|
# Create dict used for sieve pre and postfilters
|
||||||
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf
|
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
|
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
|
||||||
dict_map priv/sieve/name/\$script_name {
|
map {
|
||||||
sql_table = sieve_before
|
pattern = priv/sieve/name/\$script_name
|
||||||
|
table = sieve_before
|
||||||
username_field = username
|
username_field = username
|
||||||
value_field id {
|
value_field = id
|
||||||
}
|
fields {
|
||||||
|
script_name = \$script_name
|
||||||
# The script name field in the table to query
|
|
||||||
key_field script_name {
|
|
||||||
value = \$script_name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
map {
|
||||||
dict_map priv/sieve/data/\$id {
|
pattern = priv/sieve/data/\$id
|
||||||
sql_table = sieve_before
|
table = sieve_before
|
||||||
username_field = username
|
username_field = username
|
||||||
value_field script_data {
|
value_field = script_data
|
||||||
}
|
fields {
|
||||||
key_field id {
|
id = \$id
|
||||||
value = \$id
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf
|
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
|
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
|
||||||
dict_map priv/sieve/name/\$script_name {
|
map {
|
||||||
sql_table = sieve_after
|
pattern = priv/sieve/name/\$script_name
|
||||||
|
table = sieve_after
|
||||||
username_field = username
|
username_field = username
|
||||||
value_field id {
|
value_field = id
|
||||||
}
|
fields {
|
||||||
key_field script_name {
|
script_name = \$script_name
|
||||||
value = \$script_name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
map {
|
||||||
dict_map priv/sieve/data/\$id {
|
pattern = priv/sieve/data/\$id
|
||||||
sql_table = sieve_after
|
table = sieve_after
|
||||||
username_field = username
|
username_field = username
|
||||||
value_field script_data {
|
value_field = script_data
|
||||||
}
|
fields {
|
||||||
key_field id {
|
id = \$id
|
||||||
value = \$id
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
if [[ "${ACL_ANYONE}" == "allow" ]]; then
|
echo -n ${ACL_ANYONE} > /etc/dovecot/acl_anyone
|
||||||
echo -n "yes" > /etc/dovecot/acl_anyone
|
|
||||||
else
|
|
||||||
echo -n "no" > /etc/dovecot/acl_anyone
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${SKIP_FTS}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
if [[ "${SKIP_FTS}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||||
echo -e "\e[33mDetecting SKIP_FTS=y... not enabling Flatcurve (FTS) then...\e[0m"
|
echo -e "\e[33mDetecting SKIP_FTS=y... not enabling Flatcurve (FTS) then...\e[0m"
|
||||||
echo -n 'quota quota_clone acl mail_crypt mail_crypt_acl mail_log mail_compress notify lazy_expunge' > /etc/dovecot/mail_plugins
|
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify listescape replication lazy_expunge' > /etc/dovecot/mail_plugins
|
||||||
echo -n 'quota quota_clone imap_quota imap_acl acl imap_sieve mail_crypt mail_crypt_acl mail_compress notify mail_log' > /etc/dovecot/mail_plugins_imap
|
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify listescape replication mail_log' > /etc/dovecot/mail_plugins_imap
|
||||||
echo -n 'quota quota_clone sieve acl mail_crypt mail_crypt_acl mail_compress notify' > /etc/dovecot/mail_plugins_lmtp
|
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
|
||||||
else
|
else
|
||||||
echo -e "\e[32mDetecting SKIP_FTS=n... enabling Flatcurve (FTS)\e[0m"
|
echo -e "\e[32mDetecting SKIP_FTS=n... enabling Flatcurve (FTS)\e[0m"
|
||||||
echo -n 'quota quota_clone acl mail_crypt mail_crypt_acl mail_log mail_compress notify fts fts_flatcurve lazy_expunge' > /etc/dovecot/mail_plugins
|
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify fts fts_flatcurve listescape replication lazy_expunge' > /etc/dovecot/mail_plugins
|
||||||
echo -n 'quota quota_clone imap_quota imap_acl acl imap_sieve mail_crypt mail_crypt_acl mail_compress notify mail_log fts fts_flatcurve' > /etc/dovecot/mail_plugins_imap
|
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify mail_log fts fts_flatcurve listescape replication' > /etc/dovecot/mail_plugins_imap
|
||||||
echo -n 'quota quota_clone sieve acl mail_crypt mail_crypt_acl mail_compress fts fts_flatcurve notify' > /etc/dovecot/mail_plugins_lmtp
|
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl fts fts_flatcurve notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
|
||||||
fi
|
fi
|
||||||
chmod 644 /etc/dovecot/mail_plugins /etc/dovecot/mail_plugins_imap /etc/dovecot/mail_plugins_lmtp /templates/quarantine.tpl
|
chmod 644 /etc/dovecot/mail_plugins /etc/dovecot/mail_plugins_imap /etc/dovecot/mail_plugins_lmtp /templates/quarantine.tpl
|
||||||
|
|
||||||
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
|
cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%{user | domain }}/%{user | username }/Maildir:VOLATILEDIR=/var/volatile/%{user}:INDEX=/var/vmail_index/%{user}') AS mail, '%{protocol}' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule FROM mailbox WHERE username = '%{user}' AND (active = '1' OR active = '2')
|
driver = mysql
|
||||||
|
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
|
||||||
|
user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%d/%n/${MAILDIR_SUB}:VOLATILEDIR=/var/volatile/%u:INDEX=/var/vmail_index/%u') AS mail, '%s' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule FROM mailbox WHERE username = '%u' AND (active = '1' OR active = '2')
|
||||||
iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2';
|
iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2';
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -177,8 +158,8 @@ for cert_dir in /etc/ssl/mail/*/ ; do
|
|||||||
domains=($(cat ${cert_dir}domains))
|
domains=($(cat ${cert_dir}domains))
|
||||||
for domain in ${domains[@]}; do
|
for domain in ${domains[@]}; do
|
||||||
echo 'local_name '${domain}' {' >> /etc/dovecot/sni.conf;
|
echo 'local_name '${domain}' {' >> /etc/dovecot/sni.conf;
|
||||||
echo ' ssl_server_cert_file = '${cert_dir}'cert.pem' >> /etc/dovecot/sni.conf;
|
echo ' ssl_cert = <'${cert_dir}'cert.pem' >> /etc/dovecot/sni.conf;
|
||||||
echo ' ssl_server_key_file = '${cert_dir}'key.pem' >> /etc/dovecot/sni.conf;
|
echo ' ssl_key = <'${cert_dir}'key.pem' >> /etc/dovecot/sni.conf;
|
||||||
echo '}' >> /etc/dovecot/sni.conf;
|
echo '}' >> /etc/dovecot/sni.conf;
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
@@ -202,13 +183,11 @@ else
|
|||||||
fi
|
fi
|
||||||
cat <<EOF > /etc/dovecot/shared_namespace.conf
|
cat <<EOF > /etc/dovecot/shared_namespace.conf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
namespace shared {
|
namespace {
|
||||||
type = shared
|
type = shared
|
||||||
separator = /
|
separator = /
|
||||||
prefix = Shared/\$user/
|
prefix = Shared/%%u/
|
||||||
mail_driver = maildir
|
location = maildir:%%h${MAILDIR_SUB_SHARED}:INDEX=~${MAILDIR_SUB_SHARED}/Shared/%%u
|
||||||
mail_path = %{owner_home}${MAILDIR_SUB_SHARED}
|
|
||||||
mail_index_private_path = ~${MAILDIR_SUB_SHARED}/Shared/%{owner_user}
|
|
||||||
subscriptions = no
|
subscriptions = no
|
||||||
list = children
|
list = children
|
||||||
}
|
}
|
||||||
@@ -218,7 +197,7 @@ EOF
|
|||||||
cat <<EOF > /etc/dovecot/sogo_trusted_ip.conf
|
cat <<EOF > /etc/dovecot/sogo_trusted_ip.conf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
remote ${IPV4_NETWORK}.248 {
|
remote ${IPV4_NETWORK}.248 {
|
||||||
auth_allow_cleartext = yes
|
disable_plaintext_auth = no
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -227,13 +206,9 @@ RAND_PASS=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 32 | head -n 1)
|
|||||||
echo -n ${RAND_PASS} > /etc/phpfpm/sogo-sso.pass
|
echo -n ${RAND_PASS} > /etc/phpfpm/sogo-sso.pass
|
||||||
cat <<EOF > /etc/dovecot/sogo-sso.conf
|
cat <<EOF > /etc/dovecot/sogo-sso.conf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
passdb static {
|
passdb {
|
||||||
fields {
|
driver = static
|
||||||
allow_real_nets=${IPV4_NETWORK}.248/32
|
args = allow_nets=${IPV4_NETWORK}.248/32 password={plain}${RAND_PASS}
|
||||||
}
|
|
||||||
|
|
||||||
password={plain}${RAND_PASS}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
@@ -261,9 +236,9 @@ fi
|
|||||||
if [[ "${SKIP_FTS}" =~ ^([nN][oO]|[nN])+$ ]]; then
|
if [[ "${SKIP_FTS}" =~ ^([nN][oO]|[nN])+$ ]]; then
|
||||||
echo -e "\e[94mConfiguring FTS Settings...\e[0m"
|
echo -e "\e[94mConfiguring FTS Settings...\e[0m"
|
||||||
echo -e "\e[94mSetting FTS Memory Limit (per process) to ${FTS_HEAP} MB\e[0m"
|
echo -e "\e[94mSetting FTS Memory Limit (per process) to ${FTS_HEAP} MB\e[0m"
|
||||||
sed -i "s/vsz_limit\s*=\s*[0-9]*\s*MB*/vsz_limit=${FTS_HEAP} MB/" /etc/dovecot/conf.d/35-fts.conf
|
sed -i "s/vsz_limit\s*=\s*[0-9]*\s*MB*/vsz_limit=${FTS_HEAP} MB/" /etc/dovecot/conf.d/fts.conf
|
||||||
echo -e "\e[94mSetting FTS Process Limit to ${FTS_PROCS}\e[0m"
|
echo -e "\e[94mSetting FTS Process Limit to ${FTS_PROCS}\e[0m"
|
||||||
sed -i "s/process_limit\s*=\s*[0-9]*/process_limit=${FTS_PROCS}/" /etc/dovecot/conf.d/35-fts.conf
|
sed -i "s/process_limit\s*=\s*[0-9]*/process_limit=${FTS_PROCS}/" /etc/dovecot/conf.d/fts.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 401 is user dovecot
|
# 401 is user dovecot
|
||||||
@@ -275,16 +250,16 @@ else
|
|||||||
chown 401 /mail_crypt/ecprivkey.pem /mail_crypt/ecpubkey.pem
|
chown 401 /mail_crypt/ecprivkey.pem /mail_crypt/ecpubkey.pem
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# # Fix OpenSSL 3.X TLS1.0, 1.1 support (https://community.mailcow.email/d/4062-hi-all/20)
|
# Fix OpenSSL 3.X TLS1.0, 1.1 support (https://community.mailcow.email/d/4062-hi-all/20)
|
||||||
# if grep -qE 'ssl_min_protocol\s*=\s*(TLSv1|TLSv1\.1)\s*$' /etc/dovecot/dovecot.conf /etc/dovecot/extra.conf; then
|
if grep -qE 'ssl_min_protocol\s*=\s*(TLSv1|TLSv1\.1)\s*$' /etc/dovecot/dovecot.conf /etc/dovecot/extra.conf; then
|
||||||
# sed -i '/\[openssl_init\]/a ssl_conf = ssl_configuration' /etc/ssl/openssl.cnf
|
sed -i '/\[openssl_init\]/a ssl_conf = ssl_configuration' /etc/ssl/openssl.cnf
|
||||||
|
|
||||||
# echo "[ssl_configuration]" >> /etc/ssl/openssl.cnf
|
echo "[ssl_configuration]" >> /etc/ssl/openssl.cnf
|
||||||
# echo "system_default = tls_system_default" >> /etc/ssl/openssl.cnf
|
echo "system_default = tls_system_default" >> /etc/ssl/openssl.cnf
|
||||||
# echo "[tls_system_default]" >> /etc/ssl/openssl.cnf
|
echo "[tls_system_default]" >> /etc/ssl/openssl.cnf
|
||||||
# echo "MinProtocol = TLSv1" >> /etc/ssl/openssl.cnf
|
echo "MinProtocol = TLSv1" >> /etc/ssl/openssl.cnf
|
||||||
# echo "CipherString = DEFAULT@SECLEVEL=0" >> /etc/ssl/openssl.cnf
|
echo "CipherString = DEFAULT@SECLEVEL=0" >> /etc/ssl/openssl.cnf
|
||||||
# fi
|
fi
|
||||||
|
|
||||||
# Compile sieve scripts
|
# Compile sieve scripts
|
||||||
sievec /var/vmail/sieve/global_sieve_before.sieve
|
sievec /var/vmail/sieve/global_sieve_before.sieve
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ try:
|
|||||||
if max_score == "":
|
if max_score == "":
|
||||||
max_score = 9999.0
|
max_score = 9999.0
|
||||||
|
|
||||||
def query_mysql(query, headers = True, update = False):
|
def query_mysql(query, params = None, headers = True, update = False):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
||||||
@@ -57,7 +57,10 @@ try:
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
cur = cnx.cursor()
|
cur = cnx.cursor()
|
||||||
cur.execute(query)
|
if params:
|
||||||
|
cur.execute(query, params)
|
||||||
|
else:
|
||||||
|
cur.execute(query)
|
||||||
if not update:
|
if not update:
|
||||||
result = []
|
result = []
|
||||||
columns = tuple( [d[0] for d in cur.description] )
|
columns = tuple( [d[0] for d in cur.description] )
|
||||||
@@ -76,7 +79,7 @@ try:
|
|||||||
|
|
||||||
def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
|
def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
|
||||||
if category == "add_header": category = "add header"
|
if category == "add_header": category = "add header"
|
||||||
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %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))
|
print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
|
||||||
if len(meta_query) == 0:
|
if len(meta_query) == 0:
|
||||||
return
|
return
|
||||||
@@ -130,7 +133,7 @@ try:
|
|||||||
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
|
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
|
||||||
server.quit()
|
server.quit()
|
||||||
for res in meta_query:
|
for res in meta_query:
|
||||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%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)
|
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
|
||||||
break
|
break
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@@ -138,7 +141,7 @@ try:
|
|||||||
print('%s' % (ex))
|
print('%s' % (ex))
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %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:
|
for record in records:
|
||||||
attrs = ''
|
attrs = ''
|
||||||
@@ -156,7 +159,7 @@ try:
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
|
print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
|
||||||
last_notification = 0
|
last_notification = 0
|
||||||
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = "%s"' % (record['rcpt']))
|
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = %s', (record['rcpt'],))
|
||||||
attrs = attrs_json[0]['attributes']
|
attrs = attrs_json[0]['attributes']
|
||||||
if isinstance(attrs, str):
|
if isinstance(attrs, str):
|
||||||
# if attr is str then just load it
|
# if attr is str then just load it
|
||||||
|
|||||||
@@ -24,13 +24,12 @@ fi
|
|||||||
sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules
|
sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules
|
||||||
|
|
||||||
if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then
|
if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then
|
||||||
CONTAINER_NAME=rspamd-mailcow
|
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
|
||||||
CONTAINER_ID=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | \
|
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
|
||||||
jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | \
|
REQ_ID="$(date +%s%N)"
|
||||||
jq -rc "select( .name | tostring | contains(\"${CONTAINER_NAME}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
|
PAYLOAD="{\"cmd\":\"restart\",\"request_id\":\"${REQ_ID}\",\"issued_by\":\"dovecot-sa-rules\"}"
|
||||||
if [[ ! -z ${CONTAINER_ID} ]]; then
|
redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning \
|
||||||
curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
|
PUBLISH mailcow.control.rspamd "${PAYLOAD}" >/dev/null 2>&1 || true
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(`REDIS_SLAVEOF_PORT`)
|
port(`REDIS_SLAVEOF_PORT`)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json 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 {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(6379)
|
port(6379)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json 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 {
|
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,3 +1,7 @@
|
|||||||
|
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||||
|
|
||||||
|
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
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
|
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
|
FROM nginx:alpine
|
||||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
@@ -14,5 +18,14 @@ RUN mkdir -p /etc/nginx/includes
|
|||||||
COPY ./bootstrap.py /
|
COPY ./bootstrap.py /
|
||||||
COPY ./docker-entrypoint.sh /
|
COPY ./docker-entrypoint.sh /
|
||||||
|
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
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
|
FROM alpine:3.21
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
@@ -18,6 +22,16 @@ ADD olefy.py /app/
|
|||||||
|
|
||||||
RUN chown -R nobody:nobody /app /tmp
|
RUN chown -R nobody:nobody /app /tmp
|
||||||
|
|
||||||
|
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||||
|
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||||
|
|
||||||
USER nobody
|
USER nobody
|
||||||
|
|
||||||
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,3 +1,7 @@
|
|||||||
|
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||||
|
|
||||||
|
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||||
|
|
||||||
FROM php:8.2-fpm-alpine3.21
|
FROM php:8.2-fpm-alpine3.21
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
@@ -109,6 +113,14 @@ RUN apk add -U --no-cache autoconf \
|
|||||||
|
|
||||||
COPY ./docker-entrypoint.sh /
|
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}'"
|
session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'"
|
||||||
' > /usr/local/etc/php/conf.d/session_store.ini
|
' > /usr/local/etc/php/conf.d/session_store.ini
|
||||||
|
|
||||||
# Check mysql_upgrade (master and slave)
|
# Wait for MariaDB. The upstream mariadb image already runs mariadb-upgrade
|
||||||
CONTAINER_ID=
|
# itself on startup when needed
|
||||||
until [[ ! -z "${CONTAINER_ID}" ]] && [[ "${CONTAINER_ID}" =~ ^[[:alnum:]]*$ ]]; do
|
echo "Waiting for MariaDB socket at /var/run/mysqld/mysqld.sock..."
|
||||||
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)
|
WAIT_C=0
|
||||||
echo "Could not get mysql-mailcow container id... trying again"
|
until mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} -e "SELECT 1" >/dev/null 2>&1; do
|
||||||
sleep 2
|
WAIT_C=$((WAIT_C+1))
|
||||||
done
|
if [ ${WAIT_C} -gt 60 ]; then
|
||||||
echo "MySQL @ ${CONTAINER_ID}"
|
echo "MariaDB did not respond after 60s — continuing anyway."
|
||||||
SQL_LOOP_C=0
|
|
||||||
SQL_CHANGED=0
|
|
||||||
until [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; do
|
|
||||||
if [ ${SQL_LOOP_C} -gt 4 ]; then
|
|
||||||
echo "Tried to upgrade MySQL and failed, giving up after ${SQL_LOOP_C} retries and starting container (oops, not good)"
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
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')
|
sleep 1
|
||||||
SQL_UPGRADE_STATUS=$(echo ${SQL_FULL_UPGRADE_RETURN} | jq -r .type)
|
|
||||||
SQL_LOOP_C=$((SQL_LOOP_C+1))
|
|
||||||
echo "SQL upgrade iteration #${SQL_LOOP_C}"
|
|
||||||
if [[ ${SQL_UPGRADE_STATUS} == 'warning' ]]; then
|
|
||||||
SQL_CHANGED=1
|
|
||||||
echo "MySQL applied an upgrade, debug output:"
|
|
||||||
echo ${SQL_FULL_UPGRADE_RETURN}
|
|
||||||
sleep 3
|
|
||||||
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
|
|
||||||
echo "Waiting for SQL to return, please wait"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
continue
|
|
||||||
elif [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; then
|
|
||||||
echo "MySQL is up-to-date - debug output:"
|
|
||||||
echo ${SQL_FULL_UPGRADE_RETURN}
|
|
||||||
else
|
|
||||||
echo "No valid reponse for mysql_upgrade was received, debug output:"
|
|
||||||
echo ${SQL_FULL_UPGRADE_RETURN}
|
|
||||||
fi
|
|
||||||
done
|
done
|
||||||
|
echo "MariaDB is ready."
|
||||||
|
|
||||||
# doing post-installation stuff, if SQL was upgraded (master and slave)
|
# Timezone tables — check if CONVERT_TZ works, import if it returns NULL.
|
||||||
if [ ${SQL_CHANGED} -eq 1 ]; then
|
# Some Alpine builds drop mariadb-tzinfo-to-sql; fall back to a Python
|
||||||
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)
|
# emitter that produces the same INSERT statements from /usr/share/zoneinfo.
|
||||||
if [[ -z "${POSTFIX}" ]] || ! [[ "${POSTFIX}" =~ ^[[:alnum:]]*$ ]]; then
|
|
||||||
echo "Could not determine Postfix container ID, skipping Postfix restart."
|
|
||||||
else
|
|
||||||
echo "Restarting Postfix"
|
|
||||||
curl -X POST --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/restart | jq -r '.msg'
|
|
||||||
echo "Sleeping 5 seconds..."
|
|
||||||
sleep 5
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check mysql tz import (master and slave)
|
|
||||||
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
|
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
|
||||||
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
|
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
|
||||||
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 "Importing timezone data into mysql.time_zone_* …"
|
||||||
echo "MySQL mysql_tzinfo_to_sql - debug output:"
|
if command -v mariadb-tzinfo-to-sql >/dev/null 2>&1; then
|
||||||
echo ${SQL_FULL_TZINFO_IMPORT_RETURN}
|
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
|
fi
|
||||||
|
|
||||||
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
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
|
FROM golang:1.25-bookworm AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -45,6 +49,14 @@ RUN chmod +x /opt/postfix-tlspol.sh \
|
|||||||
/docker-entrypoint.sh
|
/docker-entrypoint.sh
|
||||||
RUN rm -rf /tmp/* /var/tmp/*
|
RUN rm -rf /tmp/* /var/tmp/*
|
||||||
|
|
||||||
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")
|
persist-name("redis1")
|
||||||
port(`REDIS_SLAVEOF_PORT`)
|
port(`REDIS_SLAVEOF_PORT`)
|
||||||
auth("`REDISPASS`")
|
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); };
|
filter f_mail { facility(mail); };
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(6379)
|
port(6379)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json 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); };
|
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
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
@@ -58,6 +62,14 @@ RUN rm -rf /tmp/* /var/tmp/*
|
|||||||
|
|
||||||
EXPOSE 588
|
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 []
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(`REDIS_SLAVEOF_PORT`)
|
port(`REDIS_SLAVEOF_PORT`)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json 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 {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(6379)
|
port(6379)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json 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 {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||||
|
|
||||||
|
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||||
|
|
||||||
FROM debian:trixie-slim
|
FROM debian:trixie-slim
|
||||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
ARG RSPAMD_VER=rspamd_3.14.2-82~90302bc
|
ARG RSPAMD_VER=rspamd_3.14.3-1~236eb65
|
||||||
ARG CODENAME=trixie
|
ARG CODENAME=trixie
|
||||||
ENV LC_ALL=C
|
ENV LC_ALL=C
|
||||||
|
|
||||||
@@ -33,8 +37,16 @@ COPY settings.conf /etc/rspamd/settings.conf
|
|||||||
COPY set_worker_password.sh /set_worker_password.sh
|
COPY set_worker_password.sh /set_worker_password.sh
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
|
||||||
|
|
||||||
STOPSIGNAL SIGTERM
|
STOPSIGNAL SIGTERM
|
||||||
|
|
||||||
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,21 +1,25 @@
|
|||||||
# SOGo built from source to enable security patch application
|
# SOGo built from source to enable security patch application
|
||||||
# Repository: https://github.com/Alinto/sogo
|
# Repository: https://github.com/Alinto/sogo
|
||||||
# Version: SOGo-5.12.4
|
# Version: SOGo-5.12.8
|
||||||
#
|
#
|
||||||
# Applied security patches:
|
# Applied security patches:
|
||||||
# - 16ab99e7cf8db2c30b211f0d5e338d7f9e3a9efb: XSS vulnerability in theme parameter
|
# -
|
||||||
#
|
#
|
||||||
# To add new patches, modify SOGO_SECURITY_PATCHES ARG below with space-separated commit hashes
|
# 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
|
FROM debian:bookworm
|
||||||
|
|
||||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
ARG SOGO_VERSION=SOGo-5.12.4
|
ARG SOGO_VERSION=SOGo-5.12.8
|
||||||
ARG SOPE_VERSION=SOPE-5.12.4
|
ARG SOPE_VERSION=SOPE-5.12.8
|
||||||
# Security patches to apply (space-separated commit hashes)
|
# Security patches to apply (space-separated commit hashes)
|
||||||
ARG SOGO_SECURITY_PATCHES="16ab99e7cf8db2c30b211f0d5e338d7f9e3a9efb"
|
ARG SOGO_SECURITY_PATCHES=""
|
||||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||||
ARG GOSU_VERSION=1.19
|
ARG GOSU_VERSION=1.19
|
||||||
ENV LC_ALL=C
|
ENV LC_ALL=C
|
||||||
@@ -26,6 +30,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
git \
|
git \
|
||||||
build-essential \
|
build-essential \
|
||||||
gobjc \
|
gobjc \
|
||||||
|
pkg-config \
|
||||||
gnustep-make \
|
gnustep-make \
|
||||||
gnustep-base-runtime \
|
gnustep-base-runtime \
|
||||||
libgnustep-base-dev \
|
libgnustep-base-dev \
|
||||||
@@ -40,6 +45,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
libzip-dev \
|
libzip-dev \
|
||||||
libytnef0-dev \
|
libytnef0-dev \
|
||||||
|
libwbxml2-dev \
|
||||||
curl \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
# Runtime dependencies
|
# Runtime dependencies
|
||||||
@@ -68,6 +74,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libcurl4 \
|
libcurl4 \
|
||||||
libzip4 \
|
libzip4 \
|
||||||
libytnef0 \
|
libytnef0 \
|
||||||
|
libwbxml2-1 \
|
||||||
# Download gosu
|
# Download gosu
|
||||||
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
|
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
|
||||||
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
|
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
|
||||||
@@ -97,6 +104,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& ./configure --disable-debug --disable-strip \
|
&& ./configure --disable-debug --disable-strip \
|
||||||
&& make -j$(nproc) \
|
&& make -j$(nproc) \
|
||||||
&& make install \
|
&& make install \
|
||||||
|
&& cd /tmp/sogo/ActiveSync \
|
||||||
|
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
|
||||||
|
&& make -j$(nproc) install \
|
||||||
&& cd / \
|
&& cd / \
|
||||||
&& rm -rf /tmp/sogo \
|
&& rm -rf /tmp/sogo \
|
||||||
# Strip binaries
|
# Strip binaries
|
||||||
@@ -146,8 +156,8 @@ RUN echo "/usr/lib64" > /etc/ld.so.conf.d/sogo.conf \
|
|||||||
# Create sogo user and group
|
# Create sogo user and group
|
||||||
RUN groupadd -r -g 999 sogo \
|
RUN groupadd -r -g 999 sogo \
|
||||||
&& useradd -r -u 999 -g sogo -d /var/lib/sogo -s /bin/bash -c "SOGo Daemon" 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 \
|
&& 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
|
&& chown -R sogo:sogo /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo
|
||||||
|
|
||||||
# Create symlinks for SOGo binaries
|
# Create symlinks for SOGo binaries
|
||||||
RUN ln -s /usr/local/sbin/sogod /usr/sbin/sogod \
|
RUN ln -s /usr/local/sbin/sogod /usr/sbin/sogod \
|
||||||
@@ -168,6 +178,14 @@ COPY docker-entrypoint.sh /
|
|||||||
RUN chmod +x /bootstrap-sogo.sh \
|
RUN chmod +x /bootstrap-sogo.sh \
|
||||||
/usr/local/sbin/stop-supervisor.sh
|
/usr/local/sbin/stop-supervisor.sh
|
||||||
|
|
||||||
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/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200
|
||||||
+++ /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200
|
+++ /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200
|
||||||
@@ -46,7 +46,7 @@
|
@@ -46,7 +46,7 @@
|
||||||
</md-item-template>
|
</md-item-template>
|
||||||
</md-autocomplete>
|
</md-autocomplete>
|
||||||
|
|||||||
@@ -130,18 +130,22 @@ chmod 600 /var/lib/sogo/GNUstep/Defaults/sogod.plist
|
|||||||
# Patch ACLs
|
# Patch ACLs
|
||||||
#if [[ ${ACL_ANYONE} == 'allow' ]]; then
|
#if [[ ${ACL_ANYONE} == 'allow' ]]; then
|
||||||
# #enable any or authenticated targets for ACL
|
# #enable any or authenticated targets for ACL
|
||||||
# if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
# if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||||
# patch -R /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
# patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||||
# fi
|
# fi
|
||||||
#else
|
#else
|
||||||
# #disable any or authenticated targets for ACL
|
# #disable any or authenticated targets for ACL
|
||||||
# if patch -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
# if patch -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||||
# patch /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
# patch /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||||
# fi
|
# fi
|
||||||
#fi
|
#fi
|
||||||
|
|
||||||
if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
|
# Apply custom UI patch (reverse patch to ADD buttons)
|
||||||
patch -R /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
|
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
|
fi
|
||||||
|
|
||||||
# Rename custom logo, if any
|
# Rename custom logo, if any
|
||||||
@@ -149,7 +153,7 @@ fi
|
|||||||
|
|
||||||
# Rsync web content
|
# Rsync web content
|
||||||
echo "Syncing web content with named volume"
|
echo "Syncing web content with named volume"
|
||||||
rsync -a /usr/lib/GNUstep/SOGo/. /sogo_web/
|
rsync -a /usr/local/lib/GNUstep/SOGo/. /sogo_web/
|
||||||
|
|
||||||
# Chown backup path
|
# Chown backup path
|
||||||
chown -R sogo:sogo /sogo_backup
|
chown -R sogo:sogo /sogo_backup
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(`REDIS_SLAVEOF_PORT`)
|
port(`REDIS_SLAVEOF_PORT`)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "SOGO_LOG" "$(format-json 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 {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(6379)
|
port(6379)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "SOGO_LOG" "$(format-json 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 {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||||
|
|
||||||
|
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
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
|
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
||||||
|
|
||||||
RUN chmod +x /healthcheck.sh
|
RUN chmod +x /healthcheck.sh
|
||||||
HEALTHCHECK --interval=30s --timeout=10s \
|
|
||||||
CMD sh -c '[ -f /tmp/healthcheck_status ] && [ "$(cat /tmp/healthcheck_status)" -eq 0 ] || exit 1'
|
|
||||||
|
|
||||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
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,3 +1,7 @@
|
|||||||
|
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||||
|
|
||||||
|
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||||
|
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
@@ -37,5 +41,16 @@ RUN apk add --update \
|
|||||||
COPY watchdog.sh /watchdog.sh
|
COPY watchdog.sh /watchdog.sh
|
||||||
COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh
|
COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh
|
||||||
COPY check_dns.sh /usr/lib/mailcow/check_dns.sh
|
COPY check_dns.sh /usr/lib/mailcow/check_dns.sh
|
||||||
|
COPY client.cnf /etc/my.cnf.d/client.cnf
|
||||||
|
|
||||||
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 []
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[client]
|
||||||
|
ssl = false
|
||||||
|
ssl-verify-server-cert = false
|
||||||
@@ -38,7 +38,7 @@ if [[ ! -p /tmp/com_pipe ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Wait for containers
|
# 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..."
|
echo "Waiting for SQL..."
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
@@ -188,44 +188,6 @@ function notify_error() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
get_container_ip() {
|
|
||||||
# ${1} is container
|
|
||||||
CONTAINER_ID=()
|
|
||||||
CONTAINER_IPS=()
|
|
||||||
CONTAINER_IP=
|
|
||||||
LOOP_C=1
|
|
||||||
until [[ ${CONTAINER_IP} =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || [[ ${LOOP_C} -gt 5 ]]; do
|
|
||||||
if [ ${IP_BY_DOCKER_API} -eq 0 ]; then
|
|
||||||
CONTAINER_IP=$(dig a "${1}" +short)
|
|
||||||
else
|
|
||||||
sleep 0.5
|
|
||||||
# get long container id for exact match
|
|
||||||
CONTAINER_ID=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring == \"${1}\") | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id"))
|
|
||||||
# returned id can have multiple elements (if scaled), shuffle for random test
|
|
||||||
CONTAINER_ID=($(printf "%s\n" "${CONTAINER_ID[@]}" | shuf))
|
|
||||||
if [[ ! -z ${CONTAINER_ID} ]]; then
|
|
||||||
for matched_container in "${CONTAINER_ID[@]}"; do
|
|
||||||
CONTAINER_IPS=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${matched_container}/json | jq -r '.NetworkSettings.Networks[].IPAddress'))
|
|
||||||
for ip_match in "${CONTAINER_IPS[@]}"; do
|
|
||||||
# grep will do nothing if one of these vars is empty
|
|
||||||
[[ -z ${ip_match} ]] && continue
|
|
||||||
[[ -z ${IPV4_NETWORK} ]] && continue
|
|
||||||
# only return ips that are part of our network
|
|
||||||
if ! grep -q ${IPV4_NETWORK} <(echo ${ip_match}); then
|
|
||||||
continue
|
|
||||||
else
|
|
||||||
CONTAINER_IP=${ip_match}
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
[[ ! -z ${CONTAINER_IP} ]] && break
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
LOOP_C=$((LOOP_C + 1))
|
|
||||||
done
|
|
||||||
[[ ${LOOP_C} -gt 5 ]] && echo 240.0.0.0 || echo ${CONTAINER_IP}
|
|
||||||
}
|
|
||||||
|
|
||||||
# One-time check
|
# One-time check
|
||||||
if grep -qi "$(echo ${IPV6_NETWORK} | cut -d: -f1-3)" <<< "$(ip a s)"; then
|
if grep -qi "$(echo ${IPV6_NETWORK} | cut -d: -f1-3)" <<< "$(ip a s)"; then
|
||||||
@@ -267,295 +229,6 @@ external_checks() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
nginx_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${NGINX_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/nginx-mailcow; echo "$(tail -50 /tmp/nginx-mailcow)" > /tmp/nginx-mailcow
|
|
||||||
host_ip=$(get_container_ip nginx-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_http -4 -H ${host_ip} -u / -p 8081 2>> /tmp/nginx-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Nginx" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
unbound_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${UNBOUND_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/unbound-mailcow; echo "$(tail -50 /tmp/unbound-mailcow)" > /tmp/unbound-mailcow
|
|
||||||
host_ip=$(get_container_ip unbound-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/mailcow/check_dns.sh -s ${host_ip} -H stackoverflow.com 2>> /tmp/unbound-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
DNSSEC=$(dig com +dnssec | egrep 'flags:.+ad')
|
|
||||||
if [[ -z ${DNSSEC} ]]; then
|
|
||||||
echo "DNSSEC failure" 2>> /tmp/unbound-mailcow 1>&2
|
|
||||||
err_count=$(( ${err_count} + 1))
|
|
||||||
else
|
|
||||||
echo "DNSSEC check succeeded" 2>> /tmp/unbound-mailcow 1>&2
|
|
||||||
fi
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Unbound" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
redis_checks() {
|
|
||||||
# A check for the local redis container
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${REDIS_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/redis-mailcow; echo "$(tail -50 /tmp/redis-mailcow)" > /tmp/redis-mailcow
|
|
||||||
host_ip=$(get_container_ip redis-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_tcp -4 -H redis-mailcow -p 6379 -E -s "AUTH ${REDISPASS}\nPING\n" -q "QUIT" -e "PONG" 2>> /tmp/redis-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Redis" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
mysql_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${MYSQL_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/mysql-mailcow; echo "$(tail -50 /tmp/mysql-mailcow)" > /tmp/mysql-mailcow
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_mysql -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
/usr/lib/nagios/plugins/check_mysql_query -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} -q "SELECT COUNT(*) FROM information_schema.tables" 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "MySQL/MariaDB" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
mysql_repl_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${MYSQL_REPLICATION_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/mysql_repl_checks; echo "$(tail -50 /tmp/mysql_repl_checks)" > /tmp/mysql_repl_checks
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_mysql_slavestatus.sh -S /var/run/mysqld/mysqld.sock -u root -p ${DBROOT} 2>> /tmp/mysql_repl_checks 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "MySQL/MariaDB replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 60
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
sogo_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${SOGO_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/sogo-mailcow; echo "$(tail -50 /tmp/sogo-mailcow)" > /tmp/sogo-mailcow
|
|
||||||
host_ip=$(get_container_ip sogo-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_http -4 -H ${host_ip} -u /SOGo.index/ -p 20000 2>> /tmp/sogo-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "SOGo" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
postfix_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${POSTFIX_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/postfix-mailcow; echo "$(tail -50 /tmp/postfix-mailcow)" > /tmp/postfix-mailcow
|
|
||||||
host_ip=$(get_container_ip postfix-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 589 -f "watchdog@invalid" -C "RCPT TO:watchdog@localhost" -C DATA -C . -R 250 2>> /tmp/postfix-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 589 -S 2>> /tmp/postfix-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Postfix" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
postfix-tlspol_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${POSTFIX_TLSPOL_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/postfix-tlspol-mailcow; echo "$(tail -50 /tmp/postfix-tlspol-mailcow)" > /tmp/postfix-tlspol-mailcow
|
|
||||||
host_ip=$(get_container_ip postfix-tlspol-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 8642 2>> /tmp/postfix-tlspol-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Postfix TLS Policy companion" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
clamd_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${CLAMD_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/clamd-mailcow; echo "$(tail -50 /tmp/clamd-mailcow)" > /tmp/clamd-mailcow
|
|
||||||
host_ip=$(get_container_ip clamd-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_clamd -4 -H ${host_ip} 2>> /tmp/clamd-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Clamd" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 120 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
dovecot_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${DOVECOT_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/dovecot-mailcow; echo "$(tail -50 /tmp/dovecot-mailcow)" > /tmp/dovecot-mailcow
|
|
||||||
host_ip=$(get_container_ip dovecot-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 24 -f "watchdog@invalid" -C "RCPT TO:<watchdog@invalid>" -L -R "User doesn't exist" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
/usr/lib/nagios/plugins/check_imap -4 -H ${host_ip} -p 993 -S -e "OK " 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
/usr/lib/nagios/plugins/check_imap -4 -H ${host_ip} -p 143 -e "OK " 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 10001 -e "VERSION" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 4190 -e "Dovecot ready" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Dovecot" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
dovecot_repl_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${DOVECOT_REPL_THRESHOLD}
|
|
||||||
D_REPL_STATUS=$(redis-cli -h redis -a ${REDISPASS} --no-auth-warning -r GET DOVECOT_REPL_HEALTH)
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
D_REPL_STATUS=$(redis-cli --raw -h redis -a ${REDISPASS} --no-auth-warning GET DOVECOT_REPL_HEALTH)
|
|
||||||
if [[ "${D_REPL_STATUS}" != "1" ]]; then
|
|
||||||
err_count=$(( ${err_count} + 1 ))
|
|
||||||
fi
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Dovecot replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 60
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
cert_checks() {
|
cert_checks() {
|
||||||
err_count=0
|
err_count=0
|
||||||
diff_c=0
|
diff_c=0
|
||||||
@@ -564,11 +237,9 @@ cert_checks() {
|
|||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
||||||
touch /tmp/certcheck; echo "$(tail -50 /tmp/certcheck)" > /tmp/certcheck
|
touch /tmp/certcheck; echo "$(tail -50 /tmp/certcheck)" > /tmp/certcheck
|
||||||
host_ip_postfix=$(get_container_ip postfix)
|
|
||||||
host_ip_dovecot=$(get_container_ip dovecot)
|
|
||||||
err_c_cur=${err_count}
|
err_c_cur=${err_count}
|
||||||
/usr/lib/nagios/plugins/check_smtp -H ${host_ip_postfix} -p 589 -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 ${host_ip_dovecot} -p 993 -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} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
||||||
progress "Primary certificate expiry check" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
progress "Primary certificate expiry check" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
||||||
@@ -578,31 +249,6 @@ cert_checks() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
phpfpm_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${PHPFPM_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/php-fpm-mailcow; echo "$(tail -50 /tmp/php-fpm-mailcow)" > /tmp/php-fpm-mailcow
|
|
||||||
host_ip=$(get_container_ip php-fpm-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_tcp -H ${host_ip} -p 9001 2>> /tmp/php-fpm-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
/usr/lib/nagios/plugins/check_tcp -H ${host_ip} -p 9002 2>> /tmp/php-fpm-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "PHP-FPM" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
ratelimit_checks() {
|
ratelimit_checks() {
|
||||||
err_count=0
|
err_count=0
|
||||||
@@ -736,90 +382,63 @@ acme_checks() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
rspamd_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${RSPAMD_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/rspamd-mailcow; echo "$(tail -50 /tmp/rspamd-mailcow)" > /tmp/rspamd-mailcow
|
|
||||||
host_ip=$(get_container_ip rspamd-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
SCORE=$(echo 'To: null@localhost
|
|
||||||
From: watchdog@localhost
|
|
||||||
|
|
||||||
Empty
|
|
||||||
' | usr/bin/curl --max-time 10 -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/scan | jq -rc .default.required_score | sed 's/\..*//' )
|
|
||||||
if [[ ${SCORE} -ne 9999 ]]; then
|
|
||||||
echo "Rspamd settings check failed, score returned: ${SCORE}" 2>> /tmp/rspamd-mailcow 1>&2
|
|
||||||
err_count=$(( ${err_count} + 1))
|
|
||||||
else
|
|
||||||
echo "Rspamd settings check succeeded, score returned: ${SCORE}" 2>> /tmp/rspamd-mailcow 1>&2
|
|
||||||
fi
|
|
||||||
# A dirty hack until a PING PONG event is implemented to worker proxy
|
|
||||||
# We expect an empty response, not a timeout
|
|
||||||
if [ "$(curl -s --max-time 10 ${host_ip}:9900 2> /dev/null ; echo $?)" == "28" ]; then
|
|
||||||
echo "Milter check failed" 2>> /tmp/rspamd-mailcow 1>&2; err_count=$(( ${err_count} + 1 ));
|
|
||||||
else
|
|
||||||
echo "Milter check succeeded" 2>> /tmp/rspamd-mailcow 1>&2
|
|
||||||
fi
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Rspamd" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
olefy_checks() {
|
|
||||||
err_count=0
|
|
||||||
diff_c=0
|
|
||||||
THRESHOLD=${OLEFY_THRESHOLD}
|
|
||||||
# Reduce error count by 2 after restarting an unhealthy container
|
|
||||||
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
|
|
||||||
while [ ${err_count} -lt ${THRESHOLD} ]; do
|
|
||||||
touch /tmp/olefy-mailcow; echo "$(tail -50 /tmp/olefy-mailcow)" > /tmp/olefy-mailcow
|
|
||||||
host_ip=$(get_container_ip olefy-mailcow)
|
|
||||||
err_c_cur=${err_count}
|
|
||||||
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 10055 -s "PING\n" 2>> /tmp/olefy-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
|
|
||||||
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
|
|
||||||
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
|
|
||||||
progress "Olefy" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
|
|
||||||
if [[ $? == 10 ]]; then
|
|
||||||
diff_c=0
|
|
||||||
sleep 1
|
|
||||||
else
|
|
||||||
diff_c=0
|
|
||||||
sleep $(( ( RANDOM % 60 ) + 20 ))
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Notify about start
|
# Notify about start
|
||||||
if [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
if [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||||
notify_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
|
notify_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 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
|
while true; do
|
||||||
if ! nginx_checks; then
|
for svc in "${HEALTH_WATCHED_SERVICES[@]}"; do
|
||||||
log_msg "Nginx hit error limit"
|
[[ -z "$svc" ]] && continue
|
||||||
echo nginx-mailcow > /tmp/com_pipe
|
nodes=$(${REDIS_CMDLINE_FULL} ZRANGEBYSCORE "mailcow.nodes.${svc}" "$(( $(date +%s) - 30 ))" "+inf" 2>/dev/null)
|
||||||
fi
|
[[ -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
|
done
|
||||||
) &
|
) &
|
||||||
PID=$!
|
PID=$!
|
||||||
echo "Spawned nginx_checks with PID ${PID}"
|
echo "Spawned registry-based health monitor with PID ${PID}"
|
||||||
BACKGROUND_TASKS+=(${PID})
|
BACKGROUND_TASKS+=(${PID})
|
||||||
|
|
||||||
if [[ ${WATCHDOG_EXTERNAL_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
if [[ ${WATCHDOG_EXTERNAL_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||||
@@ -836,110 +455,6 @@ echo "Spawned external_checks with PID ${PID}"
|
|||||||
BACKGROUND_TASKS+=(${PID})
|
BACKGROUND_TASKS+=(${PID})
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ${WATCHDOG_MYSQL_REPLICATION_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! mysql_repl_checks; then
|
|
||||||
log_msg "MySQL replication check hit error limit"
|
|
||||||
echo mysql_repl_checks > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned mysql_repl_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! mysql_checks; then
|
|
||||||
log_msg "MySQL hit error limit"
|
|
||||||
echo mysql-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned mysql_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! redis_checks; then
|
|
||||||
log_msg "Local Redis hit error limit"
|
|
||||||
echo redis-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned redis_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! phpfpm_checks; then
|
|
||||||
log_msg "PHP-FPM hit error limit"
|
|
||||||
echo php-fpm-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned phpfpm_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
|
|
||||||
if [[ "${SKIP_SOGO}" =~ ^([nN][oO]|[nN])+$ ]]; then
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! sogo_checks; then
|
|
||||||
log_msg "SOGo hit error limit"
|
|
||||||
echo sogo-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned sogo_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ${CHECK_UNBOUND} -eq 1 ]; then
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! unbound_checks; then
|
|
||||||
log_msg "Unbound hit error limit"
|
|
||||||
echo unbound-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned unbound_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${SKIP_CLAMD}" =~ ^([nN][oO]|[nN])+$ ]]; then
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! clamd_checks; then
|
|
||||||
log_msg "Clamd hit error limit"
|
|
||||||
echo clamd-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned clamd_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! postfix_checks; then
|
|
||||||
log_msg "Postfix hit error limit"
|
|
||||||
echo postfix-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned postfix_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
|
|
||||||
(
|
(
|
||||||
while true; do
|
while true; do
|
||||||
if ! mailq_checks; then
|
if ! mailq_checks; then
|
||||||
@@ -952,54 +467,6 @@ PID=$!
|
|||||||
echo "Spawned mailq_checks with PID ${PID}"
|
echo "Spawned mailq_checks with PID ${PID}"
|
||||||
BACKGROUND_TASKS+=(${PID})
|
BACKGROUND_TASKS+=(${PID})
|
||||||
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! postfix-tlspol_checks; then
|
|
||||||
log_msg "Postfix TLS Policy hit error limit"
|
|
||||||
echo postfix-tlspol-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned postfix-tlspol_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! dovecot_checks; then
|
|
||||||
log_msg "Dovecot hit error limit"
|
|
||||||
echo dovecot-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned dovecot_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! dovecot_repl_checks; then
|
|
||||||
log_msg "Dovecot hit error limit"
|
|
||||||
echo dovecot_repl_checks > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned dovecot_repl_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! rspamd_checks; then
|
|
||||||
log_msg "Rspamd hit error limit"
|
|
||||||
echo rspamd-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned rspamd_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
|
|
||||||
(
|
(
|
||||||
while true; do
|
while true; do
|
||||||
if ! ratelimit_checks; then
|
if ! ratelimit_checks; then
|
||||||
@@ -1036,20 +503,6 @@ PID=$!
|
|||||||
echo "Spawned cert_checks with PID ${PID}"
|
echo "Spawned cert_checks with PID ${PID}"
|
||||||
BACKGROUND_TASKS+=(${PID})
|
BACKGROUND_TASKS+=(${PID})
|
||||||
|
|
||||||
if [[ "${SKIP_OLEFY}" =~ ^([nN][oO]|[nN])+$ ]]; then
|
|
||||||
(
|
|
||||||
while true; do
|
|
||||||
if ! olefy_checks; then
|
|
||||||
log_msg "Olefy hit error limit"
|
|
||||||
echo olefy-mailcow > /tmp/com_pipe
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
) &
|
|
||||||
PID=$!
|
|
||||||
echo "Spawned olefy_checks with PID ${PID}"
|
|
||||||
BACKGROUND_TASKS+=(${PID})
|
|
||||||
fi
|
|
||||||
|
|
||||||
(
|
(
|
||||||
while true; do
|
while true; do
|
||||||
if ! acme_checks; then
|
if ! acme_checks; then
|
||||||
@@ -1075,15 +528,19 @@ while true; do
|
|||||||
done
|
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 true; do
|
||||||
while nc -z dockerapi 443; do
|
while ping_bus; do
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
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[*]}
|
kill -STOP ${BACKGROUND_TASKS[*]}
|
||||||
until nc -z dockerapi 443; do
|
until ping_bus; do
|
||||||
sleep 3
|
sleep 3
|
||||||
done
|
done
|
||||||
kill -CONT ${BACKGROUND_TASKS[*]}
|
kill -CONT ${BACKGROUND_TASKS[*]}
|
||||||
@@ -1143,24 +600,33 @@ while true; do
|
|||||||
elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then
|
elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then
|
||||||
kill -STOP ${BACKGROUND_TASKS[*]}
|
kill -STOP ${BACKGROUND_TASKS[*]}
|
||||||
sleep 10
|
sleep 10
|
||||||
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")
|
# "<service>-mailcow|<node>" restarts a single replica; bare "<service>-mailcow"
|
||||||
if [[ ! -z ${CONTAINER_ID} ]]; then
|
# broadcasts the restart to every replica of the service.
|
||||||
if [[ "${com_pipe_answer}" == "php-fpm-mailcow" ]]; then
|
AGENT_NODE=""
|
||||||
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)
|
AGENT_SVC="${com_pipe_answer%-mailcow}"
|
||||||
fi
|
if [[ "${com_pipe_answer}" == *"|"* ]]; then
|
||||||
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)))
|
AGENT_NODE="${com_pipe_answer#*|}"
|
||||||
if [ ${S_RUNNING} -lt 360 ]; then
|
AGENT_SVC="${com_pipe_answer%|*}"
|
||||||
log_msg "Container is running for less than 360 seconds, skipping action..."
|
AGENT_SVC="${AGENT_SVC%-mailcow}"
|
||||||
elif [[ ! -z ${HAS_INITDB} ]]; then
|
fi
|
||||||
log_msg "Database is being initialized by php-fpm-mailcow, not restarting but delaying checks for a minute..."
|
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)
|
||||||
sleep 60
|
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
|
else
|
||||||
log_msg "Sending restart command to ${CONTAINER_ID}..."
|
log_msg "Sending restart broadcast to ${AGENT_SVC} via control bus..."
|
||||||
curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
|
mailcow-agent-cli send "${AGENT_SVC}" restart >/dev/null || true
|
||||||
notify_error "${com_pipe_answer}"
|
|
||||||
log_msg "Wait for restarted container to settle and continue watching..."
|
|
||||||
sleep 35
|
|
||||||
fi
|
fi
|
||||||
|
notify_error "${com_pipe_answer}"
|
||||||
|
log_msg "Wait for restarted container to settle and continue watching..."
|
||||||
|
sleep 35
|
||||||
fi
|
fi
|
||||||
kill -CONT ${BACKGROUND_TASKS[*]}
|
kill -CONT ${BACKGROUND_TASKS[*]}
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
function auth_password_verify(request, password)
|
function auth_password_verify(request, password)
|
||||||
request.domain = request.auth_user:match("@(.+)") or nil
|
|
||||||
if request.domain == nil then
|
if request.domain == nil then
|
||||||
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
|
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
|
||||||
end
|
end
|
||||||
@@ -10,10 +9,10 @@ function auth_password_verify(request, password)
|
|||||||
https.TIMEOUT = 30
|
https.TIMEOUT = 30
|
||||||
|
|
||||||
local req = {
|
local req = {
|
||||||
username = request.auth_user,
|
username = request.user,
|
||||||
password = password,
|
password = password,
|
||||||
real_rip = request.remote_ip,
|
real_rip = request.real_rip,
|
||||||
service = request.protocol
|
service = request.service
|
||||||
}
|
}
|
||||||
local req_json = json.encode(req)
|
local req_json = json.encode(req)
|
||||||
local res = {}
|
local res = {}
|
||||||
@@ -34,6 +33,7 @@ function auth_password_verify(request, password)
|
|||||||
-- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry,
|
-- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry,
|
||||||
-- even if the TTL has expired. Useful to avoid cache eviction during backend issues.
|
-- even if the TTL has expired. Useful to avoid cache eviction during backend issues.
|
||||||
if c ~= 200 and c ~= 401 then
|
if c ~= 200 and c ~= 401 then
|
||||||
|
dovecot.i_info("HTTP request failed with " .. c .. " for user " .. request.user)
|
||||||
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
|
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ function auth_password_verify(request, password)
|
|||||||
end
|
end
|
||||||
|
|
||||||
if response_json.success == true then
|
if response_json.success == true then
|
||||||
return dovecot.auth.PASSDB_RESULT_OK, { msg = "" }
|
return dovecot.auth.PASSDB_RESULT_OK, ""
|
||||||
end
|
end
|
||||||
|
|
||||||
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Failed to authenticate"
|
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Failed to authenticate"
|
||||||
@@ -55,7 +55,3 @@ end
|
|||||||
function auth_passdb_lookup(req)
|
function auth_passdb_lookup(req)
|
||||||
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, ""
|
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, ""
|
||||||
end
|
end
|
||||||
|
|
||||||
function auth_passdb_get_cache_key()
|
|
||||||
return "%{protocol}:%{user | username}\t:%{password}"
|
|
||||||
end
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/05-core.conf
|
|
||||||
# Core, single-line settings that don't fit elsewhere.
|
|
||||||
recipient_delimiter = +
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/10-logging.conf
|
|
||||||
# Logging and debug.
|
|
||||||
#mail_debug = yes
|
|
||||||
#auth_debug = yes
|
|
||||||
#log_debug = category=fts-flatcurve
|
|
||||||
log_path = syslog
|
|
||||||
log_timestamp = "%Y-%m-%d %H:%M:%S "
|
|
||||||
login_log_format_elements = "user=<%{user}> method=%{mechanism} rip=%{remote_ip} lip=%{local_ip} mpid=%{mail_pid} %{secured} session=<%{session}>"
|
|
||||||
|
|
||||||
# Mail event logging.
|
|
||||||
mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
|
|
||||||
mail_log_fields = uid box msgid size
|
|
||||||
mail_log_cached_only = yes
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/10-mail.conf
|
|
||||||
# Mail storage paths and core mail settings.
|
|
||||||
mail_home = /var/vmail/%{user | domain }/%{user | username }
|
|
||||||
mail_driver = maildir
|
|
||||||
mail_path = ~/Maildir
|
|
||||||
mail_index_path = /var/vmail_index/%{user}
|
|
||||||
mail_plugins = </etc/dovecot/mail_plugins
|
|
||||||
mail_shared_explicit_inbox = yes
|
|
||||||
mailbox_list_storage_escape_char = "\\"
|
|
||||||
mail_prefetch_count = 30
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/10-ssl.conf
|
|
||||||
# TLS/SSL settings.
|
|
||||||
ssl_min_protocol = TLSv1.2
|
|
||||||
ssl_cipher_list = ALL:!ADH:!LOW:!SSLv2:!SSLv3:!EXP:!aNULL:!eNULL:!3DES:!MD5:!PSK:!DSS:!RC4:!SEED:!IDEA:+HIGH:+MEDIUM
|
|
||||||
ssl_options = no_ticket
|
|
||||||
#ssl_dh_parameters_length = 2048
|
|
||||||
|
|
||||||
ssl_server {
|
|
||||||
prefer_ciphers = server
|
|
||||||
dh_file = /etc/ssl/mail/dhparams.pem
|
|
||||||
cert_file = /etc/ssl/mail/cert.pem
|
|
||||||
key_file = /etc/ssl/mail/key.pem
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/11-sql.conf
|
|
||||||
# Default SQL driver used by SQL-based dicts/userdb.
|
|
||||||
sql_driver = mysql
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# Autogenerated by mailcow - DO NOT TOUCH!
|
|
||||||
mysql /var/run/mysqld/mysqld.sock {
|
|
||||||
dbname=mailcow
|
|
||||||
user=mailcow
|
|
||||||
password=D8O9BIivJc7Pb2VCfpAeLbAzUOZ0
|
|
||||||
|
|
||||||
ssl = no
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/12-storage-attachments.conf
|
|
||||||
# External attachment storage.
|
|
||||||
fs mail_ext_attachment {
|
|
||||||
fs_driver = posix
|
|
||||||
mail_ext_attachment_path = /var/attachments
|
|
||||||
mail_ext_attachment_min_size = 128k
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/15-performance.conf
|
|
||||||
# Performance and mailbox tuning.
|
|
||||||
# Enable only when you do not manually touch cur/.
|
|
||||||
maildir_very_dirty_syncs = yes
|
|
||||||
|
|
||||||
# NFS examples | Only modify if using NFS!:
|
|
||||||
#mm ap_disable = yes
|
|
||||||
#mail_fsync = always
|
|
||||||
#mail_nfs_index = yes
|
|
||||||
#mail_nfs_storage = yes
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/20-auth.conf
|
|
||||||
# Authentication mechanisms, master/user separation, passdb chain, auth cache.
|
|
||||||
auth_mechanisms = plain login
|
|
||||||
auth_allow_cleartext = yes
|
|
||||||
auth_master_user_separator = *
|
|
||||||
|
|
||||||
auth_cache_verify_password_with_worker = yes
|
|
||||||
auth_cache_negative_ttl = 60s
|
|
||||||
auth_cache_ttl = 300s
|
|
||||||
auth_cache_size = 10M
|
|
||||||
auth_verbose_passwords = sha1:6
|
|
||||||
|
|
||||||
# 1) Lua password verification (blocking, return mapping).
|
|
||||||
passdb lua {
|
|
||||||
driver = lua
|
|
||||||
lua_file = /etc/dovecot/auth/passwd-verify.lua
|
|
||||||
lua_settings {
|
|
||||||
blocking=yes
|
|
||||||
result_success = return-ok
|
|
||||||
result_failure = continue
|
|
||||||
result_internalfail = continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# 2) Master password for master user logins.
|
|
||||||
passdb master {
|
|
||||||
driver = passwd-file
|
|
||||||
passwd_file_path = /etc/dovecot/dovecot-master.passwd
|
|
||||||
master = yes
|
|
||||||
skip = authenticated
|
|
||||||
}
|
|
||||||
|
|
||||||
# 3) Mandatory return layer: empty Lua (e.g. for forced reset).
|
|
||||||
passdb empty-lua {
|
|
||||||
driver = lua
|
|
||||||
lua_file = /etc/dovecot/auth/passwd-verify.lua
|
|
||||||
lua_settings {
|
|
||||||
blocking = yes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/20-userdb.conf
|
|
||||||
# User database chain.
|
|
||||||
userdb passwd {
|
|
||||||
driver = passwd-file
|
|
||||||
passwd_file_path = /etc/dovecot/dovecot-master.userdb
|
|
||||||
}
|
|
||||||
|
|
||||||
userdb sql {
|
|
||||||
!include /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
|
|
||||||
skip = found
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/25-services.conf
|
|
||||||
# All service listeners and workers.
|
|
||||||
|
|
||||||
# doveadm remote admin
|
|
||||||
# Set doveadm_password in extra.conf.
|
|
||||||
service doveadm {
|
|
||||||
inet_listener doveadm {
|
|
||||||
port = 12345
|
|
||||||
}
|
|
||||||
vsz_limit = 2048 MB
|
|
||||||
}
|
|
||||||
|
|
||||||
# dict
|
|
||||||
service dict {
|
|
||||||
unix_listener dict {
|
|
||||||
mode = 0660
|
|
||||||
user = vmail
|
|
||||||
group = vmail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# log
|
|
||||||
service log {
|
|
||||||
user = dovenull
|
|
||||||
}
|
|
||||||
|
|
||||||
# config socket
|
|
||||||
service config {
|
|
||||||
unix_listener config {
|
|
||||||
user = root
|
|
||||||
group = vmail
|
|
||||||
mode = 0660
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# anvil socket
|
|
||||||
service anvil {
|
|
||||||
unix_listener anvil {
|
|
||||||
user = vmail
|
|
||||||
group = vmail
|
|
||||||
mode = 0660
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# auth sockets and inet
|
|
||||||
service auth {
|
|
||||||
inet_listener auth-inet {
|
|
||||||
port = 10001
|
|
||||||
}
|
|
||||||
unix_listener auth-master {
|
|
||||||
mode = 0600
|
|
||||||
user = vmail
|
|
||||||
}
|
|
||||||
unix_listener auth-userdb {
|
|
||||||
mode = 0600
|
|
||||||
user = vmail
|
|
||||||
}
|
|
||||||
vsz_limit = 2G
|
|
||||||
}
|
|
||||||
|
|
||||||
# managesieve login
|
|
||||||
service managesieve-login {
|
|
||||||
inet_listener sieve {
|
|
||||||
port = 4190
|
|
||||||
}
|
|
||||||
inet_listener sieve_haproxy {
|
|
||||||
port = 14190
|
|
||||||
haproxy = yes
|
|
||||||
}
|
|
||||||
service_restart_request_count = 1
|
|
||||||
process_min_avail = 2
|
|
||||||
vsz_limit = 1G
|
|
||||||
}
|
|
||||||
|
|
||||||
# imap login
|
|
||||||
service imap-login {
|
|
||||||
service_restart_request_count = 1
|
|
||||||
process_min_avail = 2
|
|
||||||
process_limit = 10000
|
|
||||||
vsz_limit = 1G
|
|
||||||
user = dovenull
|
|
||||||
inet_listener imap_haproxy {
|
|
||||||
port = 10143
|
|
||||||
haproxy = yes
|
|
||||||
}
|
|
||||||
inet_listener imaps_haproxy {
|
|
||||||
port = 10993
|
|
||||||
ssl = yes
|
|
||||||
haproxy = yes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# pop3 login
|
|
||||||
service pop3-login {
|
|
||||||
service_restart_request_count = 1
|
|
||||||
process_min_avail = 1
|
|
||||||
vsz_limit = 1G
|
|
||||||
inet_listener pop3_haproxy {
|
|
||||||
port = 10110
|
|
||||||
haproxy = yes
|
|
||||||
}
|
|
||||||
inet_listener pop3s_haproxy {
|
|
||||||
port = 10995
|
|
||||||
ssl = yes
|
|
||||||
haproxy = yes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# imap worker
|
|
||||||
service imap {
|
|
||||||
executable = imap
|
|
||||||
user = vmail
|
|
||||||
vsz_limit = 1G
|
|
||||||
}
|
|
||||||
|
|
||||||
# managesieve worker
|
|
||||||
service managesieve {
|
|
||||||
process_limit = 256
|
|
||||||
}
|
|
||||||
|
|
||||||
# lmtp
|
|
||||||
service lmtp {
|
|
||||||
inet_listener lmtp-inet {
|
|
||||||
port = 24
|
|
||||||
}
|
|
||||||
user = vmail
|
|
||||||
}
|
|
||||||
|
|
||||||
# quota warning hook
|
|
||||||
service quota-warning {
|
|
||||||
executable = script /usr/local/bin/quota_notify.py
|
|
||||||
user = vmail
|
|
||||||
unix_listener quota-warning {
|
|
||||||
user = vmail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# stats
|
|
||||||
service stats {
|
|
||||||
unix_listener stats-writer {
|
|
||||||
mode = 0660
|
|
||||||
user = vmail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/30-protocols.conf
|
|
||||||
# IMAP protocol specifics.
|
|
||||||
protocol imap {
|
|
||||||
mail_plugins = </etc/dovecot/mail_plugins_imap
|
|
||||||
imap_metadata = yes
|
|
||||||
}
|
|
||||||
|
|
||||||
# LMTP protocol specifics.
|
|
||||||
protocol lmtp {
|
|
||||||
mail_plugins = </etc/dovecot/mail_plugins_lmtp
|
|
||||||
auth_socket_path = /var/run/dovecot/auth-master
|
|
||||||
}
|
|
||||||
|
|
||||||
# ManageSieve protocol specifics.
|
|
||||||
protocol sieve {
|
|
||||||
managesieve_logout_format = bytes=%i/%o
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# mailcow FTS Flatcurve Settings, change them as you like.
|
|
||||||
|
|
||||||
# Maximum term length can be set via the 'maxlen' argument (maxlen is
|
|
||||||
# specified in bytes, not number of UTF-8 characters)
|
|
||||||
language_tokenizer_address_token_maxlen = 100
|
|
||||||
language_tokenizer_generic_algorithm = simple
|
|
||||||
language_tokenizer_generic_token_maxlen = 30
|
|
||||||
|
|
||||||
# These are not flatcurve settings, but required for Dovecot FTS. See
|
|
||||||
# Dovecot FTS Configuration link above for further information.
|
|
||||||
language en {
|
|
||||||
default = yes
|
|
||||||
language_filters = lowercase snowball english-possessive stopwords
|
|
||||||
}
|
|
||||||
|
|
||||||
language de {
|
|
||||||
language_filters = lowercase snowball stopwords
|
|
||||||
}
|
|
||||||
|
|
||||||
language es {
|
|
||||||
language_filters = lowercase snowball stopwords
|
|
||||||
}
|
|
||||||
|
|
||||||
language_tokenizers = generic email-address
|
|
||||||
|
|
||||||
fts_search_timeout = 300s
|
|
||||||
|
|
||||||
fts_autoindex = yes
|
|
||||||
# Tweak this setting if you only want to ensure big and frequent folders are indexed, not all.
|
|
||||||
fts_autoindex_max_recent_msgs = 20
|
|
||||||
fts flatcurve {
|
|
||||||
substring_search = no
|
|
||||||
}
|
|
||||||
|
|
||||||
### THIS PART WILL BE CHANGED BY MODIFYING mailcow.conf AUTOMATICALLY DURING RUNTIME! ###
|
|
||||||
|
|
||||||
service indexer-worker {
|
|
||||||
# Max amount of simultaniously running indexer jobs.
|
|
||||||
process_limit=1
|
|
||||||
|
|
||||||
# Max amount of RAM used by EACH indexer process.
|
|
||||||
vsz_limit=128 MB
|
|
||||||
}
|
|
||||||
|
|
||||||
### THIS PART WILL BE CHANGED BY MODIFYING mailcow.conf AUTOMATICALLY DURING RUNTIME! ###
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/40-acl.conf
|
|
||||||
# ACL and shared mailboxes.
|
|
||||||
imap_acl_allow_anyone = </etc/dovecot/acl_anyone
|
|
||||||
|
|
||||||
acl_sharing_map {
|
|
||||||
dict file {
|
|
||||||
path = /var/vmail/shared-mailboxes.db
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
acl_driver = vfile
|
|
||||||
acl_user = %{user}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/40-attributes.conf
|
|
||||||
# User/mail attributes.
|
|
||||||
mail_attribute {
|
|
||||||
dict file {
|
|
||||||
path = /etc/dovecot/dovecot-attributes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/50-quota.conf
|
|
||||||
# Quota configuration and notifications.
|
|
||||||
quota "User quota" {
|
|
||||||
driver = count
|
|
||||||
|
|
||||||
warning warn-95 {
|
|
||||||
quota_storage_percentage = 95
|
|
||||||
execute quota-warning {
|
|
||||||
args = 95 %{user}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
warning warn-80 {
|
|
||||||
quota_storage_percentage = 80
|
|
||||||
execute quota-warning {
|
|
||||||
args = 80 %{user}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
quota_clone {
|
|
||||||
dict proxy {
|
|
||||||
name = mysql_quota
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/60-sieve-pipeline.conf
|
|
||||||
# Complete Sieve pipeline: personal/global scripts, plugins, limits, training.
|
|
||||||
|
|
||||||
# Global before/after (file and dict)
|
|
||||||
sieve_script before {
|
|
||||||
type = before
|
|
||||||
driver = file
|
|
||||||
path = /var/vmail/sieve/global_sieve_before.sieve
|
|
||||||
}
|
|
||||||
|
|
||||||
sieve_script before2 {
|
|
||||||
type = before
|
|
||||||
driver = dict
|
|
||||||
name = active
|
|
||||||
dict proxy {
|
|
||||||
name = sieve_before
|
|
||||||
}
|
|
||||||
bin_path = /var/vmail/sieve_before_bindir/%{user}
|
|
||||||
}
|
|
||||||
|
|
||||||
sieve_script after {
|
|
||||||
type = after
|
|
||||||
driver = file
|
|
||||||
path = /var/vmail/sieve/global_sieve_after.sieve
|
|
||||||
}
|
|
||||||
|
|
||||||
sieve_script after2 {
|
|
||||||
type = after
|
|
||||||
driver = dict
|
|
||||||
name = active
|
|
||||||
dict proxy {
|
|
||||||
name = sieve_after
|
|
||||||
}
|
|
||||||
bin_path = /var/vmail/sieve_after_bindir/%{user}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Personal scripts
|
|
||||||
sieve_script personal {
|
|
||||||
type = personal
|
|
||||||
driver = file
|
|
||||||
path = ~/sieve
|
|
||||||
active_path = ~/.dovecot.sieve
|
|
||||||
}
|
|
||||||
|
|
||||||
# Plugins and behavior
|
|
||||||
sieve_plugins = sieve_imapsieve sieve_extprograms
|
|
||||||
sieve_vacation_send_from_recipient = yes
|
|
||||||
sieve_redirect_envelope_from = recipient
|
|
||||||
|
|
||||||
# IMAPSieve training
|
|
||||||
imapsieve_from Junk {
|
|
||||||
sieve_script ham {
|
|
||||||
type = before
|
|
||||||
cause = copy
|
|
||||||
path = /usr/lib/dovecot/sieve/report-ham.sieve
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mailbox Junk {
|
|
||||||
sieve_script spam {
|
|
||||||
type = before
|
|
||||||
cause = copy
|
|
||||||
path = /usr/lib/dovecot/sieve/report-spam.sieve
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extprograms and extensions
|
|
||||||
sieve_pipe_bin_dir = /usr/lib/dovecot/sieve
|
|
||||||
sieve_plugins {
|
|
||||||
sieve_extprograms = yes
|
|
||||||
}
|
|
||||||
sieve_global_extensions {
|
|
||||||
vnd.dovecot.pipe = yes
|
|
||||||
vnd.dovecot.execute = yes
|
|
||||||
}
|
|
||||||
|
|
||||||
# Limits and duplicate handling
|
|
||||||
sieve_max_script_size = 1M
|
|
||||||
sieve_max_redirects = 100
|
|
||||||
sieve_max_actions = 101
|
|
||||||
sieve_quota_script_count = 0
|
|
||||||
sieve_quota_storage_size = 0
|
|
||||||
sieve_vacation_min_period = 5s
|
|
||||||
sieve_vacation_max_period = 365d
|
|
||||||
sieve_vacation_default_period = 60s
|
|
||||||
sieve_duplicate_default_period = 1m
|
|
||||||
sieve_duplicate_max_period = 7d
|
|
||||||
|
|
||||||
sieve_extensions {
|
|
||||||
vacation-seconds = yes
|
|
||||||
editheader = yes
|
|
||||||
}
|
|
||||||
|
|
||||||
# pipe sockets in /var/run/dovecot/sieve-pipe
|
|
||||||
sieve_pipe_socket_dir = sieve-pipe
|
|
||||||
|
|
||||||
# execute sockets in /var/run/dovecot/sieve-execute
|
|
||||||
sieve_execute_socket_dir = sieve-execute
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/70-crypto.conf
|
|
||||||
# Global mail-crypt keys.
|
|
||||||
crypt_global_private_key global {
|
|
||||||
crypt_private_key_file = /mail_crypt/ecprivkey.pem
|
|
||||||
}
|
|
||||||
crypt_global_public_key_file = /mail_crypt/ecpubkey.pem
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/80-compress.conf
|
|
||||||
# Compression settings.
|
|
||||||
mail_compress_write_method = lz4
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/90-dict.conf
|
|
||||||
# Dict declarations and SQL bindings.
|
|
||||||
dict_server {
|
|
||||||
dict sieve_after {
|
|
||||||
driver = sql
|
|
||||||
!include /etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf
|
|
||||||
}
|
|
||||||
|
|
||||||
dict sieve_before {
|
|
||||||
driver = sql
|
|
||||||
!include /etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf
|
|
||||||
}
|
|
||||||
|
|
||||||
dict mysql_quota {
|
|
||||||
driver = sql
|
|
||||||
!include /etc/dovecot/sql/dovecot-dict-sql-quota.conf
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/90-limits.conf
|
|
||||||
# Connection and memory limits; doveadm port.
|
|
||||||
mail_max_userip_connections = 500
|
|
||||||
imap_max_line_length = 2 M
|
|
||||||
default_client_limit = 10400
|
|
||||||
default_vsz_limit = 1024 M
|
|
||||||
doveadm_port = 12345
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# /etc/dovecot/conf.d/99-includes.conf
|
|
||||||
# Late includes and site-specific bits.
|
|
||||||
|
|
||||||
# Mailbox layout includes (if used)
|
|
||||||
!include /etc/dovecot/dovecot.folders.conf
|
|
||||||
|
|
||||||
# Optional replication
|
|
||||||
!include_try /etc/dovecot/mail_replica.conf
|
|
||||||
|
|
||||||
# Existing includes you already had
|
|
||||||
!include_try /etc/dovecot/sni.conf
|
|
||||||
!include_try /etc/dovecot/sogo_trusted_ip.conf
|
|
||||||
!include_try /etc/dovecot/shared_namespace.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/fts.conf
|
|
||||||
|
|
||||||
# Remote auth override
|
|
||||||
remote 127.0.0.1 {
|
|
||||||
auth_allow_cleartext = yes
|
|
||||||
}
|
|
||||||
|
|
||||||
# Outbound submission target
|
|
||||||
submission_host = postfix:588
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# mailcow FTS Flatcurve Settings, change them as you like.
|
||||||
|
plugin {
|
||||||
|
fts_autoindex = yes
|
||||||
|
fts_autoindex_exclude = \Junk
|
||||||
|
fts_autoindex_exclude2 = \Trash
|
||||||
|
# Tweak this setting if you only want to ensure big and frequent folders are indexed, not all.
|
||||||
|
fts_autoindex_max_recent_msgs = 20
|
||||||
|
fts = flatcurve
|
||||||
|
|
||||||
|
# Maximum term length can be set via the 'maxlen' argument (maxlen is
|
||||||
|
# specified in bytes, not number of UTF-8 characters)
|
||||||
|
fts_tokenizer_email_address = maxlen=100
|
||||||
|
fts_tokenizer_generic = algorithm=simple maxlen=30
|
||||||
|
|
||||||
|
# These are not flatcurve settings, but required for Dovecot FTS. See
|
||||||
|
# Dovecot FTS Configuration link above for further information.
|
||||||
|
fts_languages = en es de
|
||||||
|
fts_tokenizers = generic email-address
|
||||||
|
|
||||||
|
# OPTIONAL: Recommended default FTS core configuration
|
||||||
|
fts_filters = normalizer-icu snowball stopwords
|
||||||
|
fts_filters_en = lowercase snowball english-possessive stopwords
|
||||||
|
|
||||||
|
fts_index_timeout = 300s
|
||||||
|
}
|
||||||
|
|
||||||
|
### THIS PART WILL BE CHANGED BY MODIFYING mailcow.conf AUTOMATICALLY DURING RUNTIME! ###
|
||||||
|
|
||||||
|
service indexer-worker {
|
||||||
|
# Max amount of simultaniously running indexer jobs.
|
||||||
|
process_limit=1
|
||||||
|
|
||||||
|
# Max amount of RAM used by EACH indexer process.
|
||||||
|
vsz_limit=128 MB
|
||||||
|
}
|
||||||
|
|
||||||
|
### THIS PART WILL BE CHANGED BY MODIFYING mailcow.conf AUTOMATICALLY DURING RUNTIME! ###
|
||||||
+306
-29
@@ -1,34 +1,311 @@
|
|||||||
# /etc/dovecot/dovecot.conf
|
# --------------------------------------------------------------------------
|
||||||
# Base file kept minimal. All real config lives under conf.d/.
|
# Please create a file "extra.conf" for persistent overrides to dovecot.conf
|
||||||
dovecot_config_version = 2.4.0
|
# --------------------------------------------------------------------------
|
||||||
dovecot_storage_version = 2.4.0
|
# LDAP example:
|
||||||
|
#passdb {
|
||||||
|
# args = /etc/dovecot/ldap/passdb.conf
|
||||||
|
# driver = ldap
|
||||||
|
#}
|
||||||
|
|
||||||
listen = *,[::]
|
auth_mechanisms = plain login
|
||||||
|
#mail_debug = yes
|
||||||
|
#auth_debug = yes
|
||||||
|
#log_debug = category=fts-flatcurve # Activate Logging for Flatcurve FTS Searchings
|
||||||
|
log_path = syslog
|
||||||
|
disable_plaintext_auth = yes
|
||||||
|
# Uncomment on NFS share
|
||||||
|
#mmap_disable = yes
|
||||||
|
#mail_fsync = always
|
||||||
|
#mail_nfs_index = yes
|
||||||
|
#mail_nfs_storage = yes
|
||||||
|
login_log_format_elements = "user=<%u> method=%m rip=%r lip=%l mpid=%e %c %k"
|
||||||
|
mail_home = /var/vmail/%d/%n
|
||||||
|
mail_location = maildir:~/
|
||||||
|
mail_plugins = </etc/dovecot/mail_plugins
|
||||||
|
mail_attachment_fs = crypt:set_prefix=mail_crypt_global:posix:
|
||||||
|
mail_attachment_dir = /var/attachments
|
||||||
|
mail_attachment_min_size = 128k
|
||||||
|
# Significantly speeds up very large mailboxes, but is only safe to enable if
|
||||||
|
# you do not manually modify the files in the `cur` directories in
|
||||||
|
# mailcowdockerized_vmail-vol-1.
|
||||||
|
# https://docs.mailcow.email/manual-guides/Dovecot/u_e-dovecot-performance/
|
||||||
|
maildir_very_dirty_syncs = yes
|
||||||
|
|
||||||
|
# Dovecot 2.2
|
||||||
|
#ssl_protocols = !SSLv3
|
||||||
|
# Dovecot 2.3
|
||||||
|
ssl_min_protocol = TLSv1.2
|
||||||
|
|
||||||
|
ssl_prefer_server_ciphers = yes
|
||||||
|
ssl_cipher_list = ALL:!ADH:!LOW:!SSLv2:!SSLv3:!EXP:!aNULL:!eNULL:!3DES:!MD5:!PSK:!DSS:!RC4:!SEED:!IDEA:+HIGH:+MEDIUM
|
||||||
|
|
||||||
|
# Default in Dovecot 2.3
|
||||||
|
ssl_options = no_compression no_ticket
|
||||||
|
|
||||||
|
# New in Dovecot 2.3
|
||||||
|
ssl_dh = </etc/ssl/mail/dhparams.pem
|
||||||
|
# Dovecot 2.2
|
||||||
|
#ssl_dh_parameters_length = 2048
|
||||||
|
log_timestamp = "%Y-%m-%d %H:%M:%S "
|
||||||
|
recipient_delimiter = +
|
||||||
|
auth_master_user_separator = *
|
||||||
|
mail_shared_explicit_inbox = yes
|
||||||
|
mail_prefetch_count = 30
|
||||||
|
passdb {
|
||||||
|
driver = lua
|
||||||
|
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
|
||||||
|
result_success = return-ok
|
||||||
|
result_failure = continue
|
||||||
|
result_internalfail = continue
|
||||||
|
}
|
||||||
|
# try a master passwd
|
||||||
|
passdb {
|
||||||
|
driver = passwd-file
|
||||||
|
args = /etc/dovecot/dovecot-master.passwd
|
||||||
|
master = yes
|
||||||
|
skip = authenticated
|
||||||
|
}
|
||||||
|
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
|
||||||
|
# a return of the following passdb is mandatory
|
||||||
|
passdb {
|
||||||
|
driver = lua
|
||||||
|
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes
|
||||||
|
}
|
||||||
|
# Set doveadm_password=your-secret-password in data/conf/dovecot/extra.conf (create if missing)
|
||||||
|
service doveadm {
|
||||||
|
inet_listener {
|
||||||
|
port = 12345
|
||||||
|
}
|
||||||
|
vsz_limit=2048 MB
|
||||||
|
}
|
||||||
|
!include /etc/dovecot/dovecot.folders.conf
|
||||||
protocols = imap sieve lmtp pop3
|
protocols = imap sieve lmtp pop3
|
||||||
|
service dict {
|
||||||
|
unix_listener dict {
|
||||||
|
mode = 0660
|
||||||
|
user = vmail
|
||||||
|
group = vmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service log {
|
||||||
|
user = dovenull
|
||||||
|
}
|
||||||
|
service config {
|
||||||
|
unix_listener config {
|
||||||
|
user = root
|
||||||
|
group = vmail
|
||||||
|
mode = 0660
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service auth {
|
||||||
|
inet_listener auth-inet {
|
||||||
|
port = 10001
|
||||||
|
}
|
||||||
|
unix_listener auth-master {
|
||||||
|
mode = 0600
|
||||||
|
user = vmail
|
||||||
|
}
|
||||||
|
unix_listener auth-userdb {
|
||||||
|
mode = 0600
|
||||||
|
user = vmail
|
||||||
|
}
|
||||||
|
vsz_limit = 2G
|
||||||
|
}
|
||||||
|
service managesieve-login {
|
||||||
|
inet_listener sieve {
|
||||||
|
port = 4190
|
||||||
|
}
|
||||||
|
inet_listener sieve_haproxy {
|
||||||
|
port = 14190
|
||||||
|
haproxy = yes
|
||||||
|
}
|
||||||
|
service_count = 1
|
||||||
|
process_min_avail = 2
|
||||||
|
vsz_limit = 1G
|
||||||
|
}
|
||||||
|
service imap-login {
|
||||||
|
service_count = 1
|
||||||
|
process_min_avail = 2
|
||||||
|
process_limit = 10000
|
||||||
|
vsz_limit = 1G
|
||||||
|
user = dovenull
|
||||||
|
inet_listener imap_haproxy {
|
||||||
|
port = 10143
|
||||||
|
haproxy = yes
|
||||||
|
}
|
||||||
|
inet_listener imaps_haproxy {
|
||||||
|
port = 10993
|
||||||
|
ssl = yes
|
||||||
|
haproxy = yes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service pop3-login {
|
||||||
|
service_count = 1
|
||||||
|
process_min_avail = 1
|
||||||
|
vsz_limit = 1G
|
||||||
|
inet_listener pop3_haproxy {
|
||||||
|
port = 10110
|
||||||
|
haproxy = yes
|
||||||
|
}
|
||||||
|
inet_listener pop3s_haproxy {
|
||||||
|
port = 10995
|
||||||
|
ssl = yes
|
||||||
|
haproxy = yes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service imap {
|
||||||
|
executable = imap
|
||||||
|
user = vmail
|
||||||
|
vsz_limit = 1G
|
||||||
|
}
|
||||||
|
service managesieve {
|
||||||
|
process_limit = 256
|
||||||
|
}
|
||||||
|
service lmtp {
|
||||||
|
inet_listener lmtp-inet {
|
||||||
|
port = 24
|
||||||
|
}
|
||||||
|
user = vmail
|
||||||
|
}
|
||||||
|
listen = *,[::]
|
||||||
|
ssl_cert = </etc/ssl/mail/cert.pem
|
||||||
|
ssl_key = </etc/ssl/mail/key.pem
|
||||||
|
userdb {
|
||||||
|
driver = passwd-file
|
||||||
|
args = /etc/dovecot/dovecot-master.userdb
|
||||||
|
}
|
||||||
|
userdb {
|
||||||
|
args = /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
|
||||||
|
driver = sql
|
||||||
|
skip = found
|
||||||
|
}
|
||||||
|
protocol imap {
|
||||||
|
mail_plugins = </etc/dovecot/mail_plugins_imap
|
||||||
|
imap_metadata = yes
|
||||||
|
}
|
||||||
|
mail_attribute_dict = file:%h/dovecot-attributes
|
||||||
|
protocol lmtp {
|
||||||
|
mail_plugins = </etc/dovecot/mail_plugins_lmtp
|
||||||
|
auth_socket_path = /var/run/dovecot/auth-master
|
||||||
|
}
|
||||||
|
protocol sieve {
|
||||||
|
managesieve_logout_format = bytes=%i/%o
|
||||||
|
}
|
||||||
|
plugin {
|
||||||
|
# Allow "any" or "authenticated" to be used in ACLs
|
||||||
|
acl_anyone = </etc/dovecot/acl_anyone
|
||||||
|
acl_shared_dict = file:/var/vmail/shared-mailboxes.db
|
||||||
|
acl = vfile
|
||||||
|
acl_user = %u
|
||||||
|
quota = dict:Userquota::proxy::sqlquota
|
||||||
|
quota_rule2 = Trash:storage=+100%%
|
||||||
|
sieve = /var/vmail/sieve/%u.sieve
|
||||||
|
sieve_plugins = sieve_imapsieve sieve_extprograms
|
||||||
|
sieve_vacation_send_from_recipient = yes
|
||||||
|
sieve_redirect_envelope_from = recipient
|
||||||
|
# From elsewhere to Spam folder
|
||||||
|
imapsieve_mailbox1_name = Junk
|
||||||
|
imapsieve_mailbox1_causes = COPY
|
||||||
|
imapsieve_mailbox1_before = file:/usr/lib/dovecot/sieve/report-spam.sieve
|
||||||
|
# END
|
||||||
|
# From Spam folder to elsewhere
|
||||||
|
imapsieve_mailbox2_name = *
|
||||||
|
imapsieve_mailbox2_from = Junk
|
||||||
|
imapsieve_mailbox2_causes = COPY
|
||||||
|
imapsieve_mailbox2_before = file:/usr/lib/dovecot/sieve/report-ham.sieve
|
||||||
|
# END
|
||||||
|
master_user = %u
|
||||||
|
quota_warning = storage=95%% quota-warning 95 %u
|
||||||
|
quota_warning2 = storage=80%% quota-warning 80 %u
|
||||||
|
sieve_pipe_bin_dir = /usr/lib/dovecot/sieve
|
||||||
|
sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.execute
|
||||||
|
sieve_extensions = +notify +imapflags +vacation-seconds +editheader
|
||||||
|
sieve_max_script_size = 1M
|
||||||
|
sieve_max_redirects = 100
|
||||||
|
sieve_max_actions = 101
|
||||||
|
sieve_quota_max_scripts = 0
|
||||||
|
sieve_quota_max_storage = 0
|
||||||
|
listescape_char = "\\"
|
||||||
|
sieve_vacation_min_period = 5s
|
||||||
|
sieve_vacation_max_period = 0
|
||||||
|
sieve_vacation_default_period = 60s
|
||||||
|
sieve_before = /var/vmail/sieve/global_sieve_before.sieve
|
||||||
|
sieve_before2 = dict:proxy::sieve_before;name=active;bindir=/var/vmail/sieve_before_bindir
|
||||||
|
sieve_after = dict:proxy::sieve_after;name=active;bindir=/var/vmail/sieve_after_bindir
|
||||||
|
sieve_after2 = /var/vmail/sieve/global_sieve_after.sieve
|
||||||
|
sieve_duplicate_default_period = 1m
|
||||||
|
sieve_duplicate_max_period = 7d
|
||||||
|
|
||||||
!include_try /etc/dovecot/conf.d/05-core.conf
|
# -- Global keys
|
||||||
!include_try /etc/dovecot/conf.d/10-logging.conf
|
mail_crypt_global_private_key = </mail_crypt/ecprivkey.pem
|
||||||
!include_try /etc/dovecot/conf.d/10-mail.conf
|
mail_crypt_global_public_key = </mail_crypt/ecpubkey.pem
|
||||||
!include_try /etc/dovecot/conf.d/10-ssl.conf
|
mail_crypt_save_version = 2
|
||||||
!include_try /etc/dovecot/conf.d/11-sql.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/12-mysql.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/12-storage-attachments.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/15-performance.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/20-auth.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/20-userdb.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/25-services.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/30-protocols.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/35-fts.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/40-acl.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/40-attributes.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/50-quota.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/60-sieve-pipeline.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/70-crypto.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/80-compress.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/80-mail-logging.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/90-limits.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/90-dict.conf
|
|
||||||
!include_try /etc/dovecot/conf.d/99-includes.conf
|
|
||||||
|
|
||||||
# Last: local overrides
|
# Enable compression while saving, lz4 Dovecot v2.3.17+
|
||||||
|
zlib_save = lz4
|
||||||
|
|
||||||
|
mail_log_events = delete undelete expunge copy mailbox_delete mailbox_rename
|
||||||
|
mail_log_fields = uid box msgid size
|
||||||
|
mail_log_cached_only = yes
|
||||||
|
|
||||||
|
# Try set mail_replica
|
||||||
|
!include_try /etc/dovecot/mail_replica.conf
|
||||||
|
}
|
||||||
|
service quota-warning {
|
||||||
|
executable = script /usr/local/bin/quota_notify.py
|
||||||
|
# use some unprivileged user for executing the quota warnings
|
||||||
|
user = vmail
|
||||||
|
unix_listener quota-warning {
|
||||||
|
user = vmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dict {
|
||||||
|
sqlquota = mysql:/etc/dovecot/sql/dovecot-dict-sql-quota.conf
|
||||||
|
sieve_after = mysql:/etc/dovecot/sql/dovecot-dict-sql-sieve_after.conf
|
||||||
|
sieve_before = mysql:/etc/dovecot/sql/dovecot-dict-sql-sieve_before.conf
|
||||||
|
}
|
||||||
|
remote 127.0.0.1 {
|
||||||
|
disable_plaintext_auth = no
|
||||||
|
}
|
||||||
|
submission_host = postfix:588
|
||||||
|
mail_max_userip_connections = 500
|
||||||
|
service stats {
|
||||||
|
unix_listener stats-writer {
|
||||||
|
mode = 0660
|
||||||
|
user = vmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
imap_max_line_length = 2 M
|
||||||
|
auth_cache_verify_password_with_worker = yes
|
||||||
|
auth_cache_negative_ttl = 60s
|
||||||
|
auth_cache_ttl = 300s
|
||||||
|
auth_cache_size = 10M
|
||||||
|
auth_verbose_passwords = sha1:6
|
||||||
|
service replicator {
|
||||||
|
process_min_avail = 1
|
||||||
|
}
|
||||||
|
service aggregator {
|
||||||
|
fifo_listener replication-notify-fifo {
|
||||||
|
user = vmail
|
||||||
|
}
|
||||||
|
unix_listener replication-notify {
|
||||||
|
user = vmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service replicator {
|
||||||
|
unix_listener replicator-doveadm {
|
||||||
|
mode = 0666
|
||||||
|
}
|
||||||
|
}
|
||||||
|
replication_max_conns = 10
|
||||||
|
doveadm_port = 12345
|
||||||
|
replication_dsync_parameters = -d -l 30 -U -n INBOX
|
||||||
|
# <Includes>
|
||||||
|
!include_try /etc/dovecot/sni.conf
|
||||||
|
!include_try /etc/dovecot/sogo_trusted_ip.conf
|
||||||
!include_try /etc/dovecot/extra.conf
|
!include_try /etc/dovecot/extra.conf
|
||||||
|
!include_try /etc/dovecot/shared_namespace.conf
|
||||||
|
!include_try /etc/dovecot/conf.d/fts.conf
|
||||||
|
# </Includes>
|
||||||
|
default_client_limit = 10400
|
||||||
|
default_vsz_limit = 1024 M
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
namespace inbox {
|
namespace inbox {
|
||||||
inbox = yes
|
inbox = yes
|
||||||
|
location =
|
||||||
separator = /
|
separator = /
|
||||||
mailbox storage/* {
|
|
||||||
quota_storage_extra = 100M
|
|
||||||
}
|
|
||||||
mailbox "Trash" {
|
mailbox "Trash" {
|
||||||
auto = subscribe
|
auto = subscribe
|
||||||
special_use = \Trash
|
special_use = \Trash
|
||||||
quota_storage_percentage = 100
|
|
||||||
fts_autoindex = no
|
|
||||||
}
|
}
|
||||||
mailbox "Deleted Messages" {
|
mailbox "Deleted Messages" {
|
||||||
special_use = \Trash
|
special_use = \Trash
|
||||||
@@ -199,7 +195,6 @@ namespace inbox {
|
|||||||
mailbox "Junk" {
|
mailbox "Junk" {
|
||||||
auto = subscribe
|
auto = subscribe
|
||||||
special_use = \Junk
|
special_use = \Junk
|
||||||
fts_autoindex = no
|
|
||||||
}
|
}
|
||||||
mailbox "Junk-E-Mail" {
|
mailbox "Junk-E-Mail" {
|
||||||
special_use = \Junk
|
special_use = \Junk
|
||||||
|
|||||||
@@ -261,19 +261,19 @@ location ~* /sogo$ {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /SOGo.woa/WebServerResources/ {
|
location /SOGo.woa/WebServerResources/ {
|
||||||
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
|
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /.woa/WebServerResources/ {
|
location /.woa/WebServerResources/ {
|
||||||
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
|
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /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)$) {
|
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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
# Whitelist generated by Postwhite v3.4 on Sun Mar 1 00:29:01 UTC 2026
|
# Whitelist generated by Postwhite v3.4 on Fri May 1 00:43:37 UTC 2026
|
||||||
# https://github.com/stevejenkins/postwhite/
|
# https://github.com/stevejenkins/postwhite/
|
||||||
# 2174 total rules
|
# 2249 total rules
|
||||||
2a00:1450:4000::/36 permit
|
2a00:1450:4000::/36 permit
|
||||||
|
2a00:1450:4864::/56 permit
|
||||||
2a01:111:f400::/48 permit
|
2a01:111:f400::/48 permit
|
||||||
2a01:111:f403:2800::/53 permit
|
2a01:111:f403:2800::/53 permit
|
||||||
2a01:111:f403:8000::/51 permit
|
2a01:111:f403:8000::/51 permit
|
||||||
@@ -31,8 +32,11 @@
|
|||||||
2a02:a60:0:5::/64 permit
|
2a02:a60:0:5::/64 permit
|
||||||
2a0f:f640::/56 permit
|
2a0f:f640::/56 permit
|
||||||
2c0f:fb50:4000::/36 permit
|
2c0f:fb50:4000::/36 permit
|
||||||
|
2c0f:fb50:4864::/56 permit
|
||||||
|
2.207.151.32/27 permit
|
||||||
2.207.151.53 permit
|
2.207.151.53 permit
|
||||||
2.207.217.30 permit
|
2.207.217.30 permit
|
||||||
|
2.207.223.160/27 permit
|
||||||
3.64.237.68 permit
|
3.64.237.68 permit
|
||||||
3.65.3.180 permit
|
3.65.3.180 permit
|
||||||
3.70.123.177 permit
|
3.70.123.177 permit
|
||||||
@@ -58,8 +62,8 @@
|
|||||||
8.40.222.0/23 permit
|
8.40.222.0/23 permit
|
||||||
8.40.222.250/31 permit
|
8.40.222.250/31 permit
|
||||||
12.130.86.238 permit
|
12.130.86.238 permit
|
||||||
13.107.213.51 permit
|
13.107.213.40 permit
|
||||||
13.107.246.51 permit
|
13.107.246.40 permit
|
||||||
13.108.16.0/20 permit
|
13.108.16.0/20 permit
|
||||||
13.110.208.0/21 permit
|
13.110.208.0/21 permit
|
||||||
13.110.209.0/24 permit
|
13.110.209.0/24 permit
|
||||||
@@ -277,6 +281,7 @@
|
|||||||
50.56.130.221 permit
|
50.56.130.221 permit
|
||||||
50.56.130.222 permit
|
50.56.130.222 permit
|
||||||
50.112.246.219 permit
|
50.112.246.219 permit
|
||||||
|
51.83.17.38 permit
|
||||||
52.1.14.157 permit
|
52.1.14.157 permit
|
||||||
52.5.230.59 permit
|
52.5.230.59 permit
|
||||||
52.6.74.205 permit
|
52.6.74.205 permit
|
||||||
@@ -368,6 +373,7 @@
|
|||||||
64.132.88.0/23 permit
|
64.132.88.0/23 permit
|
||||||
64.132.92.0/24 permit
|
64.132.92.0/24 permit
|
||||||
64.181.194.190 permit
|
64.181.194.190 permit
|
||||||
|
64.181.213.254 permit
|
||||||
64.207.219.7 permit
|
64.207.219.7 permit
|
||||||
64.207.219.8 permit
|
64.207.219.8 permit
|
||||||
64.207.219.9 permit
|
64.207.219.9 permit
|
||||||
@@ -453,7 +459,11 @@
|
|||||||
66.218.75.252/31 permit
|
66.218.75.252/31 permit
|
||||||
66.218.75.254 permit
|
66.218.75.254 permit
|
||||||
66.220.144.128/25 permit
|
66.220.144.128/25 permit
|
||||||
|
66.220.144.178 permit
|
||||||
|
66.220.144.179 permit
|
||||||
66.220.155.0/24 permit
|
66.220.155.0/24 permit
|
||||||
|
66.220.155.178 permit
|
||||||
|
66.220.155.179 permit
|
||||||
66.220.157.0/25 permit
|
66.220.157.0/25 permit
|
||||||
66.231.80.0/20 permit
|
66.231.80.0/20 permit
|
||||||
66.240.227.0/24 permit
|
66.240.227.0/24 permit
|
||||||
@@ -518,6 +528,8 @@
|
|||||||
69.162.98.0/24 permit
|
69.162.98.0/24 permit
|
||||||
69.169.224.0/20 permit
|
69.169.224.0/20 permit
|
||||||
69.171.232.0/24 permit
|
69.171.232.0/24 permit
|
||||||
|
69.171.232.180 permit
|
||||||
|
69.171.232.181 permit
|
||||||
69.171.244.0/23 permit
|
69.171.244.0/23 permit
|
||||||
70.42.149.35 permit
|
70.42.149.35 permit
|
||||||
72.3.185.0/24 permit
|
72.3.185.0/24 permit
|
||||||
@@ -639,6 +651,7 @@
|
|||||||
77.238.189.148/30 permit
|
77.238.189.148/30 permit
|
||||||
79.135.106.0/24 permit
|
79.135.106.0/24 permit
|
||||||
79.135.107.0/24 permit
|
79.135.107.0/24 permit
|
||||||
|
80.225.160.128/25 permit
|
||||||
81.169.146.243 permit
|
81.169.146.243 permit
|
||||||
81.169.146.245 permit
|
81.169.146.245 permit
|
||||||
81.169.146.246 permit
|
81.169.146.246 permit
|
||||||
@@ -657,6 +670,9 @@
|
|||||||
82.165.159.45 permit
|
82.165.159.45 permit
|
||||||
82.165.159.130 permit
|
82.165.159.130 permit
|
||||||
82.165.159.131 permit
|
82.165.159.131 permit
|
||||||
|
84.8.68.0/25 permit
|
||||||
|
84.8.192.128/25 permit
|
||||||
|
84.8.224.128/25 permit
|
||||||
85.9.206.169 permit
|
85.9.206.169 permit
|
||||||
85.9.210.45 permit
|
85.9.210.45 permit
|
||||||
85.158.136.0/21 permit
|
85.158.136.0/21 permit
|
||||||
@@ -1408,6 +1424,20 @@
|
|||||||
129.146.88.28 permit
|
129.146.88.28 permit
|
||||||
129.146.147.105 permit
|
129.146.147.105 permit
|
||||||
129.146.236.58 permit
|
129.146.236.58 permit
|
||||||
|
129.148.135.0/25 permit
|
||||||
|
129.148.148.0/25 permit
|
||||||
|
129.148.164.0/25 permit
|
||||||
|
129.148.180.0/25 permit
|
||||||
|
129.148.215.0/25 permit
|
||||||
|
129.149.6.0/25 permit
|
||||||
|
129.149.22.0/25 permit
|
||||||
|
129.149.38.0/25 permit
|
||||||
|
129.149.52.0/25 permit
|
||||||
|
129.149.68.0/25 permit
|
||||||
|
129.149.84.0/25 permit
|
||||||
|
129.149.100.0/25 permit
|
||||||
|
129.149.118.0/25 permit
|
||||||
|
129.149.126.0/25 permit
|
||||||
129.151.67.221 permit
|
129.151.67.221 permit
|
||||||
129.153.62.216 permit
|
129.153.62.216 permit
|
||||||
129.153.104.71 permit
|
129.153.104.71 permit
|
||||||
@@ -1420,16 +1450,19 @@
|
|||||||
129.159.22.159 permit
|
129.159.22.159 permit
|
||||||
129.159.87.137 permit
|
129.159.87.137 permit
|
||||||
129.213.195.191 permit
|
129.213.195.191 permit
|
||||||
|
130.35.116.0/25 permit
|
||||||
130.61.9.72 permit
|
130.61.9.72 permit
|
||||||
130.162.39.83 permit
|
130.162.39.83 permit
|
||||||
130.248.172.0/24 permit
|
130.248.172.0/24 permit
|
||||||
130.248.173.0/24 permit
|
130.248.173.0/24 permit
|
||||||
|
131.186.12.0/25 permit
|
||||||
131.253.30.0/24 permit
|
131.253.30.0/24 permit
|
||||||
131.253.121.0/26 permit
|
131.253.121.0/26 permit
|
||||||
132.145.13.209 permit
|
132.145.13.209 permit
|
||||||
132.226.26.225 permit
|
132.226.26.225 permit
|
||||||
132.226.49.32 permit
|
132.226.49.32 permit
|
||||||
132.226.56.24 permit
|
132.226.56.24 permit
|
||||||
|
134.98.248.128/25 permit
|
||||||
134.128.64.0/19 permit
|
134.128.64.0/19 permit
|
||||||
134.128.96.0/19 permit
|
134.128.96.0/19 permit
|
||||||
134.170.27.8 permit
|
134.170.27.8 permit
|
||||||
@@ -1460,6 +1493,10 @@
|
|||||||
136.147.182.0/24 permit
|
136.147.182.0/24 permit
|
||||||
136.147.224.0/20 permit
|
136.147.224.0/20 permit
|
||||||
136.179.50.206 permit
|
136.179.50.206 permit
|
||||||
|
136.248.224.128/25 permit
|
||||||
|
136.248.232.128/25 permit
|
||||||
|
138.1.108.0/25 permit
|
||||||
|
138.1.170.0/24 permit
|
||||||
139.60.152.0/22 permit
|
139.60.152.0/22 permit
|
||||||
139.138.35.44 permit
|
139.138.35.44 permit
|
||||||
139.138.46.121 permit
|
139.138.46.121 permit
|
||||||
@@ -1468,6 +1505,7 @@
|
|||||||
139.138.57.55 permit
|
139.138.57.55 permit
|
||||||
139.138.58.119 permit
|
139.138.58.119 permit
|
||||||
139.167.79.86 permit
|
139.167.79.86 permit
|
||||||
|
139.177.108.0/25 permit
|
||||||
139.180.17.0/24 permit
|
139.180.17.0/24 permit
|
||||||
140.238.148.191 permit
|
140.238.148.191 permit
|
||||||
141.148.55.217 permit
|
141.148.55.217 permit
|
||||||
@@ -1506,6 +1544,9 @@
|
|||||||
146.88.28.0/24 permit
|
146.88.28.0/24 permit
|
||||||
146.148.116.76 permit
|
146.148.116.76 permit
|
||||||
147.154.32.0/25 permit
|
147.154.32.0/25 permit
|
||||||
|
147.154.63.0/24 permit
|
||||||
|
147.154.126.0/24 permit
|
||||||
|
147.154.191.0/24 permit
|
||||||
147.243.1.47 permit
|
147.243.1.47 permit
|
||||||
147.243.1.48 permit
|
147.243.1.48 permit
|
||||||
147.243.1.153 permit
|
147.243.1.153 permit
|
||||||
@@ -1517,12 +1558,16 @@
|
|||||||
149.72.234.184 permit
|
149.72.234.184 permit
|
||||||
149.72.248.236 permit
|
149.72.248.236 permit
|
||||||
149.97.173.180 permit
|
149.97.173.180 permit
|
||||||
|
149.118.160.128/25 permit
|
||||||
150.136.21.199 permit
|
150.136.21.199 permit
|
||||||
150.230.98.160 permit
|
150.230.98.160 permit
|
||||||
151.145.38.14 permit
|
151.145.38.14 permit
|
||||||
152.67.105.195 permit
|
152.67.105.195 permit
|
||||||
152.69.200.236 permit
|
152.69.200.236 permit
|
||||||
152.70.155.126 permit
|
152.70.155.126 permit
|
||||||
|
155.248.135.128/25 permit
|
||||||
|
155.248.140.0/25 permit
|
||||||
|
155.248.148.0/25 permit
|
||||||
155.248.208.51 permit
|
155.248.208.51 permit
|
||||||
155.248.220.138 permit
|
155.248.220.138 permit
|
||||||
155.248.234.149 permit
|
155.248.234.149 permit
|
||||||
@@ -1531,10 +1576,14 @@
|
|||||||
157.58.30.128/25 permit
|
157.58.30.128/25 permit
|
||||||
157.58.196.96/29 permit
|
157.58.196.96/29 permit
|
||||||
157.58.249.3 permit
|
157.58.249.3 permit
|
||||||
|
157.137.32.128/25 permit
|
||||||
|
157.137.96.128/25 permit
|
||||||
157.151.208.65 permit
|
157.151.208.65 permit
|
||||||
157.255.1.64/29 permit
|
157.255.1.64/29 permit
|
||||||
158.101.211.207 permit
|
158.101.211.207 permit
|
||||||
158.247.16.0/20 permit
|
158.247.16.0/20 permit
|
||||||
|
158.247.100.0/25 permit
|
||||||
|
159.13.4.0/25 permit
|
||||||
159.92.154.0/24 permit
|
159.92.154.0/24 permit
|
||||||
159.92.155.0/24 permit
|
159.92.155.0/24 permit
|
||||||
159.92.157.0/24 permit
|
159.92.157.0/24 permit
|
||||||
@@ -1568,6 +1617,7 @@
|
|||||||
161.71.64.0/20 permit
|
161.71.64.0/20 permit
|
||||||
162.88.4.0/23 permit
|
162.88.4.0/23 permit
|
||||||
162.88.8.0/24 permit
|
162.88.8.0/24 permit
|
||||||
|
162.88.24.0/23 permit
|
||||||
162.88.24.0/24 permit
|
162.88.24.0/24 permit
|
||||||
162.88.25.0/24 permit
|
162.88.25.0/24 permit
|
||||||
162.88.36.0/24 permit
|
162.88.36.0/24 permit
|
||||||
@@ -1585,10 +1635,12 @@
|
|||||||
164.152.23.32 permit
|
164.152.23.32 permit
|
||||||
164.152.25.241 permit
|
164.152.25.241 permit
|
||||||
164.177.132.168/30 permit
|
164.177.132.168/30 permit
|
||||||
|
165.1.100.0/25 permit
|
||||||
165.173.128.0/24 permit
|
165.173.128.0/24 permit
|
||||||
165.173.180.1 permit
|
165.173.180.1 permit
|
||||||
165.173.180.250/31 permit
|
165.173.180.250/31 permit
|
||||||
165.173.182.250/31 permit
|
165.173.182.250/31 permit
|
||||||
|
165.173.189.205 permit
|
||||||
166.78.68.0/22 permit
|
166.78.68.0/22 permit
|
||||||
166.78.68.221 permit
|
166.78.68.221 permit
|
||||||
166.78.69.169 permit
|
166.78.69.169 permit
|
||||||
@@ -1607,9 +1659,12 @@
|
|||||||
167.89.75.126 permit
|
167.89.75.126 permit
|
||||||
167.89.75.136 permit
|
167.89.75.136 permit
|
||||||
167.89.75.164 permit
|
167.89.75.164 permit
|
||||||
167.89.101.2 permit
|
|
||||||
167.89.101.192/28 permit
|
|
||||||
167.220.67.232/29 permit
|
167.220.67.232/29 permit
|
||||||
|
168.107.248.128/25 permit
|
||||||
|
168.110.160.128/25 permit
|
||||||
|
168.110.248.128/25 permit
|
||||||
|
168.129.184.128/25 permit
|
||||||
|
168.129.248.128/25 permit
|
||||||
168.138.5.36 permit
|
168.138.5.36 permit
|
||||||
168.138.73.51 permit
|
168.138.73.51 permit
|
||||||
168.138.77.31 permit
|
168.138.77.31 permit
|
||||||
@@ -1626,6 +1681,7 @@
|
|||||||
169.148.144.0/25 permit
|
169.148.144.0/25 permit
|
||||||
169.148.144.10 permit
|
169.148.144.10 permit
|
||||||
169.148.146.0/23 permit
|
169.148.146.0/23 permit
|
||||||
|
169.148.174.10 permit
|
||||||
169.148.175.3 permit
|
169.148.175.3 permit
|
||||||
169.148.179.3 permit
|
169.148.179.3 permit
|
||||||
169.148.188.0/24 permit
|
169.148.188.0/24 permit
|
||||||
@@ -1745,8 +1801,24 @@
|
|||||||
192.18.139.154 permit
|
192.18.139.154 permit
|
||||||
192.18.145.36 permit
|
192.18.145.36 permit
|
||||||
192.18.152.58 permit
|
192.18.152.58 permit
|
||||||
|
192.22.32.128/25 permit
|
||||||
|
192.22.96.128/25 permit
|
||||||
|
192.22.160.128/25 permit
|
||||||
|
192.22.224.128/25 permit
|
||||||
192.28.128.0/18 permit
|
192.28.128.0/18 permit
|
||||||
|
192.29.24.0/25 permit
|
||||||
|
192.29.44.0/25 permit
|
||||||
|
192.29.72.0/25 permit
|
||||||
|
192.29.88.0/25 permit
|
||||||
192.29.103.128/25 permit
|
192.29.103.128/25 permit
|
||||||
|
192.29.134.0/25 permit
|
||||||
|
192.29.151.128/25 permit
|
||||||
|
192.29.172.0/25 permit
|
||||||
|
192.29.178.0/25 permit
|
||||||
|
192.29.200.0/25 permit
|
||||||
|
192.29.216.0/25 permit
|
||||||
|
192.29.232.0/25 permit
|
||||||
|
192.29.248.0/25 permit
|
||||||
192.30.252.0/22 permit
|
192.30.252.0/22 permit
|
||||||
192.161.144.0/20 permit
|
192.161.144.0/20 permit
|
||||||
192.162.87.0/24 permit
|
192.162.87.0/24 permit
|
||||||
@@ -1754,11 +1826,6 @@
|
|||||||
192.237.159.42 permit
|
192.237.159.42 permit
|
||||||
192.237.159.43 permit
|
192.237.159.43 permit
|
||||||
192.254.112.0/20 permit
|
192.254.112.0/20 permit
|
||||||
192.254.112.60 permit
|
|
||||||
192.254.112.98/31 permit
|
|
||||||
192.254.113.10 permit
|
|
||||||
192.254.113.101 permit
|
|
||||||
192.254.114.176 permit
|
|
||||||
193.109.254.0/23 permit
|
193.109.254.0/23 permit
|
||||||
193.122.128.100 permit
|
193.122.128.100 permit
|
||||||
193.123.56.63 permit
|
193.123.56.63 permit
|
||||||
@@ -1927,6 +1994,7 @@
|
|||||||
207.211.30.128/25 permit
|
207.211.30.128/25 permit
|
||||||
207.211.31.0/25 permit
|
207.211.31.0/25 permit
|
||||||
207.211.41.113 permit
|
207.211.41.113 permit
|
||||||
|
207.211.132.0/25 permit
|
||||||
207.218.90.0/24 permit
|
207.218.90.0/24 permit
|
||||||
207.218.90.122 permit
|
207.218.90.122 permit
|
||||||
207.250.68.0/24 permit
|
207.250.68.0/24 permit
|
||||||
@@ -1934,6 +2002,8 @@
|
|||||||
208.43.21.28/30 permit
|
208.43.21.28/30 permit
|
||||||
208.43.21.64/29 permit
|
208.43.21.64/29 permit
|
||||||
208.43.21.72/30 permit
|
208.43.21.72/30 permit
|
||||||
|
208.56.9.224 permit
|
||||||
|
208.56.13.196 permit
|
||||||
208.64.132.0/22 permit
|
208.64.132.0/22 permit
|
||||||
208.71.40.63 permit
|
208.71.40.63 permit
|
||||||
208.71.40.64/31 permit
|
208.71.40.64/31 permit
|
||||||
@@ -1960,6 +2030,7 @@
|
|||||||
208.71.42.214 permit
|
208.71.42.214 permit
|
||||||
208.72.249.240/29 permit
|
208.72.249.240/29 permit
|
||||||
208.75.120.0/22 permit
|
208.75.120.0/22 permit
|
||||||
|
208.76.62.0/23 permit
|
||||||
208.76.62.0/24 permit
|
208.76.62.0/24 permit
|
||||||
208.76.63.0/24 permit
|
208.76.63.0/24 permit
|
||||||
208.82.237.96/29 permit
|
208.82.237.96/29 permit
|
||||||
@@ -2120,6 +2191,7 @@
|
|||||||
216.136.168.80/28 permit
|
216.136.168.80/28 permit
|
||||||
216.139.64.0/19 permit
|
216.139.64.0/19 permit
|
||||||
216.145.221.0/24 permit
|
216.145.221.0/24 permit
|
||||||
|
216.146.32.0/23 permit
|
||||||
216.146.32.0/24 permit
|
216.146.32.0/24 permit
|
||||||
216.146.33.0/24 permit
|
216.146.33.0/24 permit
|
||||||
216.198.0.0/18 permit
|
216.198.0.0/18 permit
|
||||||
@@ -2140,6 +2212,7 @@
|
|||||||
223.165.120.0/23 permit
|
223.165.120.0/23 permit
|
||||||
2001:0868:0100:0600::/64 permit
|
2001:0868:0100:0600::/64 permit
|
||||||
2001:4860:4000::/36 permit
|
2001:4860:4000::/36 permit
|
||||||
|
2001:4860:4864::/56 permit
|
||||||
2001:748:100:40::2:0/112 permit
|
2001:748:100:40::2:0/112 permit
|
||||||
2001:748:400:1300::3 permit
|
2001:748:400:1300::3 permit
|
||||||
2001:748:400:1300::4 permit
|
2001:748:400:1300::4 permit
|
||||||
@@ -2157,10 +2230,13 @@
|
|||||||
2001:748:400:3301::3 permit
|
2001:748:400:3301::3 permit
|
||||||
2001:748:400:3301::4 permit
|
2001:748:400:3301::4 permit
|
||||||
2404:6800:4000::/36 permit
|
2404:6800:4000::/36 permit
|
||||||
|
2404:6800:4864::/56 permit
|
||||||
|
2603:1061:14:72::1 permit
|
||||||
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
|
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
|
||||||
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
|
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
|
||||||
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
|
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
|
||||||
2607:f8b0:4000::/36 permit
|
2607:f8b0:4000::/36 permit
|
||||||
|
2607:f8b0:4864::/56 permit
|
||||||
2620:109:c003:104::/64 permit
|
2620:109:c003:104::/64 permit
|
||||||
2620:109:c003:104::215 permit
|
2620:109:c003:104::215 permit
|
||||||
2620:109:c006:104::/64 permit
|
2620:109:c006:104::/64 permit
|
||||||
@@ -2172,8 +2248,7 @@
|
|||||||
2620:10d:c09c:400::8:1 permit
|
2620:10d:c09c:400::8:1 permit
|
||||||
2620:119:50c0:207::/64 permit
|
2620:119:50c0:207::/64 permit
|
||||||
2620:119:50c0:207::215 permit
|
2620:119:50c0:207::215 permit
|
||||||
2620:1ec:46::51 permit
|
|
||||||
2620:1ec:bdf::51 permit
|
|
||||||
2800:3f0:4000::/36 permit
|
2800:3f0:4000::/36 permit
|
||||||
|
2800:3f0:4864::/56 permit
|
||||||
49.12.4.251 permit # checks.mailcow.email
|
49.12.4.251 permit # checks.mailcow.email
|
||||||
2a01:4f8:c17:7906::10 permit # checks.mailcow.email
|
2a01:4f8:c17:7906::10 permit # checks.mailcow.email
|
||||||
|
|||||||
@@ -16,46 +16,92 @@ if (!isset($_SESSION['gal']) && $license_cache = $redis->Get('LICENSE_STATUS_CAC
|
|||||||
|
|
||||||
$js_minifier->add('/web/js/site/dashboard.js');
|
$js_minifier->add('/web/js/site/dashboard.js');
|
||||||
|
|
||||||
// vmail df
|
$vmail_df_resp = agent('request', 'dovecot', 'exec.df', array('dir' => '/var/vmail'), 5);
|
||||||
$exec_fields = array('cmd' => 'system', 'task' => 'df', 'dir' => '/var/vmail');
|
$vmail_df = (!empty($vmail_df_resp['ok']) && is_string($vmail_df_resp['result']))
|
||||||
$vmail_df = explode(',', (string)json_decode(docker('post', 'dovecot-mailcow', 'exec', $exec_fields), true));
|
? explode(',', $vmail_df_resp['result'])
|
||||||
|
: array('', '', '', '', '', '/var/vmail');
|
||||||
|
|
||||||
// containers
|
$known_services = agent('services');
|
||||||
$containers_info = (array) docker('info');
|
|
||||||
if ($clamd_status === false) unset($containers_info['clamd-mailcow']);
|
try {
|
||||||
if ($olefy_status === false) unset($containers_info['olefy-mailcow']);
|
$tz_obj = new DateTimeZone(getenv('TZ') ?: 'UTC');
|
||||||
ksort($containers_info);
|
}
|
||||||
$containers = array();
|
catch (Exception $e) {
|
||||||
foreach ($containers_info as $container => $container_info) {
|
$tz_obj = new DateTimeZone('UTC');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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');
|
$hostname = getenv('MAILCOW_HOSTNAME');
|
||||||
$timezone = getenv('TZ');
|
$timezone = getenv('TZ');
|
||||||
|
|
||||||
@@ -70,6 +116,7 @@ $template_data = [
|
|||||||
'clamd_status' => $clamd_status,
|
'clamd_status' => $clamd_status,
|
||||||
'olefy_status' => $olefy_status,
|
'olefy_status' => $olefy_status,
|
||||||
'containers' => $containers,
|
'containers' => $containers,
|
||||||
|
'infra_containers' => $infra_containers,
|
||||||
'ip_check' => customize('get', 'ip_check'),
|
'ip_check' => customize('get', 'ip_check'),
|
||||||
'lang_admin' => json_encode($lang['admin']),
|
'lang_admin' => json_encode($lang['admin']),
|
||||||
'lang_debug' => json_encode($lang['debug']),
|
'lang_debug' => json_encode($lang['debug']),
|
||||||
@@ -77,5 +124,3 @@ $template_data = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
// Block requests by checking the 'Sec-Fetch-Dest' header.
|
|
||||||
if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] !== 'empty') {
|
if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] !== 'empty') {
|
||||||
header('HTTP/1.1 403 Forbidden');
|
header('HTTP/1.1 403 Forbidden');
|
||||||
exit;
|
exit;
|
||||||
@@ -8,52 +6,30 @@ if (isset($_SERVER['HTTP_SEC_FETCH_DEST']) && $_SERVER['HTTP_SEC_FETCH_DEST'] !=
|
|||||||
|
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||||
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'admin') {
|
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'admin') {
|
||||||
exit();
|
exit();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'])) {
|
if (!preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'] ?? '')) {
|
||||||
if ($_GET['action'] == "start") {
|
exit();
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
|
||||||
$retry = 0;
|
|
||||||
while (docker('info', $_GET['service'])['State']['Running'] != 1 && $retry <= 3) {
|
|
||||||
$response = docker('post', $_GET['service'], 'start');
|
|
||||||
$response = json_decode($response, true);
|
|
||||||
$last_response = ($response['type'] == "success") ? '<b><span class="pull-right text-success">OK</span></b>' : '<b><span class="pull-right text-danger">Error: ' . $response['msg'] . '</span></b>';
|
|
||||||
if ($response['type'] == "success") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
usleep(1500000);
|
|
||||||
$retry++;
|
|
||||||
}
|
|
||||||
echo (!isset($last_response)) ? '<b><span class="pull-right text-warning">Already running</span></b>' : $last_response;
|
|
||||||
}
|
|
||||||
if ($_GET['action'] == "stop") {
|
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
|
||||||
$retry = 0;
|
|
||||||
while (docker('info', $_GET['service'])['State']['Running'] == 1 && $retry <= 3) {
|
|
||||||
$response = docker('post', $_GET['service'], 'stop');
|
|
||||||
$response = json_decode($response, true);
|
|
||||||
$last_response = ($response['type'] == "success") ? '<b><span class="pull-right text-success">OK</span></b>' : '<b><span class="pull-right text-danger">Error: ' . $response['msg'] . '</span></b>';
|
|
||||||
if ($response['type'] == "success") {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
usleep(1500000);
|
|
||||||
$retry++;
|
|
||||||
}
|
|
||||||
echo (!isset($last_response)) ? '<b><span class="pull-right text-warning">Not running</span></b>' : $last_response;
|
|
||||||
}
|
|
||||||
if ($_GET['action'] == "restart") {
|
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
|
||||||
$response = docker('post', $_GET['service'], 'restart');
|
|
||||||
$response = json_decode($response, true);
|
|
||||||
$last_response = ($response['type'] == "success") ? '<b><span class="pull-right text-success">OK</span></b>' : '<b><span class="pull-right text-danger">Error: ' . $response['msg'] . '</span></b>';
|
|
||||||
echo (!isset($last_response)) ? '<b><span class="pull-right text-warning">Cannot restart container</span></b>' : $last_response;
|
|
||||||
}
|
|
||||||
if ($_GET['action'] == "logs") {
|
|
||||||
$lines = (empty($_GET['lines']) || !is_numeric($_GET['lines'])) ? 1000 : $_GET['lines'];
|
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
|
||||||
print_r(preg_split('/\n/', docker('logs', $_GET['service'], $lines)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
if (($_GET['action'] ?? '') !== 'restart') {
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = preg_replace('/-mailcow$/', '', $_GET['service']);
|
||||||
|
$node = isset($_GET['node']) ? preg_replace('/[^a-zA-Z0-9._\-]/', '', $_GET['node']) : '';
|
||||||
|
|
||||||
|
$args = ($node !== '') ? array('target_node' => $node) : array();
|
||||||
|
$resp = agent('request', $service, 'restart', $args, 60);
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
if (agent('ok', $resp)) {
|
||||||
|
echo '<b><span class="pull-right text-success">' . htmlspecialchars($lang['success']['service_restart_ok']) . '</span></b>';
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$err_key = agent('error_lang', $resp);
|
||||||
|
$err_msg = isset($lang['danger'][$err_key])
|
||||||
|
? sprintf($lang['danger'][$err_key], $service)
|
||||||
|
: $lang['danger']['agent_unknown_error'];
|
||||||
|
echo '<b><span class="pull-right text-danger">' . htmlspecialchars($err_msg) . '</span></b>';
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user