mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-06-18 12:30:36 +00:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0be3347f8 | |||
| 4f08c4ed7d | |||
| 0e76396f01 | |||
| 9bbac9f171 | |||
| e6f83853ae | |||
| 7da088c931 | |||
| bb3c2fb4fe | |||
| eb84847a5b | |||
| 0cfcde673c | |||
| ed5be5d7dc | |||
| ac90ecaf4f | |||
| fed3fc9514 | |||
| 35b9940db4 | |||
| ece940b000 | |||
| 4b5fd0b50a | |||
| 5aa9498f65 | |||
| 690d511e54 | |||
| e2a2b42139 | |||
| 4bbda8006d | |||
| a281746958 | |||
| cec51b6162 | |||
| 107c5d2e7d | |||
| 00c025f31a | |||
| 9b6388d0d0 | |||
| 2f25fcad77 | |||
| 7067e2c714 | |||
| 9f3cdfa713 | |||
| 529acf5ff6 | |||
| 0371edcf5e | |||
| d20254d4ee | |||
| befecfc31d | |||
| 004fcf092b | |||
| a487fcd0bd | |||
| 17e38a05f0 | |||
| c503abfe40 | |||
| 73929db796 | |||
| fb0685fa71 | |||
| df36670c7c | |||
| 3f9215678d | |||
| 0ac0e5c252 | |||
| af61c82077 | |||
| c066273c79 | |||
| 0c3e53e3a9 | |||
| 5ca10d1cde | |||
| 7907d43af7 | |||
| d198f1d3f8 | |||
| 102226723e | |||
| 2efaccf038 | |||
| aa7b6fa4a9 | |||
| 714727a129 | |||
| 4e5e264e3e | |||
| 267c81b42e | |||
| f2f3fbe497 | |||
| 6ba650820f | |||
| baa6286471 | |||
| be8537d165 | |||
| 737fced7be | |||
| 5a532df8ce | |||
| f8ce7a71e6 | |||
| 2e876bda9a | |||
| d2e5926cce | |||
| e3b576be67 | |||
| 0f7e359686 | |||
| b9a0b2db6d | |||
| 93b876c473 | |||
| 92c2aa2023 | |||
| 9351cf24fe |
@@ -14,7 +14,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Mark/Close Stale Issues and Pull Requests 🗑️
|
- name: Mark/Close Stale Issues and Pull Requests 🗑️
|
||||||
uses: actions/stale@v10.2.0
|
uses: actions/stale@v10.1.1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.STALE_ACTION_PAT }}
|
repo-token: ${{ secrets.STALE_ACTION_PAT }}
|
||||||
days-before-stale: 60
|
days-before-stale: 60
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
images:
|
images:
|
||||||
- "acme-mailcow"
|
- "acme-mailcow"
|
||||||
- "clamd-mailcow"
|
- "clamd-mailcow"
|
||||||
- "dockerapi-mailcow"
|
- "controller-mailcow"
|
||||||
- "dovecot-mailcow"
|
- "dovecot-mailcow"
|
||||||
- "netfilter-mailcow"
|
- "netfilter-mailcow"
|
||||||
- "olefy-mailcow"
|
- "olefy-mailcow"
|
||||||
|
|||||||
@@ -16,21 +16,21 @@ jobs:
|
|||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v4
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ data/conf/sogo/cron.creds
|
|||||||
data/conf/sogo/custom-fulllogo.svg
|
data/conf/sogo/custom-fulllogo.svg
|
||||||
data/conf/sogo/custom-shortlogo.svg
|
data/conf/sogo/custom-shortlogo.svg
|
||||||
data/conf/sogo/custom-fulllogo.png
|
data/conf/sogo/custom-fulllogo.png
|
||||||
data/conf/acme/dns-01.conf
|
|
||||||
data/gitea/
|
data/gitea/
|
||||||
data/gogs/
|
data/gogs/
|
||||||
data/hooks/dovecot/*
|
data/hooks/dovecot/*
|
||||||
|
|||||||
@@ -57,14 +57,11 @@ adapt_new_options() {
|
|||||||
"DISABLE_NETFILTER_ISOLATION_RULE"
|
"DISABLE_NETFILTER_ISOLATION_RULE"
|
||||||
"HTTP_REDIRECT"
|
"HTTP_REDIRECT"
|
||||||
"ENABLE_IPV6"
|
"ENABLE_IPV6"
|
||||||
"ACME_DNS_CHALLENGE"
|
|
||||||
"ACME_DNS_PROVIDER"
|
|
||||||
"ACME_ACCOUNT_EMAIL"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sed -i --follow-symlinks '$a\' mailcow.conf
|
sed -i --follow-symlinks '$a\' mailcow.conf
|
||||||
for option in ${CONFIG_ARRAY[@]}; do
|
for option in ${CONFIG_ARRAY[@]}; do
|
||||||
if grep -q "^#\?${option}=" mailcow.conf; then
|
if grep -q "${option}" mailcow.conf; then
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -295,20 +292,6 @@ adapt_new_options() {
|
|||||||
echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
|
echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
|
||||||
echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
|
echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
|
||||||
;;
|
;;
|
||||||
ACME_DNS_CHALLENGE)
|
|
||||||
echo '# Enable DNS-01 challenge for ACME (acme-mailcow) - y/n' >> mailcow.conf
|
|
||||||
echo '# This requires you to set ACME_DNS_PROVIDER and ACME_ACCOUNT_EMAIL below' >> mailcow.conf
|
|
||||||
echo 'ACME_DNS_CHALLENGE=n' >> mailcow.conf
|
|
||||||
;;
|
|
||||||
ACME_DNS_PROVIDER)
|
|
||||||
echo '# DNS provider for DNS-01 challenge (e.g. dns_cf, dns_azure, dns_gd, etc.)' >> mailcow.conf
|
|
||||||
echo '# See the dns-01 provider documentation for more information.' >> mailcow.conf
|
|
||||||
echo 'ACME_DNS_PROVIDER=dns_xxx' >> mailcow.conf
|
|
||||||
;;
|
|
||||||
ACME_ACCOUNT_EMAIL)
|
|
||||||
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
|
|
||||||
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
|
|
||||||
;;
|
|
||||||
*)
|
*)
|
||||||
echo "${option}=" >> mailcow.conf
|
echo "${option}=" >> mailcow.conf
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
FROM alpine:3.21
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM alpine:3.23
|
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
@@ -18,35 +14,14 @@ RUN apk upgrade --no-cache \
|
|||||||
tini \
|
tini \
|
||||||
tzdata \
|
tzdata \
|
||||||
python3 \
|
python3 \
|
||||||
acme-tiny \
|
acme-tiny
|
||||||
git \
|
|
||||||
socat \
|
|
||||||
&& git clone --depth 1 https://github.com/acmesh-official/acme.sh.git /opt/acme.sh \
|
|
||||||
&& chmod +x /opt/acme.sh/acme.sh \
|
|
||||||
&& mkdir -p /var/lib/acme/acme-sh
|
|
||||||
|
|
||||||
ENV ACME_SH_BIN=/opt/acme.sh/acme.sh \
|
|
||||||
ACME_SH_HOME=/opt/acme.sh \
|
|
||||||
ACME_SH_CONFIG_HOME=/var/lib/acme/acme-sh
|
|
||||||
|
|
||||||
COPY acme.sh /srv/acme.sh
|
COPY acme.sh /srv/acme.sh
|
||||||
COPY functions.sh /srv/functions.sh
|
COPY functions.sh /srv/functions.sh
|
||||||
COPY obtain-certificate.sh /srv/obtain-certificate.sh
|
COPY obtain-certificate.sh /srv/obtain-certificate.sh
|
||||||
COPY obtain-certificate-dns.sh /srv/obtain-certificate-dns.sh
|
|
||||||
COPY load-dns-config.sh /srv/load-dns-config.sh
|
|
||||||
COPY reload-configurations.sh /srv/reload-configurations.sh
|
COPY reload-configurations.sh /srv/reload-configurations.sh
|
||||||
COPY expand6.sh /srv/expand6.sh
|
COPY expand6.sh /srv/expand6.sh
|
||||||
|
|
||||||
RUN chmod +x /srv/*.sh
|
RUN chmod +x /srv/*.sh
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
CMD ["/sbin/tini", "-g", "--", "/srv/acme.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=acme \
|
|
||||||
MAILCOW_AGENT_MAIN_CMD="/sbin/tini -g -- /srv/acme.sh"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
|
|||||||
@@ -14,17 +14,6 @@ until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
# Create DNS-01 configuration template if it doesn't exist
|
|
||||||
if [[ ! -f /etc/acme/dns-01.conf ]]; then
|
|
||||||
mkdir -p /etc/acme
|
|
||||||
cat > /etc/acme/dns-01.conf <<'EOF'
|
|
||||||
# Add here your DNS-01 challenge configuration
|
|
||||||
# For more information, visit the acme.sh documentation:
|
|
||||||
# https://github.com/acmesh-official/acme.sh/wiki/dnsapi
|
|
||||||
EOF
|
|
||||||
echo "Created DNS-01 configuration template at /etc/acme/dns-01.conf"
|
|
||||||
fi
|
|
||||||
|
|
||||||
source /srv/functions.sh
|
source /srv/functions.sh
|
||||||
# Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6
|
# Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6
|
||||||
source /srv/expand6.sh
|
source /srv/expand6.sh
|
||||||
@@ -53,21 +42,17 @@ if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
|||||||
ENABLE_SSL_SNI=y
|
ENABLE_SSL_SNI=y
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${ACME_DNS_CHALLENGE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
|
||||||
ACME_DNS_CHALLENGE=y
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||||
log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..."
|
log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..."
|
||||||
sleep 365d
|
sleep 365d
|
||||||
exec $(readlink -f "$0")
|
exec $(readlink -f "$0")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_f "Waiting for Redis control bus..."
|
log_f "Waiting for Controller .."
|
||||||
until redis-cli -h "${REDIS_SLAVEOF_IP:-redis-mailcow}" -p "${REDIS_SLAVEOF_PORT:-6379}" -a "${REDISPASS}" --no-auth-warning ping > /dev/null 2>&1; do
|
until ping controller -c1 > /dev/null; do
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
log_f "Redis control bus OK"
|
log_f "Controller 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,20 +238,10 @@ while true; do
|
|||||||
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||||
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||||
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
|
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
|
||||||
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
|
if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||||
|
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
|
||||||
# Skip if subdomain matches MAILCOW_HOSTNAME
|
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}")
|
||||||
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
|
fi
|
||||||
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[*]}")
|
||||||
@@ -283,10 +258,7 @@ 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
|
||||||
# Skip if mta-sts subdomain is covered by a wildcard
|
if check_domain "mta-sts.${alias_domain}"; then
|
||||||
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
|
||||||
@@ -321,31 +293,13 @@ while true; do
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if MAILCOW_HOSTNAME is covered by a wildcard in ADDITIONAL_SAN
|
|
||||||
MAILCOW_HOSTNAME_COVERED=0
|
|
||||||
if [[ ! -z ${VALIDATED_MAILCOW_HOSTNAME} ]]; then
|
|
||||||
if is_covered_by_wildcard "${VALIDATED_MAILCOW_HOSTNAME}"; then
|
|
||||||
MAILCOW_PARENT_DOMAIN=$(echo ${VALIDATED_MAILCOW_HOSTNAME} | cut -d. -f2-)
|
|
||||||
log_f "MAILCOW_HOSTNAME '${VALIDATED_MAILCOW_HOSTNAME}' is covered by wildcard '*.${MAILCOW_PARENT_DOMAIN}' - skipping explicit hostname"
|
|
||||||
MAILCOW_HOSTNAME_COVERED=1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Unique domains for server certificate
|
# Unique domains for server certificate
|
||||||
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
|
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
|
||||||
# create certificate for server name and fqdn SANs only
|
# create certificate for server name and fqdn SANs only
|
||||||
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
|
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||||
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 [*]
|
||||||
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
|
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||||
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]}
|
||||||
|
|||||||
@@ -80,11 +80,6 @@ check_domain(){
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ ${ACME_DNS_CHALLENGE} == "y" ]]; then
|
|
||||||
log_f "ACME_DNS_CHALLENGE=y - skipping IP and HTTP validation for ${DOMAIN}"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
# Check if CNAME without v6 enabled target
|
# Check if CNAME without v6 enabled target
|
||||||
if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then
|
if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then
|
||||||
AAAA_DOMAIN=
|
AAAA_DOMAIN=
|
||||||
@@ -135,32 +130,3 @@ verify_challenge_path(){
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if a domain is covered by a wildcard (*.example.com) in ADDITIONAL_SAN
|
|
||||||
# Usage: is_covered_by_wildcard "subdomain.example.com"
|
|
||||||
# Returns: 0 if covered, 1 if not covered
|
|
||||||
# Note: Only returns 0 (covered) when DNS-01 challenge is enabled,
|
|
||||||
# as wildcards cannot be validated with HTTP-01 challenge
|
|
||||||
is_covered_by_wildcard() {
|
|
||||||
local DOMAIN=$1
|
|
||||||
|
|
||||||
# Only skip if DNS challenge is enabled (wildcards require DNS-01)
|
|
||||||
if [[ ${ACME_DNS_CHALLENGE} != "y" ]]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Return early if no ADDITIONAL_SAN is set
|
|
||||||
if [[ -z ${ADDITIONAL_SAN} ]]; then
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Extract parent domain (e.g., mail.example.com -> example.com)
|
|
||||||
local PARENT_DOMAIN=$(echo ${DOMAIN} | cut -d. -f2-)
|
|
||||||
|
|
||||||
# Check if ADDITIONAL_SAN contains a wildcard for this parent domain
|
|
||||||
if [[ "${ADDITIONAL_SAN}" == *"*.${PARENT_DOMAIN}"* ]]; then
|
|
||||||
return 0 # Covered by wildcard
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 1 # Not covered
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
SCRIPT_SOURCE="${BASH_SOURCE[0]:-${0}}"
|
|
||||||
if [[ "${SCRIPT_SOURCE}" == "${0}" ]]; then
|
|
||||||
__dns_loader_standalone=1
|
|
||||||
else
|
|
||||||
__dns_loader_standalone=0
|
|
||||||
fi
|
|
||||||
|
|
||||||
CONFIG_PATH="${ACME_DNS_CONFIG_FILE:-/etc/acme/dns-01.conf}"
|
|
||||||
|
|
||||||
if [[ ! -f "${CONFIG_PATH}" ]]; then
|
|
||||||
if [[ $__dns_loader_standalone -eq 1 ]]; then
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
source /srv/functions.sh
|
|
||||||
|
|
||||||
log_f "Loading DNS-01 configuration from ${CONFIG_PATH}"
|
|
||||||
|
|
||||||
LINE_NO=0
|
|
||||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
|
||||||
LINE_NO=$((LINE_NO+1))
|
|
||||||
line="${line%$'\r'}"
|
|
||||||
line_trimmed="$(printf '%s' "${line}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
||||||
[[ -z "${line_trimmed}" ]] && continue
|
|
||||||
[[ "${line_trimmed:0:1}" == "#" ]] && continue
|
|
||||||
if [[ "${line_trimmed}" != *=* ]]; then
|
|
||||||
log_f "Skipping invalid DNS config line ${LINE_NO} (missing key=value)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
KEY="${line_trimmed%%=*}"
|
|
||||||
VALUE="${line_trimmed#*=}"
|
|
||||||
KEY="$(printf '%s' "${KEY}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
||||||
VALUE="$(printf '%s' "${VALUE}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
||||||
if [[ -z "${KEY}" ]]; then
|
|
||||||
log_f "Skipping invalid DNS config line ${LINE_NO} (empty key)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if [[ "${VALUE}" =~ ^\".*\"$ ]]; then
|
|
||||||
VALUE="${VALUE:1:-1}"
|
|
||||||
elif [[ "${VALUE}" =~ ^\'.*\'$ ]]; then
|
|
||||||
VALUE="${VALUE:1:-1}"
|
|
||||||
fi
|
|
||||||
export "${KEY}"="${VALUE}"
|
|
||||||
log_f "Exported DNS config key ${KEY}"
|
|
||||||
|
|
||||||
done < "${CONFIG_PATH}"
|
|
||||||
|
|
||||||
if [[ $__dns_loader_standalone -eq 1 ]]; then
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Return values / exit codes
|
|
||||||
# 0 = cert created successfully
|
|
||||||
# 1 = cert renewed successfully
|
|
||||||
# 2 = cert not due for renewal
|
|
||||||
# * = errors
|
|
||||||
|
|
||||||
source /srv/functions.sh
|
|
||||||
|
|
||||||
CERT_DOMAINS=(${DOMAINS[@]})
|
|
||||||
CERT_DOMAIN=${CERT_DOMAINS[0]}
|
|
||||||
ACME_BASE=/var/lib/acme
|
|
||||||
|
|
||||||
# Load optional DNS provider secrets from /etc/acme/dns-01.conf
|
|
||||||
if [[ -f /srv/load-dns-config.sh ]]; then
|
|
||||||
source /srv/load-dns-config.sh
|
|
||||||
if declare -F log_f >/dev/null; then
|
|
||||||
log_f "ACME_DNS_CHALLENGE is enabled, DNS provider secrets loaded"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
TYPE=${1}
|
|
||||||
PREFIX=""
|
|
||||||
# only support rsa certificates for now
|
|
||||||
if [[ "${TYPE}" != "rsa" ]]; then
|
|
||||||
log_f "Unknown certificate type '${TYPE}' requested"
|
|
||||||
exit 5
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "${ACME_DNS_PROVIDER}" ]]; then
|
|
||||||
log_f "ACME_DNS_PROVIDER is required when ACME_DNS_CHALLENGE is enabled"
|
|
||||||
exit 6
|
|
||||||
fi
|
|
||||||
|
|
||||||
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
|
|
||||||
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
|
|
||||||
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
|
|
||||||
KEY=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}key.pem
|
|
||||||
CSR=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}acme.csr
|
|
||||||
|
|
||||||
if [[ -z ${CERT_DOMAINS[*]} ]]; then
|
|
||||||
log_f "Missing CERT_DOMAINS to obtain a certificate"
|
|
||||||
exit 3
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${LE_STAGING}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
|
||||||
if [[ ! -z "${DIRECTORY_URL}" ]]; then
|
|
||||||
log_f "Cannot use DIRECTORY_URL with LE_STAGING=y - ignoring DIRECTORY_URL"
|
|
||||||
fi
|
|
||||||
log_f "Using Let's Encrypt staging servers"
|
|
||||||
ACME_SH_SERVER_ARGS=("--staging")
|
|
||||||
elif [[ ! -z "${DIRECTORY_URL}" ]]; then
|
|
||||||
log_f "Using custom directory URL ${DIRECTORY_URL}"
|
|
||||||
ACME_SH_SERVER_ARGS=("--server" "${DIRECTORY_URL}")
|
|
||||||
else
|
|
||||||
log_f "Using Let's Encrypt production servers"
|
|
||||||
ACME_SH_SERVER_ARGS=("--server" "letsencrypt")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -f ${DOMAINS_FILE} && "$(cat ${DOMAINS_FILE})" == "${CERT_DOMAINS[*]}" ]]; then
|
|
||||||
if [[ ! -f ${CERT} || ! -f "${KEY}" || -f "${ACME_BASE}/force_renew" ]]; then
|
|
||||||
log_f "Certificate ${CERT} doesn't exist yet or forced renewal - start obtaining"
|
|
||||||
elif ! openssl x509 -checkend 2592000 -noout -in ${CERT} > /dev/null; then
|
|
||||||
log_f "Certificate ${CERT} is due for renewal (< 30 days) - start renewing"
|
|
||||||
else
|
|
||||||
log_f "Certificate ${CERT} validation done, neither changed nor due for renewal."
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log_f "Certificate ${CERT} missing or changed domains '${CERT_DOMAINS[*]}' - start obtaining"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Make backup
|
|
||||||
if [[ -f ${CERT} ]]; then
|
|
||||||
DATE=$(date +%Y-%m-%d_%H_%M_%S)
|
|
||||||
BACKUP_DIR=${ACME_BASE}/backups/${CERT_DOMAIN}/${PREFIX}${DATE}
|
|
||||||
log_f "Creating backups in ${BACKUP_DIR} ..."
|
|
||||||
mkdir -p ${BACKUP_DIR}/
|
|
||||||
[[ -f ${DOMAINS_FILE} ]] && cp ${DOMAINS_FILE} ${BACKUP_DIR}/
|
|
||||||
[[ -f ${CERT} ]] && cp ${CERT} ${BACKUP_DIR}/
|
|
||||||
[[ -f ${KEY} ]] && cp ${KEY} ${BACKUP_DIR}/
|
|
||||||
[[ -f ${CSR} ]] && cp ${CSR} ${BACKUP_DIR}/
|
|
||||||
fi
|
|
||||||
|
|
||||||
mkdir -p ${ACME_BASE}/${CERT_DOMAIN}
|
|
||||||
if [[ ! -f ${KEY} ]]; then
|
|
||||||
log_f "Copying shared private key for this certificate..."
|
|
||||||
cp ${SHARED_KEY} ${KEY}
|
|
||||||
chmod 600 ${KEY}
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Generating CSR to keep layout parity with HTTP challenge flow
|
|
||||||
printf "[SAN]\nsubjectAltName=" > /tmp/_SAN
|
|
||||||
printf "DNS:%s," "${CERT_DOMAINS[@]}" >> /tmp/_SAN
|
|
||||||
sed -i '$s/,$//' /tmp/_SAN
|
|
||||||
openssl req -new -sha256 -key ${KEY} -subj "/" -reqexts SAN -config <(cat "$(openssl version -d | sed 's/.*\"\(.*\)\"/\1/g')/openssl.cnf" /tmp/_SAN) > ${CSR}
|
|
||||||
|
|
||||||
log_f "Checking resolver..."
|
|
||||||
until dig letsencrypt.org +time=3 +tries=1 @unbound > /dev/null; do
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
log_f "Resolver OK"
|
|
||||||
|
|
||||||
ACME_SH_BIN_PATH=${ACME_SH_BIN:-/opt/acme.sh/acme.sh}
|
|
||||||
ACME_SH_WORK_HOME=${ACME_SH_CONFIG_HOME:-/var/lib/acme/acme-sh}
|
|
||||||
mkdir -p ${ACME_SH_WORK_HOME}
|
|
||||||
|
|
||||||
if [[ ! -x ${ACME_SH_BIN_PATH} ]]; then
|
|
||||||
log_f "acme.sh binary not found at ${ACME_SH_BIN_PATH}"
|
|
||||||
exit 7
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ ! -f ${ACME_SH_WORK_HOME}/account.conf ]]; then
|
|
||||||
if [[ -z "${ACME_ACCOUNT_EMAIL}" ]]; then
|
|
||||||
log_f "ACME_ACCOUNT_EMAIL is required to register a new acme.sh account"
|
|
||||||
exit 8
|
|
||||||
fi
|
|
||||||
log_f "Registering acme.sh account for ${ACME_ACCOUNT_EMAIL}"
|
|
||||||
REGISTER_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}" "--register-account" "-m" "${ACME_ACCOUNT_EMAIL}")
|
|
||||||
REGISTER_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
|
|
||||||
REGISTER_RESPONSE=$("${REGISTER_CMD[@]}" 2>&1)
|
|
||||||
if [[ $? -ne 0 ]]; then
|
|
||||||
log_f "Failed to register acme.sh account: ${REGISTER_RESPONSE}"
|
|
||||||
exit 9
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
TMP_CERT=$(mktemp /tmp/acme-cert.XXXXXX)
|
|
||||||
TMP_FULLCHAIN=$(mktemp /tmp/acme-fullchain.XXXXXX)
|
|
||||||
|
|
||||||
ACME_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}")
|
|
||||||
ACME_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
|
|
||||||
ACME_CMD+=("--issue" "--dns" "${ACME_DNS_PROVIDER}" "--key-file" "${KEY}" "--cert-file" "${TMP_CERT}" "--fullchain-file" "${TMP_FULLCHAIN}" "--force")
|
|
||||||
for domain in "${CERT_DOMAINS[@]}"; do
|
|
||||||
ACME_CMD+=("-d" "${domain}")
|
|
||||||
done
|
|
||||||
|
|
||||||
log_f "Using command ${ACME_CMD[*]}"
|
|
||||||
if [[ -n "${ACME_DNS_PROVIDER}" ]]; then
|
|
||||||
log_f "DNS provider: ${ACME_DNS_PROVIDER}"
|
|
||||||
fi
|
|
||||||
if compgen -A variable | grep -Eq "^DNS_|^ACME_"; then
|
|
||||||
LOG_KEYS=$(env | grep -E "^(DNS_|ACME_)" | cut -d= -f1 | tr '\n' ' ')
|
|
||||||
log_f "Available DNS/ACME env keys: ${LOG_KEYS}" redis_only
|
|
||||||
fi
|
|
||||||
ACME_RESPONSE=$("${ACME_CMD[@]}" 2>&1 | tee /dev/fd/5; exit ${PIPESTATUS[0]})
|
|
||||||
SUCCESS="$?"
|
|
||||||
ACME_RESPONSE_B64=$(echo "${ACME_RESPONSE}" | openssl enc -e -A -base64)
|
|
||||||
log_f "${ACME_RESPONSE_B64}" redis_only b64
|
|
||||||
|
|
||||||
case "$SUCCESS" in
|
|
||||||
0)
|
|
||||||
log_f "Deploying certificate ${CERT}..."
|
|
||||||
if verify_hash_match ${TMP_FULLCHAIN} ${KEY}; then
|
|
||||||
RETURN=0
|
|
||||||
if [[ -f ${CERT} ]]; then
|
|
||||||
RETURN=1
|
|
||||||
fi
|
|
||||||
mv -f ${TMP_FULLCHAIN} ${CERT}
|
|
||||||
rm -f ${TMP_CERT}
|
|
||||||
echo -n ${CERT_DOMAINS[*]} > ${DOMAINS_FILE}
|
|
||||||
log_f "Certificate successfully obtained via DNS challenge"
|
|
||||||
exit ${RETURN}
|
|
||||||
else
|
|
||||||
log_f "Certificate was requested, but key and certificate hashes do not match"
|
|
||||||
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
|
|
||||||
exit 4
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
log_f "Failed to obtain certificate ${CERT} for domains '${CERT_DOMAINS[*]}' via DNS challenge"
|
|
||||||
redis-cli -h redis -a ${REDISPASS} --no-auth-warning SET ACME_FAIL_TIME "$(date +%s)"
|
|
||||||
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
|
|
||||||
exit 100${SUCCESS}
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -20,10 +20,6 @@ if [[ "${TYPE}" != "rsa" ]]; then
|
|||||||
log_f "Unknown certificate type '${TYPE}' requested"
|
log_f "Unknown certificate type '${TYPE}' requested"
|
||||||
exit 5
|
exit 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${ACME_DNS_CHALLENGE}" == "y" ]]; then
|
|
||||||
exec /srv/obtain-certificate-dns.sh "$@"
|
|
||||||
fi
|
|
||||||
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
|
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
|
||||||
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
|
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
|
||||||
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
|
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Tell every live replica of nginx / dovecot / postfix to reload (or restart
|
|
||||||
# on cert-amount change) via the mailcow-agent control bus. Replaces the old
|
|
||||||
# dockerapi-based container_id lookup + exec dance.
|
|
||||||
|
|
||||||
reload_service() {
|
# Reading container IDs
|
||||||
local svc="$1"
|
# Wrapping as array to ensure trimmed content when calling $NGINX etc.
|
||||||
echo "Reloading ${svc} via mailcow-agent..."
|
NGINX=($(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"nginx-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
|
||||||
if ! mailcow-agent-cli send "${svc}" reload >/dev/null; then
|
DOVECOT=($(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"dovecot-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
|
||||||
echo "Could not publish reload to ${svc}, attempting restart..."
|
POSTFIX=($(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
|
||||||
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
|
||||||
fi
|
reload_nginx(){
|
||||||
|
echo "Reloading Nginx..."
|
||||||
|
NGINX_RELOAD_RET=$(curl -X POST --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${NGINX}/exec -d '{"cmd":"reload", "task":"nginx"}' --silent -H 'Content-type: application/json' | jq -r .type)
|
||||||
|
[[ ${NGINX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Nginx, restarting container..."; restart_container ${NGINX} ; }
|
||||||
}
|
}
|
||||||
|
|
||||||
restart_service() {
|
reload_dovecot(){
|
||||||
local svc="$1"
|
echo "Reloading Dovecot..."
|
||||||
echo "Restarting ${svc} via mailcow-agent..."
|
DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type)
|
||||||
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
[[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; }
|
||||||
|
}
|
||||||
|
|
||||||
|
reload_postfix(){
|
||||||
|
echo "Reloading Postfix..."
|
||||||
|
POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://controller.${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://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${container}/restart --silent | jq -r '.msg')
|
||||||
|
echo "${C_REST_OUT}"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
|
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
|
||||||
restart_service nginx
|
restart_container ${NGINX}
|
||||||
restart_service dovecot
|
restart_container ${DOVECOT}
|
||||||
restart_service postfix
|
restart_container ${POSTFIX}
|
||||||
else
|
else
|
||||||
reload_service nginx
|
reload_nginx
|
||||||
restart_service dovecot
|
#reload_dovecot
|
||||||
restart_service postfix
|
restart_container ${DOVECOT}
|
||||||
|
#reload_postfix
|
||||||
|
restart_container ${POSTFIX}
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
# Builder image for mailcow-agent. Each service Dockerfile pulls the static
|
|
||||||
# binary from here via:
|
|
||||||
#
|
|
||||||
# COPY --from=ghcr.io/mailcow/agent:VERSION /out/mailcow-agent /usr/local/bin/mailcow-agent
|
|
||||||
#
|
|
||||||
# For local development: build this image first.
|
|
||||||
#
|
|
||||||
# docker build -t ghcr.io/mailcow/agent:dev data/Dockerfiles/agent/
|
|
||||||
#
|
|
||||||
# CI publishes a versioned tag and the service Dockerfiles pin against it via
|
|
||||||
# ARG AGENT_IMAGE.
|
|
||||||
|
|
||||||
FROM golang:1.22-alpine AS build
|
|
||||||
|
|
||||||
ENV CGO_ENABLED=0 \
|
|
||||||
GOOS=linux
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
COPY go.mod go.sum* ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN mkdir -p /out \
|
|
||||||
&& go build -trimpath -ldflags="-s -w" \
|
|
||||||
-o /out/mailcow-agent ./cmd/mailcow-agent \
|
|
||||||
&& cp mailcow-agent-cli /out/mailcow-agent-cli \
|
|
||||||
&& chmod +x /out/mailcow-agent-cli
|
|
||||||
|
|
||||||
# Final stage: tiny image whose only purpose is to be a COPY --from source.
|
|
||||||
FROM scratch
|
|
||||||
COPY --from=build /out/mailcow-agent /out/mailcow-agent
|
|
||||||
COPY --from=build /out/mailcow-agent-cli /out/mailcow-agent-cli
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# mailcow-agent
|
|
||||||
|
|
||||||
Each mailcow service container (postfix, dovecot, …) runs `mailcow-agent` as
|
|
||||||
ENTRYPOINT. It supervises the original service main process and exposes its
|
|
||||||
control commands over a Redis Pub/Sub bus:
|
|
||||||
|
|
||||||
- `mailcow.control.<service>` — request channel (Backend → Agent)
|
|
||||||
- `mailcow.reply.<request_id>` — per-request reply channel
|
|
||||||
- `mailcow.events.<topic>` — broadcast events
|
|
||||||
- `mailcow.nodes.<service>` (ZSET) + `mailcow.node.<service>.<node_id>` (HASH) — heartbeat registry
|
|
||||||
- `mailcow.stats.<service>.<node_id>` (HASH) — per-node cpu/memory stats
|
|
||||||
|
|
||||||
Service behaviour is selected via `MAILCOW_AGENT_SERVICE=<service>`. The main
|
|
||||||
process command is configured via `MAILCOW_AGENT_MAIN_CMD` (string, executed via
|
|
||||||
`sh -c` so existing entrypoints/supervisord commands keep working).
|
|
||||||
|
|
||||||
@@ -1,278 +0,0 @@
|
|||||||
// Per-container control-bus subscriber. Subscribes to mailcow.control.<service>
|
|
||||||
// on Redis, runs handlers from the per-service command table, publishes
|
|
||||||
// heartbeats and stats. Optionally supervises a child process.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/bus"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/registry"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/services"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/stats"
|
|
||||||
)
|
|
||||||
|
|
||||||
const agentVersion = "0.1.0"
|
|
||||||
|
|
||||||
// atomicSignal shares the last caught terminal signal between the handler
|
|
||||||
// goroutine and main() so it can be forwarded to the supervised child.
|
|
||||||
type atomicSignal struct{ v atomic.Int32 }
|
|
||||||
|
|
||||||
func (a *atomicSignal) Store(s syscall.Signal) { a.v.Store(int32(s)) }
|
|
||||||
func (a *atomicSignal) Load() os.Signal { return syscall.Signal(a.v.Load()) }
|
|
||||||
|
|
||||||
// healthState holds the latest health probe result. Written by the probe loop,
|
|
||||||
// read by the heartbeat loop.
|
|
||||||
type healthState struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
ok bool
|
|
||||||
detail string
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *healthState) Set(ok bool, detail string) {
|
|
||||||
h.mu.Lock()
|
|
||||||
h.ok = ok
|
|
||||||
h.detail = detail
|
|
||||||
h.at = time.Now()
|
|
||||||
h.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *healthState) Snapshot() (ok bool, detail string, at time.Time) {
|
|
||||||
h.mu.RLock()
|
|
||||||
defer h.mu.RUnlock()
|
|
||||||
return h.ok, h.detail, h.at
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
service := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_SERVICE"))
|
|
||||||
if service == "" {
|
|
||||||
fmt.Fprintf(os.Stderr, "mailcow-agent: MAILCOW_AGENT_SERVICE is required. Known: %v\n", services.Known())
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// `mailcow-agent healthcheck` runs the probe once and exits 0/1
|
|
||||||
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
|
||||||
runHealthcheckOnce(service)
|
|
||||||
}
|
|
||||||
|
|
||||||
nodeID := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_NODE_ID"))
|
|
||||||
if nodeID == "" {
|
|
||||||
h, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("mailcow-agent: hostname: %v", err)
|
|
||||||
}
|
|
||||||
nodeID = h
|
|
||||||
}
|
|
||||||
|
|
||||||
mainCmd := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_MAIN_CMD"))
|
|
||||||
// host-agent has no supervised child; everything else runs one.
|
|
||||||
wantsSupervisor := service != "host" && mainCmd != ""
|
|
||||||
|
|
||||||
rdb, err := newRedis()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("mailcow-agent: redis: %v", err)
|
|
||||||
}
|
|
||||||
defer rdb.Close()
|
|
||||||
|
|
||||||
// Start the supervised process before serving bus requests — restart/stop
|
|
||||||
// handlers assume something is already running.
|
|
||||||
var sup *proc.Supervisor
|
|
||||||
if wantsSupervisor {
|
|
||||||
sup = proc.New(mainCmd)
|
|
||||||
if err := sup.Start(); err != nil {
|
|
||||||
log.Fatalf("mailcow-agent: start main: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
table, err := services.Build(service, sup)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("mailcow-agent: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We handle signals ourselves so we can (a) suppress the Go-runtime stack
|
|
||||||
// dump on SIGQUIT (php-fpm-alpine's STOPSIGNAL) and (b) remember which
|
|
||||||
// signal arrived to forward it to the child on shutdown.
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
sigCh := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT,
|
|
||||||
syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2)
|
|
||||||
defer signal.Stop(sigCh)
|
|
||||||
|
|
||||||
stopSig := atomicSignal{}
|
|
||||||
stopSig.Store(syscall.SIGTERM)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for sig := range sigCh {
|
|
||||||
switch sig {
|
|
||||||
case syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT:
|
|
||||||
stopSig.Store(sig.(syscall.Signal))
|
|
||||||
log.Printf("mailcow-agent: caught %s, beginning graceful shutdown", sig)
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
case syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2:
|
|
||||||
if sup != nil {
|
|
||||||
sup.SignalChild(sig)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Initial state is "ok" so the service isn't flagged unhealthy before the
|
|
||||||
// first probe has run.
|
|
||||||
health := &healthState{ok: true, detail: "", at: time.Now()}
|
|
||||||
if table.HealthProbe != nil {
|
|
||||||
go runHealthLoop(ctx, table.HealthProbe, health, 10*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
hb := registry.Heartbeat{
|
|
||||||
Service: service,
|
|
||||||
NodeID: nodeID,
|
|
||||||
Version: agentVersion,
|
|
||||||
StartedAt: time.Now(),
|
|
||||||
Image: os.Getenv("MAILCOW_AGENT_IMAGE"),
|
|
||||||
Health: health,
|
|
||||||
}
|
|
||||||
go registry.Loop(ctx, rdb, hb, 10*time.Second)
|
|
||||||
|
|
||||||
// cgroup stats for this container. Host metrics come from exec.host-stats.
|
|
||||||
pub := stats.NewPublisher(rdb, service, nodeID)
|
|
||||||
go pub.Run(ctx, 10*time.Second)
|
|
||||||
|
|
||||||
srv := bus.NewServer(rdb, table, nodeID)
|
|
||||||
busErrCh := make(chan error, 1)
|
|
||||||
go func() { busErrCh <- srv.Run(ctx) }()
|
|
||||||
|
|
||||||
log.Printf("mailcow-agent: service=%s node=%s ready (commands=%d)", service, nodeID, len(table.Handlers))
|
|
||||||
|
|
||||||
// Exit only on outside termination or fatal bus error. A crashed/stopped
|
|
||||||
// child should not tear down the container — the operator may want to
|
|
||||||
// issue `start` over the bus afterwards.
|
|
||||||
exitCode := 0
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
log.Println("mailcow-agent: shutdown signal received")
|
|
||||||
case err := <-busErrCh:
|
|
||||||
if err != nil && !errors.Is(err, context.Canceled) {
|
|
||||||
log.Printf("mailcow-agent: bus loop exited: %v", err)
|
|
||||||
exitCode = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful shutdown bounded at 35s.
|
|
||||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 35*time.Second)
|
|
||||||
defer shutCancel()
|
|
||||||
_ = srv.Shutdown(shutCtx)
|
|
||||||
_ = registry.Deregister(shutCtx, rdb, service, nodeID)
|
|
||||||
if sup != nil {
|
|
||||||
// Forward the exact signal we received so the child sees the same
|
|
||||||
// shutdown semantics it would without us in front (e.g. SIGQUIT →
|
|
||||||
// php-fpm graceful drain).
|
|
||||||
if err := sup.StopWithSignal(shutCtx, stopSig.Load()); err != nil {
|
|
||||||
log.Printf("mailcow-agent: stop main: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
os.Exit(exitCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runHealthcheckOnce runs the local probe with a tight deadline and exits 0/1.
|
|
||||||
// Used by the `healthcheck` sub-command path.
|
|
||||||
func runHealthcheckOnce(service string) {
|
|
||||||
table, err := services.Build(service, nil)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "mailcow-agent healthcheck:", err)
|
|
||||||
os.Exit(2)
|
|
||||||
}
|
|
||||||
if table.HealthProbe == nil {
|
|
||||||
// Services without a probe are considered healthy.
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := table.HealthProbe(ctx); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "unhealthy:", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runHealthLoop ticks the probe and updates state. Same probe path as the
|
|
||||||
// healthcheck sub-command.
|
|
||||||
func runHealthLoop(ctx context.Context, probe commands.HealthProbe, state *healthState, interval time.Duration) {
|
|
||||||
t := time.NewTicker(interval)
|
|
||||||
defer t.Stop()
|
|
||||||
check := func() {
|
|
||||||
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := probe(pctx); err != nil {
|
|
||||||
state.Set(false, err.Error())
|
|
||||||
} else {
|
|
||||||
state.Set(true, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
check()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
check()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRedis() (*redis.Client, error) {
|
|
||||||
addr := os.Getenv("REDIS_SLAVEOF_IP")
|
|
||||||
port := os.Getenv("REDIS_SLAVEOF_PORT")
|
|
||||||
if addr == "" {
|
|
||||||
addr = "redis-mailcow"
|
|
||||||
port = "6379"
|
|
||||||
}
|
|
||||||
if port == "" {
|
|
||||||
port = "6379"
|
|
||||||
}
|
|
||||||
pass := os.Getenv("REDISPASS")
|
|
||||||
cli := redis.NewClient(&redis.Options{
|
|
||||||
Addr: addr + ":" + port,
|
|
||||||
Password: pass,
|
|
||||||
DB: 0,
|
|
||||||
MaxRetries: -1,
|
|
||||||
MinRetryBackoff: 200 * time.Millisecond,
|
|
||||||
MaxRetryBackoff: 5 * time.Second,
|
|
||||||
})
|
|
||||||
// Wait up to 2 minutes for Redis to come up before giving up
|
|
||||||
deadline := time.Now().Add(2 * time.Minute)
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 1; time.Now().Before(deadline); attempt++ {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
||||||
err := cli.Ping(ctx).Err()
|
|
||||||
cancel()
|
|
||||||
if err == nil {
|
|
||||||
return cli, nil
|
|
||||||
}
|
|
||||||
lastErr = err
|
|
||||||
wait := time.Duration(attempt) * time.Second
|
|
||||||
if wait > 10*time.Second {
|
|
||||||
wait = 10 * time.Second
|
|
||||||
}
|
|
||||||
log.Printf("mailcow-agent: waiting for redis %s (attempt %d): %v", addr, attempt, err)
|
|
||||||
time.Sleep(wait)
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("ping %s after 2m: %w", addr, lastErr)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
module github.com/mailcow/mailcow-dockerized/agent
|
|
||||||
|
|
||||||
go 1.22
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/oklog/ulid/v2 v2.1.0
|
|
||||||
github.com/redis/go-redis/v9 v9.7.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
|
||||||
)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
|
||||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
|
||||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
|
||||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
// Package bus implements the Redis Pub/Sub control bus: subscribing to the
|
|
||||||
// service's control channel, dispatching envelopes to a commands.Table, and
|
|
||||||
// publishing responses back to env.ReplyTo.
|
|
||||||
package bus
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/envelope"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ControlChannel assembles the per-service control channel.
|
|
||||||
func ControlChannel(service string) string { return "mailcow.control." + service }
|
|
||||||
|
|
||||||
// Server subscribes to a control channel and dispatches commands.
|
|
||||||
type Server struct {
|
|
||||||
rdb *redis.Client
|
|
||||||
table *commands.Table
|
|
||||||
nodeID string
|
|
||||||
dedupe *lru
|
|
||||||
stop chan struct{}
|
|
||||||
stopped sync.Once
|
|
||||||
wg sync.WaitGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewServer wires a fresh server. nodeID is stamped into every Response and is
|
|
||||||
// what the backend's fan-in aggregator uses to attribute results.
|
|
||||||
func NewServer(rdb *redis.Client, table *commands.Table, nodeID string) *Server {
|
|
||||||
return &Server{
|
|
||||||
rdb: rdb,
|
|
||||||
table: table,
|
|
||||||
nodeID: nodeID,
|
|
||||||
dedupe: newLRU(1024),
|
|
||||||
stop: make(chan struct{}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run blocks, subscribing to ControlChannel(service) and dispatching incoming
|
|
||||||
// envelopes concurrently. It returns when ctx is done or Shutdown is called.
|
|
||||||
func (s *Server) Run(ctx context.Context) error {
|
|
||||||
channel := ControlChannel(s.table.Service)
|
|
||||||
sub := s.rdb.Subscribe(ctx, channel)
|
|
||||||
defer sub.Close()
|
|
||||||
if _, err := sub.Receive(ctx); err != nil {
|
|
||||||
return fmt.Errorf("bus: subscribe %s: %w", channel, err)
|
|
||||||
}
|
|
||||||
msgs := sub.Channel()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
s.wg.Wait()
|
|
||||||
return ctx.Err()
|
|
||||||
case <-s.stop:
|
|
||||||
s.wg.Wait()
|
|
||||||
return nil
|
|
||||||
case m, ok := <-msgs:
|
|
||||||
if !ok {
|
|
||||||
s.wg.Wait()
|
|
||||||
return errors.New("bus: subscription channel closed")
|
|
||||||
}
|
|
||||||
s.wg.Add(1)
|
|
||||||
go func(payload string) {
|
|
||||||
defer s.wg.Done()
|
|
||||||
s.dispatch(ctx, payload)
|
|
||||||
}(m.Payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown signals Run to stop and waits for in-flight handlers (bounded by
|
|
||||||
// ctx).
|
|
||||||
func (s *Server) Shutdown(ctx context.Context) error {
|
|
||||||
s.stopped.Do(func() { close(s.stop) })
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() { s.wg.Wait(); close(done) }()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) dispatch(parent context.Context, payload string) {
|
|
||||||
var req envelope.Request
|
|
||||||
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
|
||||||
// Malformed envelope: no RequestID/ReplyTo we can trust — drop.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.RequestID != "" && !s.dedupe.add(req.RequestID) {
|
|
||||||
// Duplicate (retry of an idempotent command): silently absorb.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Per-node targeting: if args.target_node is set and doesn't match us,
|
|
||||||
// drop silently. The intended replica picks it up and replies.
|
|
||||||
if target, ok := req.Args["target_node"].(string); ok && target != "" && target != s.nodeID {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := handlerContext(parent, req.Deadline)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
resp := envelope.Response{RequestID: req.RequestID, OK: true, Node: s.nodeID}
|
|
||||||
|
|
||||||
if h := s.table.Lookup(req.Cmd); h == nil {
|
|
||||||
resp.OK = false
|
|
||||||
resp.Error = fmt.Sprintf("no handler for cmd %q", req.Cmd)
|
|
||||||
resp.ErrorCode = envelope.ErrCodeUnsupportedCommand
|
|
||||||
} else {
|
|
||||||
result, err := runWithRecover(ctx, h, req.Args)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
resp.Result = result
|
|
||||||
case errors.Is(err, commands.ErrNotFound):
|
|
||||||
resp.OK = false
|
|
||||||
resp.Error = err.Error()
|
|
||||||
resp.ErrorCode = envelope.ErrCodeNotFound
|
|
||||||
case errors.Is(err, commands.ErrValidation):
|
|
||||||
resp.OK = false
|
|
||||||
resp.Error = err.Error()
|
|
||||||
resp.ErrorCode = envelope.ErrCodeValidation
|
|
||||||
case errors.Is(err, context.DeadlineExceeded), errors.Is(ctx.Err(), context.DeadlineExceeded):
|
|
||||||
resp.OK = false
|
|
||||||
resp.Error = err.Error()
|
|
||||||
resp.ErrorCode = envelope.ErrCodeTimeout
|
|
||||||
default:
|
|
||||||
resp.OK = false
|
|
||||||
resp.Error = err.Error()
|
|
||||||
resp.ErrorCode = envelope.ErrCodeInternal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
resp.DurationMS = time.Since(start).Milliseconds()
|
|
||||||
|
|
||||||
if req.ReplyTo == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := json.Marshal(resp)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Replies go through a List (RPUSH + EXPIRE), not Pub/Sub. This sidesteps
|
|
||||||
// the "subscribe-before-publish" race and lets the backend fan-in via
|
|
||||||
// BLPOP — important because PhpRedis's subscribe() blocks, so we can't
|
|
||||||
// publish on the same connection after subscribing. Use parent ctx so a
|
|
||||||
// per-handler deadline can't stop us from delivering the timeout response.
|
|
||||||
pipe := s.rdb.Pipeline()
|
|
||||||
pipe.RPush(parent, req.ReplyTo, data)
|
|
||||||
pipe.Expire(parent, req.ReplyTo, 60*time.Second)
|
|
||||||
_, _ = pipe.Exec(parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runWithRecover(ctx context.Context, h commands.Handler, args map[string]any) (out any, err error) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
err = fmt.Errorf("handler panic: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return h(ctx, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handlerContext(parent context.Context, deadline time.Time) (context.Context, context.CancelFunc) {
|
|
||||||
if deadline.IsZero() {
|
|
||||||
return context.WithCancel(parent)
|
|
||||||
}
|
|
||||||
return context.WithDeadline(parent, deadline)
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package bus
|
|
||||||
|
|
||||||
import (
|
|
||||||
"container/list"
|
|
||||||
"sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// lru is a tiny request-id deduplication cache. The bus treats Pub/Sub
|
|
||||||
// retries (same request_id) as no-ops. Not a security boundary — only a
|
|
||||||
// best-effort guard against accidental double-execution.
|
|
||||||
type lru struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
cap int
|
|
||||||
idx map[string]*list.Element
|
|
||||||
list *list.List
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLRU(cap int) *lru {
|
|
||||||
return &lru{cap: cap, idx: make(map[string]*list.Element, cap), list: list.New()}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add returns true if id is new and was inserted; false if it was already
|
|
||||||
// known (caller should skip the duplicate).
|
|
||||||
func (l *lru) add(id string) bool {
|
|
||||||
l.mu.Lock()
|
|
||||||
defer l.mu.Unlock()
|
|
||||||
if _, ok := l.idx[id]; ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
e := l.list.PushFront(id)
|
|
||||||
l.idx[id] = e
|
|
||||||
for l.list.Len() > l.cap {
|
|
||||||
old := l.list.Back()
|
|
||||||
l.list.Remove(old)
|
|
||||||
delete(l.idx, old.Value.(string))
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// Package commands defines the per-service handler table. The bus dispatcher
|
|
||||||
// looks up handlers by name and wraps the result in an envelope.Response.
|
|
||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrNotFound signals that the target (queue id, mailbox, …) doesn't live on
|
|
||||||
// this node. For broadcast operations the aggregator still counts success if
|
|
||||||
// any other node returns ok.
|
|
||||||
var ErrNotFound = errors.New("not_found")
|
|
||||||
|
|
||||||
// ErrValidation indicates a missing or malformed argument.
|
|
||||||
var ErrValidation = errors.New("validation")
|
|
||||||
|
|
||||||
// Handler executes a single command for a service.
|
|
||||||
type Handler func(ctx context.Context, args map[string]any) (any, error)
|
|
||||||
|
|
||||||
// HealthProbe returns nil if the supervised service is healthy, error otherwise.
|
|
||||||
// Shared between the `healthcheck` sub-command and the agent's heartbeat loop.
|
|
||||||
type HealthProbe func(ctx context.Context) error
|
|
||||||
|
|
||||||
// Table is the per-service command registry built once at startup.
|
|
||||||
type Table struct {
|
|
||||||
Service string
|
|
||||||
Handlers map[string]Handler
|
|
||||||
HealthProbe HealthProbe
|
|
||||||
}
|
|
||||||
|
|
||||||
// New constructs an empty table for a service.
|
|
||||||
func New(service string) *Table {
|
|
||||||
return &Table{Service: service, Handlers: make(map[string]Handler)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register adds a handler. Duplicate registration panics — wiring bugs should
|
|
||||||
// be loud.
|
|
||||||
func (t *Table) Register(cmd string, h Handler) {
|
|
||||||
if _, dup := t.Handlers[cmd]; dup {
|
|
||||||
panic("commands: duplicate handler " + t.Service + "/" + cmd)
|
|
||||||
}
|
|
||||||
t.Handlers[cmd] = h
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup returns the handler for cmd or nil.
|
|
||||||
func (t *Table) Lookup(cmd string) Handler {
|
|
||||||
return t.Handlers[cmd]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArgString extracts a required string argument.
|
|
||||||
func ArgString(args map[string]any, key string) (string, error) {
|
|
||||||
v, ok := args[key]
|
|
||||||
if !ok {
|
|
||||||
return "", errArg(key, "missing")
|
|
||||||
}
|
|
||||||
s, ok := v.(string)
|
|
||||||
if !ok || s == "" {
|
|
||||||
return "", errArg(key, "must be non-empty string")
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArgStringOpt returns an optional string argument with a default.
|
|
||||||
func ArgStringOpt(args map[string]any, key, def string) string {
|
|
||||||
if v, ok := args[key]; ok {
|
|
||||||
if s, ok := v.(string); ok && s != "" {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func errArg(key, reason string) error {
|
|
||||||
return &validationError{key: key, reason: reason}
|
|
||||||
}
|
|
||||||
|
|
||||||
type validationError struct{ key, reason string }
|
|
||||||
|
|
||||||
func (e *validationError) Error() string { return "arg " + e.key + ": " + e.reason }
|
|
||||||
func (e *validationError) Is(target error) bool {
|
|
||||||
return target == ErrValidation
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package commands
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RunOptions configures a single Run invocation.
|
|
||||||
type RunOptions struct {
|
|
||||||
// Stdin, if non-nil, is written to the process stdin.
|
|
||||||
Stdin []byte
|
|
||||||
// CombinedOutputCap limits the captured output (truncated at the end).
|
|
||||||
// 0 means unlimited. The agent uses ~1 MiB for cat-queue, smaller for
|
|
||||||
// status-style commands.
|
|
||||||
OutputCap int
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunResult is what every shell-style command returns.
|
|
||||||
type RunResult struct {
|
|
||||||
Stdout string `json:"stdout,omitempty"`
|
|
||||||
Stderr string `json:"stderr,omitempty"`
|
|
||||||
ExitCode int `json:"exit_code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run executes argv[0] argv[1:] under ctx (the bus deadline). It does not
|
|
||||||
// translate exit codes to errors — callers inspect r.ExitCode themselves so
|
|
||||||
// they can map e.g. "queue id not found" exit codes to ErrNotFound.
|
|
||||||
func Run(ctx context.Context, opts RunOptions, argv ...string) (*RunResult, error) {
|
|
||||||
if len(argv) == 0 {
|
|
||||||
return nil, fmt.Errorf("commands.Run: empty argv")
|
|
||||||
}
|
|
||||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
if opts.Stdin != nil {
|
|
||||||
cmd.Stdin = bytes.NewReader(opts.Stdin)
|
|
||||||
}
|
|
||||||
err := cmd.Run()
|
|
||||||
|
|
||||||
out := stdout.String()
|
|
||||||
errOut := stderr.String()
|
|
||||||
if opts.OutputCap > 0 {
|
|
||||||
if len(out) > opts.OutputCap {
|
|
||||||
out = out[:opts.OutputCap] + "\n…(truncated)"
|
|
||||||
}
|
|
||||||
if len(errOut) > opts.OutputCap {
|
|
||||||
errOut = errOut[:opts.OutputCap] + "\n…(truncated)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
exit := 0
|
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
||||||
exit = exitErr.ExitCode()
|
|
||||||
err = nil
|
|
||||||
}
|
|
||||||
return &RunResult{Stdout: out, Stderr: errOut, ExitCode: exit}, err
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
// Package envelope defines the wire format for the mailcow-agent control bus.
|
|
||||||
package envelope
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Request is what the backend publishes on mailcow.control.<service>.
|
|
||||||
type Request struct {
|
|
||||||
Cmd string `json:"cmd"`
|
|
||||||
RequestID string `json:"request_id"`
|
|
||||||
Args map[string]any `json:"args,omitempty"`
|
|
||||||
ReplyTo string `json:"reply_to,omitempty"`
|
|
||||||
Deadline time.Time `json:"deadline,omitempty"`
|
|
||||||
IssuedBy string `json:"issued_by,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response is what the agent publishes on the reply_to channel.
|
|
||||||
type Response struct {
|
|
||||||
RequestID string `json:"request_id"`
|
|
||||||
OK bool `json:"ok"`
|
|
||||||
Result any `json:"result,omitempty"`
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
ErrorCode string `json:"error_code,omitempty"`
|
|
||||||
DurationMS int64 `json:"duration_ms"`
|
|
||||||
Node string `json:"node,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error codes returned in Response.ErrorCode. Keep in sync with the V2 schema.
|
|
||||||
const (
|
|
||||||
ErrCodeValidation = "validation"
|
|
||||||
ErrCodeNotFound = "not_found"
|
|
||||||
ErrCodeTimeout = "timeout"
|
|
||||||
ErrCodeUnsupportedCommand = "unsupported_command"
|
|
||||||
ErrCodeInternal = "internal"
|
|
||||||
)
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
// Package proc supervises the service's main process — postfix, dovecot,
|
|
||||||
// nginx, … — as a child of the agent. It exposes the high-level lifecycle
|
|
||||||
// verbs (reload/restart/stop/start) used by the per-service command tables.
|
|
||||||
//
|
|
||||||
// "reload" → SIGHUP
|
|
||||||
// "restart" → SIGTERM, wait, exec again
|
|
||||||
// "stop" → SIGTERM, leave stopped
|
|
||||||
// "start" → exec again (only if currently stopped)
|
|
||||||
package proc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Supervisor wraps a single child process.
|
|
||||||
type Supervisor struct {
|
|
||||||
cmdLine string // shell command (passed to `sh -c …`)
|
|
||||||
stopSignal os.Signal
|
|
||||||
stopGrace time.Duration
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
cmd *exec.Cmd
|
|
||||||
stopped bool
|
|
||||||
exitedCh chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// New constructs a Supervisor. cmdLine is executed via `sh -c` so existing
|
|
||||||
// docker-entrypoint.sh scripts keep working without quoting headaches.
|
|
||||||
func New(cmdLine string) *Supervisor {
|
|
||||||
return &Supervisor{
|
|
||||||
cmdLine: cmdLine,
|
|
||||||
stopSignal: syscall.SIGTERM,
|
|
||||||
stopGrace: 30 * time.Second,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start launches the child process. Returns an error if it cannot be spawned.
|
|
||||||
// The agent's main() also blocks on Wait() to surface exit status.
|
|
||||||
func (s *Supervisor) Start() error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.cmd != nil && s.cmd.Process != nil && !s.stopped {
|
|
||||||
return errors.New("proc: already running")
|
|
||||||
}
|
|
||||||
// `exec ` prefix tells the shell to replace itself with the command
|
|
||||||
// instead of forking and waiting. Without it, sh stays alive as the
|
|
||||||
// parent of the real service process, signals from us land on the
|
|
||||||
// shell instead of on the service, and SIGHUP for config reloads
|
|
||||||
// silently does nothing. With the prefix the supervised PID *is* the
|
|
||||||
// service after the script's own `exec "$@"` chains through.
|
|
||||||
cmd := exec.Command("/bin/sh", "-c", "exec "+s.cmdLine)
|
|
||||||
cmd.Stdout = os.Stdout
|
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return fmt.Errorf("proc: start: %w", err)
|
|
||||||
}
|
|
||||||
s.cmd = cmd
|
|
||||||
s.stopped = false
|
|
||||||
s.exitedCh = make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
_ = cmd.Wait()
|
|
||||||
close(s.exitedCh)
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait blocks until the child exits and returns its exit code.
|
|
||||||
func (s *Supervisor) Wait() int {
|
|
||||||
s.mu.Lock()
|
|
||||||
exited := s.exitedCh
|
|
||||||
cmd := s.cmd
|
|
||||||
s.mu.Unlock()
|
|
||||||
if exited == nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
<-exited
|
|
||||||
if cmd == nil || cmd.ProcessState == nil {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
return cmd.ProcessState.ExitCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignalChild forwards a single signal to the supervised child without
|
|
||||||
// changing the supervisor's lifecycle state. Used to relay SIGHUP/USR1/USR2
|
|
||||||
// from the agent's signal handler to the service so operators can still
|
|
||||||
// `docker compose kill -s HUP postfix-mailcow` and see the expected effect.
|
|
||||||
func (s *Supervisor) SignalChild(sig os.Signal) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.cmd == nil || s.cmd.Process == nil || s.stopped {
|
|
||||||
return errors.New("proc: not running")
|
|
||||||
}
|
|
||||||
return s.cmd.Process.Signal(sig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload sends SIGHUP. Returns nil if the signal was delivered.
|
|
||||||
func (s *Supervisor) Reload() error {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.cmd == nil || s.cmd.Process == nil || s.stopped {
|
|
||||||
return errors.New("proc: not running")
|
|
||||||
}
|
|
||||||
return s.cmd.Process.Signal(syscall.SIGHUP)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop sends the configured stop signal and waits for the process to exit
|
|
||||||
// (bounded by stopGrace). Marks the supervisor as stopped — Start must be
|
|
||||||
// called again to relaunch.
|
|
||||||
func (s *Supervisor) Stop(ctx context.Context) error {
|
|
||||||
return s.StopWithSignal(ctx, s.stopSignal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopWithSignal is like Stop but lets the caller override the stop signal.
|
|
||||||
// Used by main() to forward whatever signal Docker sent us (SIGTERM for
|
|
||||||
// most containers, SIGQUIT for php-fpm-alpine which uses SIGQUIT for
|
|
||||||
// graceful shutdown) so the child gets the same signal semantics it would
|
|
||||||
// receive without the agent in front of it.
|
|
||||||
func (s *Supervisor) StopWithSignal(ctx context.Context, sig os.Signal) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
cmd := s.cmd
|
|
||||||
exited := s.exitedCh
|
|
||||||
if cmd == nil || cmd.Process == nil {
|
|
||||||
s.mu.Unlock()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
s.stopped = true
|
|
||||||
s.mu.Unlock()
|
|
||||||
|
|
||||||
sysSig, ok := sig.(syscall.Signal)
|
|
||||||
if !ok {
|
|
||||||
sysSig = syscall.SIGTERM
|
|
||||||
}
|
|
||||||
pgid, err := syscall.Getpgid(cmd.Process.Pid)
|
|
||||||
if err == nil {
|
|
||||||
_ = syscall.Kill(-pgid, sysSig)
|
|
||||||
} else {
|
|
||||||
_ = cmd.Process.Signal(sysSig)
|
|
||||||
}
|
|
||||||
|
|
||||||
timer := time.NewTimer(s.stopGrace)
|
|
||||||
defer timer.Stop()
|
|
||||||
select {
|
|
||||||
case <-exited:
|
|
||||||
return nil
|
|
||||||
case <-timer.C:
|
|
||||||
// Last resort: SIGKILL the whole process group.
|
|
||||||
if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil {
|
|
||||||
_ = syscall.Kill(-pgid, syscall.SIGKILL)
|
|
||||||
} else {
|
|
||||||
_ = cmd.Process.Kill()
|
|
||||||
}
|
|
||||||
<-exited
|
|
||||||
return errors.New("proc: forced kill after grace period")
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restart performs Stop+Start using the supervisor's default stop signal.
|
|
||||||
// Different from a Docker-initiated shutdown: here it's an explicit "restart
|
|
||||||
// this service" command, so we want the standard SIGTERM semantics.
|
|
||||||
func (s *Supervisor) Restart(ctx context.Context) error {
|
|
||||||
if err := s.Stop(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return s.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRunning reports whether the supervised child is currently alive (started
|
|
||||||
// and not yet exited or stopped).
|
|
||||||
func (s *Supervisor) IsRunning() bool {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.stopped || s.cmd == nil || s.cmd.Process == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
// exitedCh is closed when the child exits. Non-blocking read.
|
|
||||||
select {
|
|
||||||
case <-s.exitedCh:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WaitStable blocks for `settle` and returns nil if the supervised child is
|
|
||||||
// still running at the end, otherwise an error describing the exit. Used by
|
|
||||||
// the `restart` command to give the operator real "did it come back up"
|
|
||||||
// feedback instead of an immediate OK.
|
|
||||||
func (s *Supervisor) WaitStable(ctx context.Context, settle time.Duration) error {
|
|
||||||
s.mu.Lock()
|
|
||||||
exited := s.exitedCh
|
|
||||||
s.mu.Unlock()
|
|
||||||
if exited == nil {
|
|
||||||
return errors.New("proc: not running")
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-exited:
|
|
||||||
// Child died within the settle window.
|
|
||||||
s.mu.Lock()
|
|
||||||
cmd := s.cmd
|
|
||||||
s.mu.Unlock()
|
|
||||||
code := -1
|
|
||||||
if cmd != nil && cmd.ProcessState != nil {
|
|
||||||
code = cmd.ProcessState.ExitCode()
|
|
||||||
}
|
|
||||||
return fmt.Errorf("proc: child exited within settle window (code=%d)", code)
|
|
||||||
case <-time.After(settle):
|
|
||||||
return nil
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward installs a signal forwarder: SIGINT/SIGTERM/SIGHUP/SIGUSR1/SIGUSR2
|
|
||||||
// received by the agent are propagated to the child. Returns a cancel func
|
|
||||||
// to release the handler.
|
|
||||||
func (s *Supervisor) Forward(signals ...os.Signal) func() {
|
|
||||||
ch := make(chan os.Signal, len(signals)+1)
|
|
||||||
signalNotify(ch, signals...)
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
return
|
|
||||||
case sig := <-ch:
|
|
||||||
s.mu.Lock()
|
|
||||||
cmd := s.cmd
|
|
||||||
s.mu.Unlock()
|
|
||||||
if cmd != nil && cmd.Process != nil {
|
|
||||||
_ = cmd.Process.Signal(sig)
|
|
||||||
}
|
|
||||||
if sig == syscall.SIGTERM || sig == syscall.SIGINT {
|
|
||||||
// On terminal signals propagate and let main exit.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return func() {
|
|
||||||
close(done)
|
|
||||||
signalStop(ch)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package proc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Indirection so tests can stub these out if ever needed.
|
|
||||||
var (
|
|
||||||
signalNotify = signal.Notify
|
|
||||||
signalStop = signal.Stop
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = os.Stdout // anchor import for go vet
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
// Package registry publishes per-node heartbeats to Redis so the backend can
|
|
||||||
// enumerate live containers. Two keys per service:
|
|
||||||
//
|
|
||||||
// ZSET mailcow.nodes.<service> score=unix_ts member=node_id
|
|
||||||
// HASH mailcow.node.<service>.<node_id> { version, started_at, image, health* }
|
|
||||||
//
|
|
||||||
// Both keys have a 30s TTL refreshed every 10s. Deregister clears them on
|
|
||||||
// graceful shutdown.
|
|
||||||
package registry
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HealthSnapshotter returns the latest health probe result so the heartbeat
|
|
||||||
// can attach it to each tick. Implemented by main.healthState.
|
|
||||||
type HealthSnapshotter interface {
|
|
||||||
Snapshot() (ok bool, detail string, at time.Time)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heartbeat carries the metadata published with every refresh.
|
|
||||||
type Heartbeat struct {
|
|
||||||
Service string
|
|
||||||
NodeID string
|
|
||||||
Version string
|
|
||||||
StartedAt time.Time
|
|
||||||
Image string
|
|
||||||
Health HealthSnapshotter // optional; nil → omit health fields
|
|
||||||
}
|
|
||||||
|
|
||||||
func nodesKey(service string) string { return "mailcow.nodes." + service }
|
|
||||||
func nodeKey(service, node string) string { return "mailcow.node." + service + "." + node }
|
|
||||||
|
|
||||||
// Publish writes one heartbeat tick. Callers run this in a loop.
|
|
||||||
func Publish(ctx context.Context, rdb *redis.Client, h Heartbeat) error {
|
|
||||||
now := time.Now().Unix()
|
|
||||||
fields := map[string]any{
|
|
||||||
"version": h.Version,
|
|
||||||
"started_at": h.StartedAt.UTC().Format(time.RFC3339),
|
|
||||||
"image": h.Image,
|
|
||||||
"node_id": h.NodeID,
|
|
||||||
"service": h.Service,
|
|
||||||
"updated_at": strconv.FormatInt(now, 10),
|
|
||||||
}
|
|
||||||
if h.Health != nil {
|
|
||||||
ok, detail, at := h.Health.Snapshot()
|
|
||||||
if ok {
|
|
||||||
fields["health"] = "ok"
|
|
||||||
} else {
|
|
||||||
fields["health"] = "fail"
|
|
||||||
}
|
|
||||||
fields["health_detail"] = detail
|
|
||||||
fields["health_at"] = strconv.FormatInt(at.Unix(), 10)
|
|
||||||
}
|
|
||||||
pipe := rdb.Pipeline()
|
|
||||||
pipe.ZAdd(ctx, nodesKey(h.Service), redis.Z{Score: float64(now), Member: h.NodeID})
|
|
||||||
pipe.Expire(ctx, nodesKey(h.Service), 5*time.Minute)
|
|
||||||
pipe.HSet(ctx, nodeKey(h.Service, h.NodeID), fields)
|
|
||||||
pipe.Expire(ctx, nodeKey(h.Service, h.NodeID), 30*time.Second)
|
|
||||||
_, err := pipe.Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("registry: heartbeat exec: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deregister removes the node from the ZSET and deletes its detail hash.
|
|
||||||
// Called on graceful shutdown so the dashboard reflects intentional stops
|
|
||||||
// immediately rather than waiting for TTL.
|
|
||||||
func Deregister(ctx context.Context, rdb *redis.Client, service, nodeID string) error {
|
|
||||||
pipe := rdb.Pipeline()
|
|
||||||
pipe.ZRem(ctx, nodesKey(service), nodeID)
|
|
||||||
pipe.Del(ctx, nodeKey(service, nodeID))
|
|
||||||
_, err := pipe.Exec(ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loop runs Publish on a ticker until ctx is done. It is the typical caller.
|
|
||||||
func Loop(ctx context.Context, rdb *redis.Client, h Heartbeat, interval time.Duration) {
|
|
||||||
// Publish once immediately so the dashboard sees us right away.
|
|
||||||
_ = Publish(ctx, rdb, h)
|
|
||||||
t := time.NewTicker(interval)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
_ = Publish(ctx, rdb, h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// nowStamp returns a sortable timestamp used to suffix moved/garbage maildirs
|
|
||||||
// so repeated cleanups don't collide.
|
|
||||||
func nowStamp() string {
|
|
||||||
return time.Now().UTC().Format("20060102T150405Z")
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() { Register("dovecot", buildDovecot) }
|
|
||||||
|
|
||||||
const vmailRoot = "/var/vmail"
|
|
||||||
|
|
||||||
func dovecotHealthProbe(ctx context.Context) error {
|
|
||||||
// IMAP greeting on :143 — must be "* OK ..."
|
|
||||||
conn, err := net.DialTimeout("tcp", "127.0.0.1:143", 3*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
buf := make([]byte, 64)
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
|
||||||
n, err := conn.Read(buf)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read greeting: %w", err)
|
|
||||||
}
|
|
||||||
greeting := string(buf[:n])
|
|
||||||
if !strings.HasPrefix(greeting, "* OK") {
|
|
||||||
return fmt.Errorf("unexpected greeting: %s", strings.TrimSpace(greeting))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildDovecot(sup *proc.Supervisor) *commands.Table {
|
|
||||||
t := commands.New("dovecot")
|
|
||||||
t.HealthProbe = dovecotHealthProbe
|
|
||||||
|
|
||||||
// `dovecot reload` re-reads config without restarting the master process.
|
|
||||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "dovecot", "reload")
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
addLifecycleExceptReload(t, sup)
|
|
||||||
|
|
||||||
t.Register("exec.fts-rescan", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
user := commands.ArgStringOpt(args, "user", "")
|
|
||||||
argv := []string{"doveadm", "fts", "rescan"}
|
|
||||||
if user != "" {
|
|
||||||
argv = append(argv, "-u", user)
|
|
||||||
} else {
|
|
||||||
argv = append(argv, "-A")
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, argv...)
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.sieve-list", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
user, err := commands.ArgString(args, "user")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "sieve", "list", "-u", user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if r.ExitCode != 0 {
|
|
||||||
return nil, &runError{msg: strings.TrimSpace(r.Stderr)}
|
|
||||||
}
|
|
||||||
scripts := splitNonEmpty(r.Stdout)
|
|
||||||
return map[string]any{"scripts": scripts}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.sieve-print", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
user, err := commands.ArgString(args, "user")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
script, err := commands.ArgString(args, "script")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 1 << 20}, "doveadm", "sieve", "get", "-u", user, script)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if r.ExitCode != 0 {
|
|
||||||
return nil, &runError{msg: strings.TrimSpace(r.Stderr)}
|
|
||||||
}
|
|
||||||
return map[string]any{"body": r.Stdout}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.acl-get", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
user, err := commands.ArgString(args, "user")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// First enumerate mailboxes, then collect ACLs per mailbox.
|
|
||||||
boxes, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "mailbox", "list", "-u", user)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if boxes.ExitCode != 0 {
|
|
||||||
return nil, &runError{msg: strings.TrimSpace(boxes.Stderr)}
|
|
||||||
}
|
|
||||||
out := []map[string]any{}
|
|
||||||
for _, mbx := range splitNonEmpty(boxes.Stdout) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "get", "-u", user, mbx)
|
|
||||||
if err != nil || r.ExitCode != 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(r.Stdout), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "ID") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) >= 2 {
|
|
||||||
out = append(out, map[string]any{
|
|
||||||
"mailbox": mbx,
|
|
||||||
"identifier": fields[0],
|
|
||||||
"rights": strings.Join(fields[1:], " "),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map[string]any{"acls": out}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.acl-set", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
user, err := commands.ArgString(args, "user")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mailbox, err := commands.ArgString(args, "mailbox")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
identifier, err := commands.ArgString(args, "identifier")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rights, err := commands.ArgString(args, "rights")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "set", "-u", user, mailbox, identifier, rights)
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.acl-delete", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
user, err := commands.ArgString(args, "user")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
mailbox, err := commands.ArgString(args, "mailbox")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
identifier, err := commands.ArgString(args, "identifier")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "delete", "-u", user, mailbox, identifier)
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.maildir-cleanup", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
maildir, err := commands.ArgString(args, "maildir")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := assertSafeMaildirPath(maildir); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
src := filepath.Join(vmailRoot, maildir)
|
|
||||||
dst := filepath.Join(vmailRoot, "_garbage", maildir+"_"+nowStamp())
|
|
||||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
|
||||||
return nil, commands.ErrNotFound
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o770); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, os.Rename(src, dst)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.df", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
dir := commands.ArgStringOpt(args, "dir", "/var/vmail")
|
|
||||||
var st syscall.Statfs_t
|
|
||||||
if err := syscall.Statfs(dir, &st); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
size := uint64(st.Blocks) * uint64(st.Bsize)
|
|
||||||
free := uint64(st.Bavail) * uint64(st.Bsize)
|
|
||||||
used := size - free
|
|
||||||
pct := 0
|
|
||||||
if size > 0 {
|
|
||||||
pct = int(float64(used) / float64(size) * 100)
|
|
||||||
}
|
|
||||||
// Format: Filesystem,Size,Used,Avail,Use%,Mounted-on
|
|
||||||
return fmt.Sprintf("%s,%s,%s,%s,%d%%,%s",
|
|
||||||
"local", humanBytes(size), humanBytes(used), humanBytes(free), pct, dir), nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.maildir-move", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
from, err := commands.ArgString(args, "from")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
to, err := commands.ArgString(args, "to")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := assertSafeMaildirPath(from); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := assertSafeMaildirPath(to); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
src := filepath.Join(vmailRoot, from)
|
|
||||||
dst := filepath.Join(vmailRoot, to)
|
|
||||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
|
||||||
return nil, commands.ErrNotFound
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dst), 0o770); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, os.Rename(src, dst)
|
|
||||||
})
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
// addLifecycleExceptReload wires restart/stop/start without overriding reload,
|
|
||||||
// which postfix/dovecot define themselves (canonical CLI command).
|
|
||||||
func addLifecycleExceptReload(t *commands.Table, sup *proc.Supervisor) {
|
|
||||||
if sup == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return nil, sup.Restart(ctx)
|
|
||||||
})
|
|
||||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return nil, sup.Stop(ctx)
|
|
||||||
})
|
|
||||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return nil, sup.Start()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitNonEmpty(s string) []string {
|
|
||||||
out := []string{}
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line != "" {
|
|
||||||
out = append(out, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertSafeMaildirPath blocks path traversal and absolute paths — relative
|
|
||||||
// names under /var/vmail only.
|
|
||||||
func assertSafeMaildirPath(p string) error {
|
|
||||||
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "..") {
|
|
||||||
return &validationErr{msg: "unsafe maildir path"}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type validationErr struct{ msg string }
|
|
||||||
|
|
||||||
func (e *validationErr) Error() string { return e.msg }
|
|
||||||
func (e *validationErr) Is(target error) bool { return target == commands.ErrValidation }
|
|
||||||
|
|
||||||
// humanBytes renders a byte count in `df -H` style (1000-based units).
|
|
||||||
func humanBytes(n uint64) string {
|
|
||||||
const unit = 1000
|
|
||||||
if n < unit {
|
|
||||||
return fmt.Sprintf("%dB", n)
|
|
||||||
}
|
|
||||||
div, exp := uint64(unit), 0
|
|
||||||
for x := n / unit; x >= unit; x /= unit {
|
|
||||||
div *= unit
|
|
||||||
exp++
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%.1f%c", float64(n)/float64(div), "KMGTPE"[exp])
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Services without any exec.* commands of their own — lifecycle only.
|
|
||||||
func init() {
|
|
||||||
Register("clamd", genericBuilder("clamd", tcpProbe("127.0.0.1:3310", 2*time.Second)))
|
|
||||||
Register("olefy", genericBuilder("olefy", tcpProbe("127.0.0.1:10055", 2*time.Second)))
|
|
||||||
Register("postfix-tlspol", genericBuilder("postfix-tlspol", tcpProbe("127.0.0.1:8642", 2*time.Second)))
|
|
||||||
Register("php-fpm", genericBuilder("php-fpm", tcpProbe("127.0.0.1:9001", 2*time.Second)))
|
|
||||||
Register("acme", genericBuilder("acme", nil))
|
|
||||||
Register("watchdog", genericBuilder("watchdog", nil))
|
|
||||||
Register("netfilter", genericBuilder("netfilter", nil))
|
|
||||||
Register("ofelia", genericBuilder("ofelia", nil))
|
|
||||||
Register("dovecot-fts", genericBuilder("dovecot-fts", nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
func genericBuilder(name string, probe commands.HealthProbe) Builder {
|
|
||||||
return func(sup *proc.Supervisor) *commands.Table {
|
|
||||||
t := commands.New(name)
|
|
||||||
t.HealthProbe = probe
|
|
||||||
addLifecycle(t, sup)
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func tcpProbe(addr string, timeout time.Duration) commands.HealthProbe {
|
|
||||||
return func(ctx context.Context) error {
|
|
||||||
return probeTCP(addr, timeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
)
|
|
||||||
|
|
||||||
// runError is what we return when a shell command exited non-zero but the
|
|
||||||
// failure is not a "target not found" case. The bus maps it to
|
|
||||||
// ErrCodeInternal.
|
|
||||||
type runError struct{ msg string }
|
|
||||||
|
|
||||||
func (e *runError) Error() string { return e.msg }
|
|
||||||
|
|
||||||
// asError converts a (RunResult, err) pair from commands.Run into a single
|
|
||||||
// error: pre-exec error → return as-is; non-zero exit → wrap stderr.
|
|
||||||
func asError(r *commands.RunResult, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if r.ExitCode != 0 {
|
|
||||||
msg := strings.TrimSpace(r.Stderr)
|
|
||||||
if msg == "" {
|
|
||||||
msg = "command exited " + itoa(r.ExitCode)
|
|
||||||
}
|
|
||||||
return &runError{msg: msg}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// asNotFoundOrError is the variant for queue/mailbox operations that may fail
|
|
||||||
// because the target doesn't live on this node. Maps known stderr fragments
|
|
||||||
// to commands.ErrNotFound so broadcast aggregation works.
|
|
||||||
func asNotFoundOrError(r *commands.RunResult, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if r.ExitCode == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if matchesAny(r.Stderr, notFoundFragments) {
|
|
||||||
return commands.ErrNotFound
|
|
||||||
}
|
|
||||||
return &runError{msg: strings.TrimSpace(r.Stderr)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func matchesAny(haystack string, fragments []string) bool {
|
|
||||||
for _, f := range fragments {
|
|
||||||
if strings.Contains(haystack, f) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func itoa(i int) string {
|
|
||||||
// avoid strconv import for a one-shot; small ints only
|
|
||||||
if i == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
neg := false
|
|
||||||
if i < 0 {
|
|
||||||
neg = true
|
|
||||||
i = -i
|
|
||||||
}
|
|
||||||
var b [20]byte
|
|
||||||
n := len(b)
|
|
||||||
for i > 0 {
|
|
||||||
n--
|
|
||||||
b[n] = byte('0' + i%10)
|
|
||||||
i /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
n--
|
|
||||||
b[n] = '-'
|
|
||||||
}
|
|
||||||
return string(b[n:])
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() { Register("host", buildHost) }
|
|
||||||
|
|
||||||
// hostProcRoot is where the host-agent container mounts /proc. If we're not
|
|
||||||
// running as host-agent, falling back to /proc still produces sensible numbers
|
|
||||||
// (the container's own view) so dashboards don't blank out in unit tests.
|
|
||||||
var hostProcRoot = "/host/proc"
|
|
||||||
|
|
||||||
func resolveProc(p string) string {
|
|
||||||
if _, err := os.Stat(hostProcRoot); err == nil {
|
|
||||||
return hostProcRoot + p
|
|
||||||
}
|
|
||||||
return "/proc" + p
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildHost(_ *proc.Supervisor) *commands.Table {
|
|
||||||
t := commands.New("host")
|
|
||||||
// No lifecycle — the host-agent container has no main process to manage.
|
|
||||||
|
|
||||||
t.Register("exec.df", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
path := commands.ArgStringOpt(args, "path", "/")
|
|
||||||
var stat syscall.Statfs_t
|
|
||||||
if err := syscall.Statfs(path, &stat); err != nil {
|
|
||||||
return nil, fmt.Errorf("statfs %s: %w", path, err)
|
|
||||||
}
|
|
||||||
size := int64(stat.Blocks) * int64(stat.Bsize)
|
|
||||||
free := int64(stat.Bavail) * int64(stat.Bsize)
|
|
||||||
used := size - free
|
|
||||||
return map[string]any{
|
|
||||||
"path": path,
|
|
||||||
"size": size,
|
|
||||||
"used": used,
|
|
||||||
"available": free,
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.host-stats", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return readHostStats()
|
|
||||||
})
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func readHostStats() (map[string]any, error) {
|
|
||||||
out := map[string]any{
|
|
||||||
"system_time": time.Now().Format("2006-01-02 15:04:05"),
|
|
||||||
"architecture": readArchitecture(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if uptime, err := readUptime(); err == nil {
|
|
||||||
out["uptime"] = int64(uptime)
|
|
||||||
} else {
|
|
||||||
out["uptime"] = int64(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
cores := readCPUCores()
|
|
||||||
cpuUsage, _ := sampleHostCPU(500 * time.Millisecond)
|
|
||||||
out["cpu"] = map[string]any{
|
|
||||||
"cores": cores,
|
|
||||||
"usage": cpuUsage,
|
|
||||||
}
|
|
||||||
|
|
||||||
memTotal, memUsage := readMemoryTotalAndUsagePct()
|
|
||||||
out["memory"] = map[string]any{
|
|
||||||
"total": memTotal, // bytes
|
|
||||||
"usage": memUsage, // percent 0..100
|
|
||||||
}
|
|
||||||
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readArchitecture returns the host's machine architecture (e.g. "x86_64",
|
|
||||||
// "aarch64"). Falls back to a single dash if syscall.Uname fails.
|
|
||||||
func readArchitecture() string {
|
|
||||||
var u syscall.Utsname
|
|
||||||
if err := syscall.Uname(&u); err != nil {
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
return charsToString(u.Machine[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func charsToString(b []int8) string {
|
|
||||||
out := make([]byte, 0, len(b))
|
|
||||||
for _, c := range b {
|
|
||||||
if c == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out = append(out, byte(c))
|
|
||||||
}
|
|
||||||
return string(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCPUCores counts `^processor` lines in /proc/cpuinfo. On a container
|
|
||||||
// with /host/proc bind-mounted this gives the host's logical CPU count,
|
|
||||||
// not the container's cgroup limits.
|
|
||||||
func readCPUCores() int {
|
|
||||||
f, err := os.Open(resolveProc("/cpuinfo"))
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
n := 0
|
|
||||||
sc := bufio.NewScanner(f)
|
|
||||||
for sc.Scan() {
|
|
||||||
if strings.HasPrefix(sc.Text(), "processor") {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// readMemoryTotalAndUsagePct reads /proc/meminfo and returns (total_bytes,
|
|
||||||
// usage_pct_0_100). "Usage" is computed as (Total - Available)/Total which
|
|
||||||
// matches what tools like `free` show as "used".
|
|
||||||
func readMemoryTotalAndUsagePct() (int64, int) {
|
|
||||||
f, err := os.Open(resolveProc("/meminfo"))
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
var total, available int64
|
|
||||||
sc := bufio.NewScanner(f)
|
|
||||||
for sc.Scan() {
|
|
||||||
fields := strings.Fields(sc.Text())
|
|
||||||
if len(fields) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch fields[0] {
|
|
||||||
case "MemTotal:":
|
|
||||||
total = parseInt64(fields[1]) * 1024
|
|
||||||
case "MemAvailable:":
|
|
||||||
available = parseInt64(fields[1]) * 1024
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total <= 0 {
|
|
||||||
return 0, 0
|
|
||||||
}
|
|
||||||
used := total - available
|
|
||||||
if available <= 0 {
|
|
||||||
used = total
|
|
||||||
}
|
|
||||||
pct := int(float64(used) / float64(total) * 100.0)
|
|
||||||
if pct < 0 {
|
|
||||||
pct = 0
|
|
||||||
}
|
|
||||||
if pct > 100 {
|
|
||||||
pct = 100
|
|
||||||
}
|
|
||||||
return total, pct
|
|
||||||
}
|
|
||||||
|
|
||||||
func readUptime() (float64, error) {
|
|
||||||
b, err := os.ReadFile(resolveProc("/uptime"))
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
fields := strings.Fields(string(b))
|
|
||||||
if len(fields) < 1 {
|
|
||||||
return 0, fmt.Errorf("malformed uptime")
|
|
||||||
}
|
|
||||||
return strconv.ParseFloat(fields[0], 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sampleHostCPU returns CPU utilization (0..100) sampled over `window`.
|
|
||||||
func sampleHostCPU(window time.Duration) (float64, error) {
|
|
||||||
a, err := readCPULine()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
time.Sleep(window)
|
|
||||||
b, err := readCPULine()
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
totalA, totalB := sum(a), sum(b)
|
|
||||||
idleA, idleB := a[3], b[3]
|
|
||||||
dTotal, dIdle := totalB-totalA, idleB-idleA
|
|
||||||
if dTotal == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return 100.0 * float64(dTotal-dIdle) / float64(dTotal), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func readCPULine() ([]int64, error) {
|
|
||||||
f, err := os.Open(resolveProc("/stat"))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
sc := bufio.NewScanner(f)
|
|
||||||
if !sc.Scan() {
|
|
||||||
return nil, fmt.Errorf("empty /proc/stat")
|
|
||||||
}
|
|
||||||
fields := strings.Fields(sc.Text())
|
|
||||||
if len(fields) < 5 || fields[0] != "cpu" {
|
|
||||||
return nil, fmt.Errorf("unexpected /proc/stat first line")
|
|
||||||
}
|
|
||||||
out := make([]int64, 0, len(fields)-1)
|
|
||||||
for _, f := range fields[1:] {
|
|
||||||
n, err := strconv.ParseInt(f, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, n)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sum(xs []int64) int64 {
|
|
||||||
var s int64
|
|
||||||
for _, x := range xs {
|
|
||||||
s += x
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseInt64(s string) int64 {
|
|
||||||
n, _ := strconv.ParseInt(s, 10, 64)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() { Register("nginx", buildNginx) }
|
|
||||||
|
|
||||||
func nginxHealthProbe(ctx context.Context) error {
|
|
||||||
if err := probeShell(ctx, 3*time.Second, "nginx", "-t"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return probeTCP("127.0.0.1:8081", 2*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildNginx(sup *proc.Supervisor) *commands.Table {
|
|
||||||
t := commands.New("nginx")
|
|
||||||
t.HealthProbe = nginxHealthProbe
|
|
||||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "nginx", "-s", "reload")
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
addLifecycleExceptReload(t, sup)
|
|
||||||
t.Register("exec.test-config", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "nginx", "-t")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"ok": r.ExitCode == 0,
|
|
||||||
"output": r.Stderr + r.Stdout,
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() { Register("postfix", buildPostfix) }
|
|
||||||
|
|
||||||
// notFoundFragments are substrings emitted by postsuper/postqueue when the
|
|
||||||
// requested queue id doesn't live on this node. Broadcast handlers map them
|
|
||||||
// to commands.ErrNotFound so the backend can count partial success.
|
|
||||||
var notFoundFragments = []string{
|
|
||||||
"No such file or directory",
|
|
||||||
"no such file",
|
|
||||||
"unknown",
|
|
||||||
}
|
|
||||||
|
|
||||||
func postfixHealthProbe(ctx context.Context) error {
|
|
||||||
if err := probeSMTPGreeting("127.0.0.1:25", 3*time.Second); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return probeShell(ctx, 5*time.Second, "postfix", "status")
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildPostfix(sup *proc.Supervisor) *commands.Table {
|
|
||||||
t := commands.New("postfix")
|
|
||||||
t.HealthProbe = postfixHealthProbe
|
|
||||||
|
|
||||||
// Override generic reload — `postfix reload` is the canonical operation,
|
|
||||||
// not SIGHUP-to-supervisord (which would just rotate logs).
|
|
||||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postfix", "reload")
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
// Lifecycle: stop/start/restart still go through the supervisor.
|
|
||||||
if sup != nil {
|
|
||||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return nil, sup.Restart(ctx)
|
|
||||||
})
|
|
||||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return nil, sup.Stop(ctx)
|
|
||||||
})
|
|
||||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return nil, sup.Start()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Register("exec.mailq", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 8 << 20}, "postqueue", "-j")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if r.ExitCode != 0 {
|
|
||||||
return nil, &runError{msg: "postqueue failed: " + r.Stderr}
|
|
||||||
}
|
|
||||||
// postqueue -j prints one JSON object per line.
|
|
||||||
entries := make([]map[string]any, 0)
|
|
||||||
for _, line := range strings.Split(strings.TrimSpace(r.Stdout), "\n") {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var obj map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(line), &obj); err == nil {
|
|
||||||
entries = append(entries, obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map[string]any{"queue": entries}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.flush-queue", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postqueue", "-f")
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.delete-from-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
qid, err := commands.ArgString(args, "queue_id")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-d", qid)
|
|
||||||
return nil, asNotFoundOrError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.hold-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
qid, err := commands.ArgString(args, "queue_id")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-h", qid)
|
|
||||||
return nil, asNotFoundOrError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.unhold-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
qid, err := commands.ArgString(args, "queue_id")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-H", qid)
|
|
||||||
return nil, asNotFoundOrError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.deliver-now", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
qid, err := commands.ArgString(args, "queue_id")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postqueue", "-i", qid)
|
|
||||||
return nil, asNotFoundOrError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.cat-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
qid, err := commands.ArgString(args, "queue_id")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 2 << 20}, "postcat", "-q", qid)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if r.ExitCode != 0 {
|
|
||||||
if matchesAny(r.Stderr, notFoundFragments) {
|
|
||||||
return nil, commands.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, &runError{msg: "postcat failed: " + r.Stderr}
|
|
||||||
}
|
|
||||||
return map[string]any{"body": r.Stdout}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.super-delete", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-d", "ALL")
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
)
|
|
||||||
|
|
||||||
// probeTCP opens a TCP connection to addr within timeout. Returns nil if the
|
|
||||||
// port accepts a connection, otherwise the dial error.
|
|
||||||
func probeTCP(addr string, timeout time.Duration) error {
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = conn.Close()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeSMTPGreeting connects to addr and reads the SMTP greeting line. The
|
|
||||||
// service is considered healthy if the line starts with "220".
|
|
||||||
func probeSMTPGreeting(addr string, timeout time.Duration) error {
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
|
||||||
line, err := bufio.NewReader(conn).ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read greeting: %w", err)
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(line, "220") {
|
|
||||||
return fmt.Errorf("unexpected greeting: %s", strings.TrimSpace(line))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeHTTP issues a GET to url, checks for a 2xx status.
|
|
||||||
func probeHTTP(ctx context.Context, url string, timeout time.Duration) error {
|
|
||||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("http %s", resp.Status)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// probeShell runs argv with a timeout and returns nil if exit code is 0.
|
|
||||||
func probeShell(ctx context.Context, timeout time.Duration, argv ...string) error {
|
|
||||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
r, err := commands.Run(cctx, commands.RunOptions{}, argv...)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if r.ExitCode != 0 {
|
|
||||||
msg := strings.TrimSpace(r.Stderr)
|
|
||||||
if msg == "" {
|
|
||||||
msg = fmt.Sprintf("exit %d", r.ExitCode)
|
|
||||||
}
|
|
||||||
return errors.New(msg)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() { Register("rspamd", buildRspamd) }
|
|
||||||
|
|
||||||
func rspamdHealthProbe(ctx context.Context) error {
|
|
||||||
return probeHTTP(ctx, "http://127.0.0.1:11334/ping", 3*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override file rspamd reads on startup for the controller's enable_password.
|
|
||||||
const rspamdWorkerPasswordPath = "/etc/rspamd/override.d/worker-controller-password.inc"
|
|
||||||
|
|
||||||
func buildRspamd(sup *proc.Supervisor) *commands.Table {
|
|
||||||
t := commands.New("rspamd")
|
|
||||||
t.HealthProbe = rspamdHealthProbe
|
|
||||||
addLifecycle(t, sup)
|
|
||||||
|
|
||||||
t.Register("exec.set-worker-password", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
password, err := commands.ArgString(args, "password")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// rspamadm pw -e -p <pw> writes the hashed value to stdout.
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "rspamadm", "pw", "-e", "-p", password)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if r.ExitCode != 0 {
|
|
||||||
return nil, &runError{msg: "rspamadm pw failed: " + strings.TrimSpace(r.Stderr)}
|
|
||||||
}
|
|
||||||
hash := strings.TrimSpace(r.Stdout)
|
|
||||||
// rspamd distinguishes `password` (read-only access to the controller)
|
|
||||||
// from `enable_password` (write access — restart, settings, learn).
|
|
||||||
content := "enable_password = \"" + hash + "\";\n"
|
|
||||||
if err := os.MkdirAll(filepath.Dir(rspamdWorkerPasswordPath), 0o755); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(rspamdWorkerPasswordPath, []byte(content), 0o644); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Must do a full re-fork of workers (SIGHUP to rspamd master), not
|
|
||||||
// `rspamadm control reload`
|
|
||||||
if sup != nil {
|
|
||||||
return nil, sup.Reload()
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.relearn-spam", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
path, err := commands.ArgString(args, "file")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{Stdin: data}, "rspamc", "learn_spam")
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Register("exec.relearn-ham", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
path, err := commands.ArgString(args, "file")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{Stdin: data}, "rspamc", "learn_ham")
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
// Package services registers per-service command tables. The agent selects
|
|
||||||
// the right table at startup via MAILCOW_AGENT_SERVICE.
|
|
||||||
//
|
|
||||||
// A service "builder" receives a Supervisor for lifecycle commands; services
|
|
||||||
// that don't supervise a main process (currently just "host") pass nil and
|
|
||||||
// the generic lifecycle commands are skipped.
|
|
||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Builder constructs a command table for a service. sup may be nil for
|
|
||||||
// services without a supervised main process.
|
|
||||||
type Builder func(sup *proc.Supervisor) *commands.Table
|
|
||||||
|
|
||||||
var registry = map[string]Builder{}
|
|
||||||
|
|
||||||
// Register installs a builder for a service name. Called from init() in each
|
|
||||||
// per-service file.
|
|
||||||
func Register(service string, b Builder) {
|
|
||||||
if _, dup := registry[service]; dup {
|
|
||||||
panic("services: duplicate registration for " + service)
|
|
||||||
}
|
|
||||||
registry[service] = b
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build returns the table for service, or an error if no builder exists.
|
|
||||||
func Build(service string, sup *proc.Supervisor) (*commands.Table, error) {
|
|
||||||
b, ok := registry[service]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("services: unknown service %q (set MAILCOW_AGENT_SERVICE correctly)", service)
|
|
||||||
}
|
|
||||||
return b(sup), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Known returns the list of registered service names (sorted-ish, depends on
|
|
||||||
// map iteration — for help output only).
|
|
||||||
func Known() []string {
|
|
||||||
out := make([]string, 0, len(registry))
|
|
||||||
for k := range registry {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// restartSettle is how long we wait after a Start to verify the new child
|
|
||||||
// didn't immediately crash. Gives the operator real "did the service come
|
|
||||||
// back up?" feedback instead of an instant OK that hides flapping services.
|
|
||||||
const restartSettle = 3 * time.Second
|
|
||||||
|
|
||||||
// addLifecycle wires reload/restart/stop/start onto t backed by sup. Services
|
|
||||||
// override these (e.g. postfix overrides reload to run `postfix reload`).
|
|
||||||
func addLifecycle(t *commands.Table, sup *proc.Supervisor) {
|
|
||||||
if sup == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return nil, sup.Reload()
|
|
||||||
})
|
|
||||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
if err := sup.Restart(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := sup.WaitStable(ctx, restartSettle); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return map[string]any{"status": "restarted", "settled_ms": int(restartSettle / time.Millisecond)}, nil
|
|
||||||
})
|
|
||||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
return nil, sup.Stop(ctx)
|
|
||||||
})
|
|
||||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
if err := sup.Start(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := sup.WaitStable(ctx, restartSettle); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return map[string]any{"status": "started", "settled_ms": int(restartSettle / time.Millisecond)}, nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() { Register("sogo", buildSogo) }
|
|
||||||
|
|
||||||
func sogoHealthProbe(ctx context.Context) error {
|
|
||||||
return probeHTTP(ctx, "http://127.0.0.1:20000/SOGo.index/", 3*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildSogo(sup *proc.Supervisor) *commands.Table {
|
|
||||||
t := commands.New("sogo")
|
|
||||||
t.HealthProbe = sogoHealthProbe
|
|
||||||
addLifecycle(t, sup)
|
|
||||||
|
|
||||||
t.Register("exec.rename-user", func(ctx context.Context, args map[string]any) (any, error) {
|
|
||||||
oldName, err := commands.ArgString(args, "old")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
newName, err := commands.ArgString(args, "new")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "sogo-tool", "rename-user", oldName, newName)
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
|
||||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() { Register("unbound", buildUnbound) }
|
|
||||||
|
|
||||||
func unboundHealthProbe(ctx context.Context) error {
|
|
||||||
return probeShell(ctx, 3*time.Second, "dig", "+time=2", "+tries=1", "@127.0.0.1", "mailcow.email", "A")
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildUnbound(sup *proc.Supervisor) *commands.Table {
|
|
||||||
t := commands.New("unbound")
|
|
||||||
t.HealthProbe = unboundHealthProbe
|
|
||||||
addLifecycle(t, sup)
|
|
||||||
t.Register("exec.flush-cache", func(ctx context.Context, _ map[string]any) (any, error) {
|
|
||||||
r, err := commands.Run(ctx, commands.RunOptions{}, "unbound-control", "flush_zone", ".")
|
|
||||||
return nil, asError(r, err)
|
|
||||||
})
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
// Package stats reads cgroup CPU + memory usage and publishes them to
|
|
||||||
//
|
|
||||||
// HASH mailcow.stats.<service>.<node_id>
|
|
||||||
//
|
|
||||||
// with a 30s TTL. Supports both cgroup v1 and v2. The numbers are intentionally
|
|
||||||
// approximate — they replace what dockerapi exposed via /containers/<id>/stats.
|
|
||||||
package stats
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Sample is one observation. CPUPercent is the share of one host CPU consumed
|
|
||||||
// since the previous sample (range 0..100*numCPU).
|
|
||||||
type Sample struct {
|
|
||||||
CPUPercent float64
|
|
||||||
MemoryBytes int64
|
|
||||||
MemoryLimit int64
|
|
||||||
Timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func statsKey(service, node string) string { return "mailcow.stats." + service + "." + node }
|
|
||||||
|
|
||||||
// Publisher reads cgroup metrics and pushes them to Redis on a ticker.
|
|
||||||
type Publisher struct {
|
|
||||||
rdb *redis.Client
|
|
||||||
service string
|
|
||||||
node string
|
|
||||||
|
|
||||||
// previous CPU sample to derive a delta-based percent
|
|
||||||
prevCPUNanos int64
|
|
||||||
prevAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPublisher constructs a publisher. Caller drives it via Run.
|
|
||||||
func NewPublisher(rdb *redis.Client, service, node string) *Publisher {
|
|
||||||
return &Publisher{rdb: rdb, service: service, node: node}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run blocks on a ticker until ctx is done.
|
|
||||||
func (p *Publisher) Run(ctx context.Context, interval time.Duration) {
|
|
||||||
t := time.NewTicker(interval)
|
|
||||||
defer t.Stop()
|
|
||||||
// Prime the CPU sample so the first publish has a real delta.
|
|
||||||
if cpu, ok := readCPUNanos(); ok {
|
|
||||||
p.prevCPUNanos = cpu
|
|
||||||
p.prevAt = time.Now()
|
|
||||||
}
|
|
||||||
// Immediate first publish so the dashboard never sees a node without a
|
|
||||||
// stats hash. CPU is 0 in this first sample (no prev delta yet); memory
|
|
||||||
// is already accurate.
|
|
||||||
_ = p.publish(ctx, p.sample())
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
_ = p.publish(ctx, p.sample())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Publisher) sample() Sample {
|
|
||||||
s := Sample{Timestamp: time.Now()}
|
|
||||||
if cpu, ok := readCPUNanos(); ok {
|
|
||||||
if !p.prevAt.IsZero() {
|
|
||||||
dCPU := cpu - p.prevCPUNanos
|
|
||||||
dT := s.Timestamp.Sub(p.prevAt).Nanoseconds()
|
|
||||||
if dT > 0 && dCPU >= 0 {
|
|
||||||
s.CPUPercent = (float64(dCPU) / float64(dT)) * 100.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.prevCPUNanos = cpu
|
|
||||||
p.prevAt = s.Timestamp
|
|
||||||
}
|
|
||||||
if mem, limit, ok := readMemory(); ok {
|
|
||||||
s.MemoryBytes = mem
|
|
||||||
s.MemoryLimit = limit
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Publisher) publish(ctx context.Context, s Sample) error {
|
|
||||||
pipe := p.rdb.Pipeline()
|
|
||||||
pipe.HSet(ctx, statsKey(p.service, p.node), map[string]any{
|
|
||||||
"cpu_percent": strconv.FormatFloat(s.CPUPercent, 'f', 2, 64),
|
|
||||||
"memory_bytes": s.MemoryBytes,
|
|
||||||
"memory_limit": s.MemoryLimit,
|
|
||||||
"timestamp": s.Timestamp.Unix(),
|
|
||||||
"node_id": p.node,
|
|
||||||
"service": p.service,
|
|
||||||
})
|
|
||||||
pipe.Expire(ctx, statsKey(p.service, p.node), 30*time.Second)
|
|
||||||
_, err := pipe.Exec(ctx)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- cgroup readers --------------------------------------------------------
|
|
||||||
|
|
||||||
// readCPUNanos returns total CPU-nanoseconds consumed by the current cgroup,
|
|
||||||
// summed across all CPUs. Works for both cgroup v2 (cpu.stat) and v1
|
|
||||||
// (cpuacct.usage).
|
|
||||||
func readCPUNanos() (int64, bool) {
|
|
||||||
if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil {
|
|
||||||
// v2: lines like "usage_usec 12345"
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
if strings.HasPrefix(line, "usage_usec ") {
|
|
||||||
n, err := strconv.ParseInt(strings.TrimPrefix(line, "usage_usec "), 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
return n * 1000, true // µs → ns
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if data, err := os.ReadFile("/sys/fs/cgroup/cpuacct/cpuacct.usage"); err == nil {
|
|
||||||
n, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
return n, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// readMemory returns current usage and limit in bytes.
|
|
||||||
func readMemory() (int64, int64, bool) {
|
|
||||||
// v2
|
|
||||||
if cur, err := readInt("/sys/fs/cgroup/memory.current"); err == nil {
|
|
||||||
limit, _ := readInt("/sys/fs/cgroup/memory.max")
|
|
||||||
return cur, limit, true
|
|
||||||
}
|
|
||||||
// v1
|
|
||||||
if cur, err := readInt("/sys/fs/cgroup/memory/memory.usage_in_bytes"); err == nil {
|
|
||||||
limit, _ := readInt("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
|
||||||
return cur, limit, true
|
|
||||||
}
|
|
||||||
return 0, 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func readInt(path string) (int64, error) {
|
|
||||||
b, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
s := strings.TrimSpace(string(b))
|
|
||||||
if s == "max" {
|
|
||||||
return -1, nil
|
|
||||||
}
|
|
||||||
return strconv.ParseInt(s, 10, 64)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# mailcow-agent-cli — publish a control-bus command from inside a service
|
|
||||||
# container, optionally collecting one reply. Same wire protocol as the Go
|
|
||||||
# agent (see internal/envelope/envelope.go).
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# mailcow-agent-cli send <service> <cmd> [json-args]
|
|
||||||
# Fire-and-forget. Prints the number of subscribers reached.
|
|
||||||
# mailcow-agent-cli call <service> <cmd> [json-args] [timeout-seconds]
|
|
||||||
# Publish + wait for one reply on its private reply list. Prints the
|
|
||||||
# reply envelope JSON on stdout.
|
|
||||||
#
|
|
||||||
# Requires the `redis-cli` binary to be present in the calling container.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
op="${1:-}"
|
|
||||||
svc="${2:-}"
|
|
||||||
cmd="${3:-}"
|
|
||||||
args="${4:-{\}}"
|
|
||||||
tmo="${5:-10}"
|
|
||||||
|
|
||||||
if [ -z "$op" ] || [ -z "$svc" ] || [ -z "$cmd" ]; then
|
|
||||||
echo "usage: $0 send|call <service> <cmd> [json-args] [timeout-seconds]" >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
|
|
||||||
redis_host="${REDIS_SLAVEOF_IP:-redis-mailcow}"
|
|
||||||
redis_port="${REDIS_SLAVEOF_PORT:-6379}"
|
|
||||||
|
|
||||||
rcli() {
|
|
||||||
if [ -n "${REDISPASS:-}" ]; then
|
|
||||||
redis-cli -h "$redis_host" -p "$redis_port" -a "$REDISPASS" --no-auth-warning "$@"
|
|
||||||
else
|
|
||||||
redis-cli -h "$redis_host" -p "$redis_port" "$@"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
rid="$(date +%s%N)$$"
|
|
||||||
issued_by="$(hostname 2>/dev/null || echo unknown)"
|
|
||||||
|
|
||||||
case "$op" in
|
|
||||||
send)
|
|
||||||
payload="{\"cmd\":\"${cmd}\",\"request_id\":\"${rid}\",\"args\":${args},\"issued_by\":\"${issued_by}\"}"
|
|
||||||
rcli PUBLISH "mailcow.control.${svc}" "$payload"
|
|
||||||
;;
|
|
||||||
call)
|
|
||||||
reply="mailcow.reply.${rid}"
|
|
||||||
payload="{\"cmd\":\"${cmd}\",\"request_id\":\"${rid}\",\"args\":${args},\"reply_to\":\"${reply}\",\"issued_by\":\"${issued_by}\"}"
|
|
||||||
rcli PUBLISH "mailcow.control.${svc}" "$payload" >/dev/null
|
|
||||||
# BLPOP returns two lines: the list name then the value. Print only the value.
|
|
||||||
rcli BLPOP "$reply" "$tmo" 2>/dev/null | tail -n1
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "usage: $0 send|call <service> <cmd> [json-args] [timeout-seconds]" >&2
|
|
||||||
exit 2
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM alpine:3.21 AS builder
|
FROM alpine:3.21 AS builder
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -45,7 +41,7 @@ RUN wget -P /src https://www.clamav.net/downloads/production/clamav-${CLAMD_VERS
|
|||||||
-D ENABLE_MILTER=ON \
|
-D ENABLE_MILTER=ON \
|
||||||
-D ENABLE_MAN_PAGES=OFF \
|
-D ENABLE_MAN_PAGES=OFF \
|
||||||
-D ENABLE_STATIC_LIB=OFF \
|
-D ENABLE_STATIC_LIB=OFF \
|
||||||
-D ENABLE_JSON_SHARED=ON \
|
-D ENABLE_JSON_SHARED=ON \
|
||||||
&& cmake --build . \
|
&& cmake --build . \
|
||||||
&& make DESTDIR="/clamav" -j$(($(nproc) - 1)) install \
|
&& make DESTDIR="/clamav" -j$(($(nproc) - 1)) install \
|
||||||
&& rm -r "/clamav/usr/lib/pkgconfig/" \
|
&& rm -r "/clamav/usr/lib/pkgconfig/" \
|
||||||
@@ -108,15 +104,7 @@ COPY healthcheck.sh /healthcheck.sh
|
|||||||
COPY clamdcheck.sh /usr/local/bin
|
COPY clamdcheck.sh /usr/local/bin
|
||||||
RUN chmod +x /healthcheck.sh
|
RUN chmod +x /healthcheck.sh
|
||||||
RUN chmod +x /usr/local/bin/clamdcheck.sh
|
RUN chmod +x /usr/local/bin/clamdcheck.sh
|
||||||
|
HEALTHCHECK --start-period=6m CMD "/healthcheck.sh"
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
ENTRYPOINT []
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
CMD ["/sbin/tini", "-g", "--", "/clamd.sh"]
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=clamd \
|
|
||||||
MAILCOW_AGENT_MAIN_CMD="/sbin/tini -g -- /clamd.sh"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
FROM alpine:3.21
|
||||||
|
|
||||||
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
|
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --update --no-cache python3 \
|
||||||
|
bash \
|
||||||
|
py3-pip \
|
||||||
|
openssl \
|
||||||
|
tzdata \
|
||||||
|
py3-psutil \
|
||||||
|
py3-redis \
|
||||||
|
py3-async-timeout \
|
||||||
|
supervisor \
|
||||||
|
curl \
|
||||||
|
&& pip3 install --upgrade pip \
|
||||||
|
fastapi \
|
||||||
|
uvicorn \
|
||||||
|
aiodocker \
|
||||||
|
docker
|
||||||
|
|
||||||
|
COPY mailcow-adm/ /app/mailcow-adm/
|
||||||
|
RUN pip3 install -r /app/mailcow-adm/requirements.txt
|
||||||
|
|
||||||
|
COPY api/ /app/api/
|
||||||
|
|
||||||
|
COPY docker-entrypoint.sh /app/
|
||||||
|
COPY supervisord.conf /etc/supervisor/supervisord.conf
|
||||||
|
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/bin/sh", "/app/docker-entrypoint.sh"]
|
||||||
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uvicorn
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import async_timeout
|
||||||
|
import asyncio
|
||||||
|
import aiodocker
|
||||||
|
import docker
|
||||||
|
import logging
|
||||||
|
from logging.config import dictConfig
|
||||||
|
from fastapi import FastAPI, Response, Request
|
||||||
|
from modules.DockerApi import DockerApi
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
dockerapi = None
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
global dockerapi
|
||||||
|
|
||||||
|
# Initialize a custom logger
|
||||||
|
logger = logging.getLogger("dockerapi")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
# Configure the logger to output logs to the terminal
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setLevel(logging.INFO)
|
||||||
|
formatter = logging.Formatter("%(levelname)s: %(message)s")
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
logger.info("Init APP")
|
||||||
|
|
||||||
|
# Init redis client
|
||||||
|
if os.environ['REDIS_SLAVEOF_IP'] != "":
|
||||||
|
redis_client = redis = await aioredis.from_url(f"redis://{os.environ['REDIS_SLAVEOF_IP']}:{os.environ['REDIS_SLAVEOF_PORT']}/0", password=os.environ['REDISPASS'])
|
||||||
|
else:
|
||||||
|
redis_client = redis = await aioredis.from_url("redis://redis-mailcow:6379/0", password=os.environ['REDISPASS'])
|
||||||
|
|
||||||
|
# Init docker clients
|
||||||
|
sync_docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
|
||||||
|
async_docker_client = aiodocker.Docker(url='unix:///var/run/docker.sock')
|
||||||
|
|
||||||
|
dockerapi = DockerApi(redis_client, sync_docker_client, async_docker_client, logger)
|
||||||
|
|
||||||
|
logger.info("Subscribe to redis channel")
|
||||||
|
# Subscribe to redis channel
|
||||||
|
dockerapi.pubsub = redis.pubsub()
|
||||||
|
await dockerapi.pubsub.subscribe("MC_CHANNEL")
|
||||||
|
asyncio.create_task(handle_pubsub_messages(dockerapi.pubsub))
|
||||||
|
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Close docker connections
|
||||||
|
dockerapi.sync_docker_client.close()
|
||||||
|
await dockerapi.async_docker_client.close()
|
||||||
|
|
||||||
|
# Close redis
|
||||||
|
await dockerapi.pubsub.unsubscribe("MC_CHANNEL")
|
||||||
|
await dockerapi.redis_client.close()
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
# Define Routes
|
||||||
|
@app.get("/host/stats")
|
||||||
|
async def get_host_update_stats():
|
||||||
|
global dockerapi
|
||||||
|
|
||||||
|
if dockerapi.host_stats_isUpdating == False:
|
||||||
|
asyncio.create_task(dockerapi.get_host_stats())
|
||||||
|
dockerapi.host_stats_isUpdating = True
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if await dockerapi.redis_client.exists('host_stats'):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
|
||||||
|
stats = json.loads(await dockerapi.redis_client.get('host_stats'))
|
||||||
|
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
|
||||||
|
|
||||||
|
@app.get("/containers/{container_id}/json")
|
||||||
|
async def get_container(container_id : str):
|
||||||
|
global dockerapi
|
||||||
|
|
||||||
|
if container_id and container_id.isalnum():
|
||||||
|
try:
|
||||||
|
for container in (await dockerapi.async_docker_client.containers.list()):
|
||||||
|
if container._id == container_id:
|
||||||
|
container_info = await container.show()
|
||||||
|
return Response(content=json.dumps(container_info, indent=4), media_type="application/json")
|
||||||
|
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": "no container found"
|
||||||
|
}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
except Exception as e:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": str(e)
|
||||||
|
}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
else:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": "no or invalid id defined"
|
||||||
|
}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
|
||||||
|
@app.get("/containers/json")
|
||||||
|
async def get_containers():
|
||||||
|
global dockerapi
|
||||||
|
|
||||||
|
containers = {}
|
||||||
|
try:
|
||||||
|
for container in (await dockerapi.async_docker_client.containers.list()):
|
||||||
|
container_info = await container.show()
|
||||||
|
containers.update({container_info['Id']: container_info})
|
||||||
|
return Response(content=json.dumps(containers, indent=4), media_type="application/json")
|
||||||
|
except Exception as e:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": str(e)
|
||||||
|
}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
|
||||||
|
@app.post("/containers/{container_id}/{post_action}")
|
||||||
|
async def post_containers(container_id : str, post_action : str, request: Request):
|
||||||
|
global dockerapi
|
||||||
|
|
||||||
|
try:
|
||||||
|
request_json = await request.json()
|
||||||
|
except Exception as err:
|
||||||
|
request_json = {}
|
||||||
|
|
||||||
|
if container_id and container_id.isalnum() and post_action:
|
||||||
|
try:
|
||||||
|
"""Dispatch container_post api call"""
|
||||||
|
if post_action == 'exec':
|
||||||
|
if not request_json or not 'cmd' in request_json:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": "cmd is missing"
|
||||||
|
}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
if not request_json or not 'task' in request_json:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": "task is missing"
|
||||||
|
}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
|
||||||
|
api_call_method_name = '__'.join(['container_post', str(post_action), str(request_json['cmd']), str(request_json['task']) ])
|
||||||
|
else:
|
||||||
|
api_call_method_name = '__'.join(['container_post', str(post_action) ])
|
||||||
|
|
||||||
|
api_call_method = getattr(dockerapi, api_call_method_name, lambda container_id: Response(content=json.dumps({'type': 'danger', 'msg':'container_post - unknown api call' }, indent=4), media_type="application/json"))
|
||||||
|
|
||||||
|
dockerapi.logger.info("api call: %s, container_id: %s" % (api_call_method_name, container_id))
|
||||||
|
return api_call_method(request_json, container_id=container_id)
|
||||||
|
except Exception as e:
|
||||||
|
dockerapi.logger.error("error - container_post: %s" % str(e))
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": str(e)
|
||||||
|
}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
|
||||||
|
else:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": "invalid container id or missing action"
|
||||||
|
}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
|
||||||
|
@app.post("/container/{container_id}/stats/update")
|
||||||
|
async def post_container_update_stats(container_id : str):
|
||||||
|
global dockerapi
|
||||||
|
|
||||||
|
# start update task for container if no task is running
|
||||||
|
if container_id not in dockerapi.containerIds_to_update:
|
||||||
|
asyncio.create_task(dockerapi.get_container_stats(container_id))
|
||||||
|
dockerapi.containerIds_to_update.append(container_id)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if await dockerapi.redis_client.exists(container_id + '_stats'):
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
|
||||||
|
stats = json.loads(await dockerapi.redis_client.get(container_id + '_stats'))
|
||||||
|
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
|
||||||
|
|
||||||
|
|
||||||
|
# PubSub Handler
|
||||||
|
async def handle_pubsub_messages(channel: aioredis.client.PubSub):
|
||||||
|
global dockerapi
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
async with async_timeout.timeout(60):
|
||||||
|
message = await channel.get_message(ignore_subscribe_messages=True, timeout=30)
|
||||||
|
if message is not None:
|
||||||
|
# Parse message
|
||||||
|
data_json = json.loads(message['data'].decode('utf-8'))
|
||||||
|
dockerapi.logger.info(f"PubSub Received - {json.dumps(data_json)}")
|
||||||
|
|
||||||
|
# Handle api_call
|
||||||
|
if 'api_call' in data_json:
|
||||||
|
# api_call: container_post
|
||||||
|
if data_json['api_call'] == "container_post":
|
||||||
|
if 'post_action' in data_json and 'container_name' in data_json:
|
||||||
|
try:
|
||||||
|
"""Dispatch container_post api call"""
|
||||||
|
request_json = {}
|
||||||
|
if data_json['post_action'] == 'exec':
|
||||||
|
if 'request' in data_json:
|
||||||
|
request_json = data_json['request']
|
||||||
|
if 'cmd' in request_json:
|
||||||
|
if 'task' in request_json:
|
||||||
|
api_call_method_name = '__'.join(['container_post', str(data_json['post_action']), str(request_json['cmd']), str(request_json['task']) ])
|
||||||
|
else:
|
||||||
|
dockerapi.logger.error("api call: task missing")
|
||||||
|
else:
|
||||||
|
dockerapi.logger.error("api call: cmd missing")
|
||||||
|
else:
|
||||||
|
dockerapi.logger.error("api call: request missing")
|
||||||
|
else:
|
||||||
|
api_call_method_name = '__'.join(['container_post', str(data_json['post_action'])])
|
||||||
|
|
||||||
|
if api_call_method_name:
|
||||||
|
api_call_method = getattr(dockerapi, api_call_method_name)
|
||||||
|
if api_call_method:
|
||||||
|
dockerapi.logger.info("api call: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
|
||||||
|
api_call_method(request_json, container_name=data_json['container_name'])
|
||||||
|
else:
|
||||||
|
dockerapi.logger.error("api call not found: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
|
||||||
|
except Exception as e:
|
||||||
|
dockerapi.logger.error("container_post: %s" % str(e))
|
||||||
|
else:
|
||||||
|
dockerapi.logger.error("api call: missing container_name, post_action or request")
|
||||||
|
else:
|
||||||
|
dockerapi.logger.error("Unknown PubSub received - %s" % json.dumps(data_json))
|
||||||
|
else:
|
||||||
|
dockerapi.logger.error("Unknown PubSub received - %s" % json.dumps(data_json))
|
||||||
|
|
||||||
|
await asyncio.sleep(0.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=443,
|
||||||
|
ssl_certfile="/app/controller_cert.pem",
|
||||||
|
ssl_keyfile="/app/controller_key.pem",
|
||||||
|
log_level="info",
|
||||||
|
loop="none"
|
||||||
|
)
|
||||||
@@ -0,0 +1,626 @@
|
|||||||
|
import psutil
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import platform
|
||||||
|
from datetime import datetime
|
||||||
|
from fastapi import FastAPI, Response, Request
|
||||||
|
|
||||||
|
class DockerApi:
|
||||||
|
def __init__(self, redis_client, sync_docker_client, async_docker_client, logger):
|
||||||
|
self.redis_client = redis_client
|
||||||
|
self.sync_docker_client = sync_docker_client
|
||||||
|
self.async_docker_client = async_docker_client
|
||||||
|
self.logger = logger
|
||||||
|
|
||||||
|
self.host_stats_isUpdating = False
|
||||||
|
self.containerIds_to_update = []
|
||||||
|
|
||||||
|
# api call: container_post - post_action: stop
|
||||||
|
def container_post__stop(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||||
|
container.stop()
|
||||||
|
|
||||||
|
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: start
|
||||||
|
def container_post__start(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||||
|
container.start()
|
||||||
|
|
||||||
|
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: restart
|
||||||
|
def container_post__restart(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||||
|
container.restart()
|
||||||
|
|
||||||
|
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: top
|
||||||
|
def container_post__top(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||||
|
res = { 'type': 'success', 'msg': container.top()}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: stats
|
||||||
|
def container_post__stats(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||||
|
for stat in container.stats(decode=True, stream=True):
|
||||||
|
res = { 'type': 'success', 'msg': stat}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: exec - cmd: mailq - task: delete
|
||||||
|
def container_post__exec__mailq__delete(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'items' in request_json:
|
||||||
|
r = re.compile("^[0-9a-fA-F]+$")
|
||||||
|
filtered_qids = filter(r.match, request_json['items'])
|
||||||
|
if filtered_qids:
|
||||||
|
flagged_qids = ['-d %s' % i for i in filtered_qids]
|
||||||
|
sanitized_string = str(' '.join(flagged_qids))
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||||
|
return self.exec_run_handler('generic', postsuper_r)
|
||||||
|
# api call: container_post - post_action: exec - cmd: mailq - task: hold
|
||||||
|
def container_post__exec__mailq__hold(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'items' in request_json:
|
||||||
|
r = re.compile("^[0-9a-fA-F]+$")
|
||||||
|
filtered_qids = filter(r.match, request_json['items'])
|
||||||
|
if filtered_qids:
|
||||||
|
flagged_qids = ['-h %s' % i for i in filtered_qids]
|
||||||
|
sanitized_string = str(' '.join(flagged_qids))
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||||
|
return self.exec_run_handler('generic', postsuper_r)
|
||||||
|
# api call: container_post - post_action: exec - cmd: mailq - task: cat
|
||||||
|
def container_post__exec__mailq__cat(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'items' in request_json:
|
||||||
|
r = re.compile("^[0-9a-fA-F]+$")
|
||||||
|
filtered_qids = filter(r.match, request_json['items'])
|
||||||
|
if filtered_qids:
|
||||||
|
sanitized_string = str(' '.join(filtered_qids))
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
postcat_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postcat -q " + sanitized_string], user='postfix')
|
||||||
|
if not postcat_return:
|
||||||
|
postcat_return = 'err: invalid'
|
||||||
|
return self.exec_run_handler('utf8_text_only', postcat_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: mailq - task: unhold
|
||||||
|
def container_post__exec__mailq__unhold(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'items' in request_json:
|
||||||
|
r = re.compile("^[0-9a-fA-F]+$")
|
||||||
|
filtered_qids = filter(r.match, request_json['items'])
|
||||||
|
if filtered_qids:
|
||||||
|
flagged_qids = ['-H %s' % i for i in filtered_qids]
|
||||||
|
sanitized_string = str(' '.join(flagged_qids))
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||||
|
return self.exec_run_handler('generic', postsuper_r)
|
||||||
|
# api call: container_post - post_action: exec - cmd: mailq - task: deliver
|
||||||
|
def container_post__exec__mailq__deliver(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'items' in request_json:
|
||||||
|
r = re.compile("^[0-9a-fA-F]+$")
|
||||||
|
filtered_qids = filter(r.match, request_json['items'])
|
||||||
|
if filtered_qids:
|
||||||
|
flagged_qids = ['-i %s' % i for i in filtered_qids]
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
for i in flagged_qids:
|
||||||
|
postqueue_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postqueue " + i], user='postfix')
|
||||||
|
# todo: check each exit code
|
||||||
|
res = { 'type': 'success', 'msg': 'Scheduled immediate delivery'}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: exec - cmd: mailq - task: list
|
||||||
|
def container_post__exec__mailq__list(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
mailq_return = container.exec_run(["/usr/sbin/postqueue", "-j"], user='postfix')
|
||||||
|
return self.exec_run_handler('utf8_text_only', mailq_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: mailq - task: flush
|
||||||
|
def container_post__exec__mailq__flush(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
postqueue_r = container.exec_run(["/usr/sbin/postqueue", "-f"], user='postfix')
|
||||||
|
return self.exec_run_handler('generic', postqueue_r)
|
||||||
|
# api call: container_post - post_action: exec - cmd: mailq - task: super_delete
|
||||||
|
def container_post__exec__mailq__super_delete(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
postsuper_r = container.exec_run(["/usr/sbin/postsuper", "-d", "ALL"])
|
||||||
|
return self.exec_run_handler('generic', postsuper_r)
|
||||||
|
# api call: container_post - post_action: exec - cmd: system - task: fts_rescan
|
||||||
|
def container_post__exec__system__fts_rescan(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'username' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -u '" + request_json['username'].replace("'", "'\\''") + "'"], user='vmail')
|
||||||
|
if rescan_return.exit_code == 0:
|
||||||
|
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
else:
|
||||||
|
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
if 'all' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -A"], user='vmail')
|
||||||
|
if rescan_return.exit_code == 0:
|
||||||
|
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
else:
|
||||||
|
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: exec - cmd: system - task: df
|
||||||
|
def container_post__exec__system__df(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'dir' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
df_return = container.exec_run(["/bin/bash", "-c", "/bin/df -H '" + request_json['dir'].replace("'", "'\\''") + "' | /usr/bin/tail -n1 | /usr/bin/tr -s [:blank:] | /usr/bin/tr ' ' ','"], user='nobody')
|
||||||
|
if df_return.exit_code == 0:
|
||||||
|
return df_return.output.decode('utf-8').rstrip()
|
||||||
|
else:
|
||||||
|
return "0,0,0,0,0,0"
|
||||||
|
# api call: container_post - post_action: exec - cmd: system - task: mysql_upgrade
|
||||||
|
def container_post__exec__system__mysql_upgrade(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_upgrade -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "'\n"], user='mysql')
|
||||||
|
if sql_return.exit_code == 0:
|
||||||
|
matched = False
|
||||||
|
for line in sql_return.output.decode('utf-8').split("\n"):
|
||||||
|
if 'is already upgraded to' in line:
|
||||||
|
matched = True
|
||||||
|
if matched:
|
||||||
|
res = { 'type': 'success', 'msg':'mysql_upgrade: already upgraded', 'text': sql_return.output.decode('utf-8')}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
else:
|
||||||
|
container.restart()
|
||||||
|
res = { 'type': 'warning', 'msg':'mysql_upgrade: upgrade was applied', 'text': sql_return.output.decode('utf-8')}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
else:
|
||||||
|
res = { 'type': 'error', 'msg': 'mysql_upgrade: error running command', 'text': sql_return.output.decode('utf-8')}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
|
||||||
|
def container_post__exec__system__mysql_tzinfo_to_sql(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_tzinfo_to_sql /usr/share/zoneinfo | /bin/sed 's/Local time zone must be set--see zic manual page/FCTY/' | /usr/bin/mysql -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "' mysql \n"], user='mysql')
|
||||||
|
if sql_return.exit_code == 0:
|
||||||
|
res = { 'type': 'info', 'msg': 'mysql_tzinfo_to_sql: command completed successfully', 'text': sql_return.output.decode('utf-8')}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
else:
|
||||||
|
res = { 'type': 'error', 'msg': 'mysql_tzinfo_to_sql: error running command', 'text': sql_return.output.decode('utf-8')}
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: exec - cmd: reload - task: dovecot
|
||||||
|
def container_post__exec__reload__dovecot(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
|
||||||
|
return self.exec_run_handler('generic', reload_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: reload - task: postfix
|
||||||
|
def container_post__exec__reload__postfix(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
|
||||||
|
return self.exec_run_handler('generic', reload_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: reload - task: nginx
|
||||||
|
def container_post__exec__reload__nginx(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
|
||||||
|
return self.exec_run_handler('generic', reload_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: sieve - task: list
|
||||||
|
def container_post__exec__sieve__list(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'username' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request_json['username'].replace("'", "'\\''") + "'"])
|
||||||
|
return self.exec_run_handler('utf8_text_only', sieve_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: sieve - task: print
|
||||||
|
def container_post__exec__sieve__print(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'username' in request_json and 'script_name' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]
|
||||||
|
sieve_return = container.exec_run(cmd)
|
||||||
|
return self.exec_run_handler('utf8_text_only', sieve_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
|
||||||
|
def container_post__exec__maildir__cleanup(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'maildir' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
sane_name = re.sub(r'\W+', '', request_json['maildir'])
|
||||||
|
vmail_name = request_json['maildir'].replace("'", "'\\''")
|
||||||
|
cmd_vmail = "if [[ -d '/var/vmail/" + vmail_name + "' ]]; then /bin/mv '/var/vmail/" + vmail_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "'; fi"
|
||||||
|
index_name = request_json['maildir'].split("/")
|
||||||
|
if len(index_name) > 1:
|
||||||
|
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
|
||||||
|
cmd_vmail_index = "if [[ -d '/var/vmail_index/" + index_name + "' ]]; then /bin/mv '/var/vmail_index/" + index_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "_index'; fi"
|
||||||
|
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
|
||||||
|
else:
|
||||||
|
cmd = ["/bin/bash", "-c", cmd_vmail]
|
||||||
|
maildir_cleanup = container.exec_run(cmd, user='vmail')
|
||||||
|
return self.exec_run_handler('generic', maildir_cleanup)
|
||||||
|
# api call: container_post - post_action: exec - cmd: maildir - task: move
|
||||||
|
def container_post__exec__maildir__move(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'old_maildir' in request_json and 'new_maildir' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
vmail_name = request_json['old_maildir'].replace("'", "'\\''")
|
||||||
|
new_vmail_name = request_json['new_maildir'].replace("'", "'\\''")
|
||||||
|
cmd_vmail = f"if [[ -d '/var/vmail/{vmail_name}' ]]; then /bin/mv '/var/vmail/{vmail_name}' '/var/vmail/{new_vmail_name}'; fi"
|
||||||
|
|
||||||
|
index_name = request_json['old_maildir'].split("/")
|
||||||
|
new_index_name = request_json['new_maildir'].split("/")
|
||||||
|
if len(index_name) > 1 and len(new_index_name) > 1:
|
||||||
|
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
|
||||||
|
new_index_name = new_index_name[1].replace("'", "'\\''") + "@" + new_index_name[0].replace("'", "'\\''")
|
||||||
|
cmd_vmail_index = f"if [[ -d '/var/vmail_index/{index_name}' ]]; then /bin/mv '/var/vmail_index/{index_name}' '/var/vmail_index/{new_index_name}_index'; fi"
|
||||||
|
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
|
||||||
|
else:
|
||||||
|
cmd = ["/bin/bash", "-c", cmd_vmail]
|
||||||
|
maildir_move = container.exec_run(cmd, user='vmail')
|
||||||
|
return self.exec_run_handler('generic', maildir_move)
|
||||||
|
# api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
|
||||||
|
def container_post__exec__rspamd__worker_password(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'raw' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
cmd = "/usr/bin/rspamadm pw -e -p '" + request_json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
|
||||||
|
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
|
||||||
|
|
||||||
|
matched = False
|
||||||
|
for line in cmd_response.split("\n"):
|
||||||
|
if '$2$' in line:
|
||||||
|
hash = line.strip()
|
||||||
|
hash_out = re.search(r'\$2\$.+$', hash).group(0)
|
||||||
|
rspamd_passphrase_hash = re.sub(r'[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
|
||||||
|
rspamd_password_filename = "/etc/rspamd/override.d/worker-controller-password.inc"
|
||||||
|
cmd = '''/bin/echo 'enable_password = "%s";' > %s && cat %s''' % (rspamd_passphrase_hash, rspamd_password_filename, rspamd_password_filename)
|
||||||
|
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
|
||||||
|
if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
|
||||||
|
container.restart()
|
||||||
|
matched = True
|
||||||
|
if matched:
|
||||||
|
res = { 'type': 'success', 'msg': 'command completed successfully' }
|
||||||
|
self.logger.info('success changing Rspamd password')
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
else:
|
||||||
|
self.logger.error('failed changing Rspamd password')
|
||||||
|
res = { 'type': 'danger', 'msg': 'command did not complete' }
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: exec - cmd: sogo - task: rename
|
||||||
|
def container_post__exec__sogo__rename_user(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
if 'old_username' in request_json and 'new_username' in request_json:
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
old_username = request_json['old_username'].replace("'", "'\\''")
|
||||||
|
new_username = request_json['new_username'].replace("'", "'\\''")
|
||||||
|
|
||||||
|
sogo_return = container.exec_run(["/bin/bash", "-c", f"sogo-tool rename-user '{old_username}' '{new_username}'"], user='sogo')
|
||||||
|
return self.exec_run_handler('generic', sogo_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: doveadm - task: get_acl
|
||||||
|
def container_post__exec__doveadm__get_acl(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
id = request_json['id'].replace("'", "'\\''")
|
||||||
|
|
||||||
|
shared_folders = container.exec_run(["/bin/bash", "-c", f"doveadm mailbox list -u '{id}'"])
|
||||||
|
shared_folders = shared_folders.output.decode('utf-8')
|
||||||
|
shared_folders = shared_folders.splitlines()
|
||||||
|
|
||||||
|
formatted_acls = []
|
||||||
|
mailbox_seen = []
|
||||||
|
for shared_folder in shared_folders:
|
||||||
|
if "Shared" not in shared_folder:
|
||||||
|
mailbox = shared_folder.replace("'", "'\\''")
|
||||||
|
if mailbox in mailbox_seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"])
|
||||||
|
acls = acls.output.decode('utf-8').strip().splitlines()
|
||||||
|
if len(acls) >= 2:
|
||||||
|
for acl in acls[1:]:
|
||||||
|
user_id, rights = acl.split(maxsplit=1)
|
||||||
|
user_id = user_id.split('=')[1]
|
||||||
|
mailbox_seen.append(mailbox)
|
||||||
|
formatted_acls.append({ 'user': id, 'id': user_id, 'mailbox': mailbox, 'rights': rights.split() })
|
||||||
|
elif "Shared" in shared_folder and "/" in shared_folder:
|
||||||
|
shared_folder = shared_folder.split("/")
|
||||||
|
if len(shared_folder) < 3:
|
||||||
|
continue
|
||||||
|
|
||||||
|
user = shared_folder[1].replace("'", "'\\''")
|
||||||
|
mailbox = '/'.join(shared_folder[2:]).replace("'", "'\\''")
|
||||||
|
if mailbox in mailbox_seen:
|
||||||
|
continue
|
||||||
|
|
||||||
|
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"])
|
||||||
|
acls = acls.output.decode('utf-8').strip().splitlines()
|
||||||
|
if len(acls) >= 2:
|
||||||
|
for acl in acls[1:]:
|
||||||
|
user_id, rights = acl.split(maxsplit=1)
|
||||||
|
user_id = user_id.split('=')[1].replace("'", "'\\''")
|
||||||
|
if user_id == id and mailbox not in mailbox_seen:
|
||||||
|
mailbox_seen.append(mailbox)
|
||||||
|
formatted_acls.append({ 'user': user, 'id': id, 'mailbox': mailbox, 'rights': rights.split() })
|
||||||
|
|
||||||
|
return Response(content=json.dumps(formatted_acls, indent=4), media_type="application/json")
|
||||||
|
# api call: container_post - post_action: exec - cmd: doveadm - task: delete_acl
|
||||||
|
def container_post__exec__doveadm__delete_acl(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
user = request_json['user'].replace("'", "'\\''")
|
||||||
|
mailbox = request_json['mailbox'].replace("'", "'\\''")
|
||||||
|
id = request_json['id'].replace("'", "'\\''")
|
||||||
|
|
||||||
|
if user and mailbox and id:
|
||||||
|
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' 'user={id}'"])
|
||||||
|
return self.exec_run_handler('generic', acl_delete_return)
|
||||||
|
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
|
||||||
|
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
|
||||||
|
if 'container_id' in kwargs:
|
||||||
|
filters = {"id": kwargs['container_id']}
|
||||||
|
elif 'container_name' in kwargs:
|
||||||
|
filters = {"name": kwargs['container_name']}
|
||||||
|
|
||||||
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
|
user = request_json['user'].replace("'", "'\\''")
|
||||||
|
mailbox = request_json['mailbox'].replace("'", "'\\''")
|
||||||
|
id = request_json['id'].replace("'", "'\\''")
|
||||||
|
rights = ""
|
||||||
|
|
||||||
|
available_rights = [
|
||||||
|
"admin",
|
||||||
|
"create",
|
||||||
|
"delete",
|
||||||
|
"expunge",
|
||||||
|
"insert",
|
||||||
|
"lookup",
|
||||||
|
"post",
|
||||||
|
"read",
|
||||||
|
"write",
|
||||||
|
"write-deleted",
|
||||||
|
"write-seen"
|
||||||
|
]
|
||||||
|
for right in request_json['rights']:
|
||||||
|
right = right.replace("'", "'\\''").lower()
|
||||||
|
if right in available_rights:
|
||||||
|
rights += right + " "
|
||||||
|
|
||||||
|
if user and mailbox and id and rights:
|
||||||
|
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' 'user={id}' {rights}"])
|
||||||
|
return self.exec_run_handler('generic', acl_set_return)
|
||||||
|
|
||||||
|
|
||||||
|
# Collect host stats
|
||||||
|
async def get_host_stats(self, wait=5):
|
||||||
|
try:
|
||||||
|
system_time = datetime.now()
|
||||||
|
host_stats = {
|
||||||
|
"cpu": {
|
||||||
|
"cores": psutil.cpu_count(),
|
||||||
|
"usage": psutil.cpu_percent()
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"total": psutil.virtual_memory().total,
|
||||||
|
"usage": psutil.virtual_memory().percent,
|
||||||
|
"swap": psutil.swap_memory()
|
||||||
|
},
|
||||||
|
"uptime": time.time() - psutil.boot_time(),
|
||||||
|
"system_time": system_time.strftime("%d.%m.%Y %H:%M:%S"),
|
||||||
|
"architecture": platform.machine()
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.redis_client.set('host_stats', json.dumps(host_stats), ex=10)
|
||||||
|
except Exception as e:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
self.host_stats_isUpdating = False
|
||||||
|
# Collect container stats
|
||||||
|
async def get_container_stats(self, container_id, wait=5, stop=False):
|
||||||
|
if container_id and container_id.isalnum():
|
||||||
|
try:
|
||||||
|
for container in (await self.async_docker_client.containers.list()):
|
||||||
|
if container._id == container_id:
|
||||||
|
res = await container.stats(stream=False)
|
||||||
|
|
||||||
|
if await self.redis_client.exists(container_id + '_stats'):
|
||||||
|
stats = json.loads(await self.redis_client.get(container_id + '_stats'))
|
||||||
|
else:
|
||||||
|
stats = []
|
||||||
|
stats.append(res[0])
|
||||||
|
if len(stats) > 3:
|
||||||
|
del stats[0]
|
||||||
|
await self.redis_client.set(container_id + '_stats', json.dumps(stats), ex=60)
|
||||||
|
except Exception as e:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": str(e)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
res = {
|
||||||
|
"type": "danger",
|
||||||
|
"msg": "no or invalid id defined"
|
||||||
|
}
|
||||||
|
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
if stop == True:
|
||||||
|
# update task was called second time, stop
|
||||||
|
self.containerIds_to_update.remove(container_id)
|
||||||
|
else:
|
||||||
|
# call update task a second time
|
||||||
|
await self.get_container_stats(container_id, wait=0, stop=True)
|
||||||
|
|
||||||
|
def exec_cmd_container(self, container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
|
||||||
|
def recv_socket_data(c_socket, timeout):
|
||||||
|
c_socket.setblocking(0)
|
||||||
|
total_data=[]
|
||||||
|
data=''
|
||||||
|
begin=time.time()
|
||||||
|
while True:
|
||||||
|
if total_data and time.time()-begin > timeout:
|
||||||
|
break
|
||||||
|
elif time.time()-begin > timeout*2:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = c_socket.recv(8192)
|
||||||
|
if data:
|
||||||
|
total_data.append(data.decode('utf-8'))
|
||||||
|
#change the beginning time for measurement
|
||||||
|
begin=time.time()
|
||||||
|
else:
|
||||||
|
#sleep for sometime to indicate a gap
|
||||||
|
time.sleep(0.1)
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return ''.join(total_data)
|
||||||
|
|
||||||
|
try :
|
||||||
|
socket = container.exec_run([shell_cmd], stdin=True, socket=True, user=user).output._sock
|
||||||
|
if not cmd.endswith("\n"):
|
||||||
|
cmd = cmd + "\n"
|
||||||
|
socket.send(cmd.encode('utf-8'))
|
||||||
|
data = recv_socket_data(socket, timeout)
|
||||||
|
socket.close()
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error("error - exec_cmd_container: %s" % str(e))
|
||||||
|
traceback.print_exc(file=sys.stdout)
|
||||||
|
|
||||||
|
def exec_run_handler(self, type, output):
|
||||||
|
if type == 'generic':
|
||||||
|
if output.exit_code == 0:
|
||||||
|
res = { 'type': 'success', 'msg': 'command completed successfully' }
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
else:
|
||||||
|
res = { 'type': 'danger', 'msg': 'command failed: ' + output.output.decode('utf-8') }
|
||||||
|
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||||
|
if type == 'utf8_text_only':
|
||||||
|
return Response(content=output.output.decode('utf-8'), media_type="text/plain")
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
`openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
|
||||||
|
-keyout /app/controller_key.pem \
|
||||||
|
-out /app/controller_cert.pem \
|
||||||
|
-subj /CN=controller/O=mailcow \
|
||||||
|
-addext subjectAltName=DNS:controller`
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from models.AliasModel import AliasModel
|
||||||
|
from models.MailboxModel import MailboxModel
|
||||||
|
from models.SyncjobModel import SyncjobModel
|
||||||
|
from models.CalendarModel import CalendarModel
|
||||||
|
from models.MailerModel import MailerModel
|
||||||
|
from models.AddressbookModel import AddressbookModel
|
||||||
|
from models.MaildirModel import MaildirModel
|
||||||
|
from models.DomainModel import DomainModel
|
||||||
|
from models.DomainadminModel import DomainadminModel
|
||||||
|
from models.StatusModel import StatusModel
|
||||||
|
|
||||||
|
from modules.Utils import Utils
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
utils = Utils()
|
||||||
|
|
||||||
|
model_map = {
|
||||||
|
MailboxModel.parser_command: MailboxModel,
|
||||||
|
AliasModel.parser_command: AliasModel,
|
||||||
|
SyncjobModel.parser_command: SyncjobModel,
|
||||||
|
CalendarModel.parser_command: CalendarModel,
|
||||||
|
AddressbookModel.parser_command: AddressbookModel,
|
||||||
|
MailerModel.parser_command: MailerModel,
|
||||||
|
MaildirModel.parser_command: MaildirModel,
|
||||||
|
DomainModel.parser_command: DomainModel,
|
||||||
|
DomainadminModel.parser_command: DomainadminModel,
|
||||||
|
StatusModel.parser_command: StatusModel
|
||||||
|
}
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="mailcow Admin Tool")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
for model in model_map.values():
|
||||||
|
model.add_parser(subparsers)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
for cmd, model_cls in model_map.items():
|
||||||
|
if args.command == cmd and model_cls.has_required_args(args):
|
||||||
|
instance = model_cls(**vars(args))
|
||||||
|
action = getattr(instance, args.object, None)
|
||||||
|
if callable(action):
|
||||||
|
res = action()
|
||||||
|
utils.pprint(res)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from modules.Sogo import Sogo
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class AddressbookModel(BaseModel):
|
||||||
|
parser_command = "addressbook"
|
||||||
|
required_args = {
|
||||||
|
"add": [["username", "name"]],
|
||||||
|
"delete": [["username", "name"]],
|
||||||
|
"get": [["username", "name"]],
|
||||||
|
"set_acl": [["username", "name", "sharee_email", "acl"]],
|
||||||
|
"get_acl": [["username", "name"]],
|
||||||
|
"delete_acl": [["username", "name", "sharee_email"]],
|
||||||
|
"add_contact": [["username", "name", "contact_name", "contact_email", "type"]],
|
||||||
|
"delete_contact": [["username", "name", "contact_name"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username=None,
|
||||||
|
name=None,
|
||||||
|
sharee_email=None,
|
||||||
|
acl=None,
|
||||||
|
subscribe=None,
|
||||||
|
ics=None,
|
||||||
|
contact_name=None,
|
||||||
|
contact_email=None,
|
||||||
|
type=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.sogo = Sogo(username)
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.acl = acl
|
||||||
|
self.sharee_email = sharee_email
|
||||||
|
self.subscribe = subscribe
|
||||||
|
self.ics = ics
|
||||||
|
self.contact_name = contact_name
|
||||||
|
self.contact_email = contact_email
|
||||||
|
self.type = type
|
||||||
|
|
||||||
|
def add(self):
|
||||||
|
"""
|
||||||
|
Add a new addressbook.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
return self.sogo.addAddressbook(self.name)
|
||||||
|
|
||||||
|
def set_acl(self):
|
||||||
|
"""
|
||||||
|
Set ACL for the addressbook.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
|
||||||
|
if not addressbook_id:
|
||||||
|
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.setAddressbookACL(addressbook_id, self.sharee_email, self.acl, self.subscribe)
|
||||||
|
|
||||||
|
def delete_acl(self):
|
||||||
|
"""
|
||||||
|
Delete the addressbook ACL.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
|
||||||
|
if not addressbook_id:
|
||||||
|
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.deleteAddressbookACL(addressbook_id, self.sharee_email)
|
||||||
|
|
||||||
|
def get_acl(self):
|
||||||
|
"""
|
||||||
|
Get the ACL for the addressbook.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
|
||||||
|
if not addressbook_id:
|
||||||
|
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.getAddressbookACL(addressbook_id)
|
||||||
|
|
||||||
|
def add_contact(self):
|
||||||
|
"""
|
||||||
|
Add a new contact to the addressbook.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
|
||||||
|
if not addressbook_id:
|
||||||
|
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
if self.type == "card":
|
||||||
|
return self.sogo.addAddressbookContact(addressbook_id, self.contact_name, self.contact_email)
|
||||||
|
elif self.type == "list":
|
||||||
|
return self.sogo.addAddressbookContactList(addressbook_id, self.contact_name, self.contact_email)
|
||||||
|
|
||||||
|
def delete_contact(self):
|
||||||
|
"""
|
||||||
|
Delete a contact or contactlist from the addressbook.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
|
||||||
|
if not addressbook_id:
|
||||||
|
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.deleteAddressbookItem(addressbook_id, self.contact_name)
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Retrieve addressbooks list.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
return self.sogo.getAddressbookList()
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""
|
||||||
|
Delete the addressbook.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
addressbook_id = self.sogo.getAddressbookIdByName(self.name)
|
||||||
|
if not addressbook_id:
|
||||||
|
print(f"Addressbook '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.deleteAddressbook(addressbook_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Manage addressbooks (add, delete, get, set_acl, get_acl, delete_acl, add_contact, delete_contact)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, set_acl, get_acl, delete_acl, add_contact, delete_contact")
|
||||||
|
parser.add_argument("--username", required=True, help="Username of the addressbook owner (e.g. user@example.com)")
|
||||||
|
parser.add_argument("--name", help="Addressbook name")
|
||||||
|
parser.add_argument("--sharee-email", help="Email address to share the addressbook with")
|
||||||
|
parser.add_argument("--acl", help="ACL rights for the sharee (e.g. r, w, rw)")
|
||||||
|
parser.add_argument("--subscribe", action='store_true', help="Subscribe the sharee to the addressbook")
|
||||||
|
parser.add_argument("--contact-name", help="Name of the contact or contactlist to add or delete")
|
||||||
|
parser.add_argument("--contact-email", help="Email address of the contact to add")
|
||||||
|
parser.add_argument("--type", choices=["card", "list"], help="Type of contact to add: card (single contact) or list (distribution list)")
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from modules.Mailcow import Mailcow
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class AliasModel(BaseModel):
|
||||||
|
parser_command = "alias"
|
||||||
|
required_args = {
|
||||||
|
"add": [["address", "goto"]],
|
||||||
|
"delete": [["id"]],
|
||||||
|
"get": [["id"]],
|
||||||
|
"edit": [["id"]]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id=None,
|
||||||
|
address=None,
|
||||||
|
goto=None,
|
||||||
|
active=None,
|
||||||
|
sogo_visible=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.mailcow = Mailcow()
|
||||||
|
|
||||||
|
self.id = id
|
||||||
|
self.address = address
|
||||||
|
self.goto = goto
|
||||||
|
self.active = active
|
||||||
|
self.sogo_visible = sogo_visible
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data):
|
||||||
|
return cls(
|
||||||
|
address=data.get("address"),
|
||||||
|
goto=data.get("goto"),
|
||||||
|
active=data.get("active", None),
|
||||||
|
sogo_visible=data.get("sogo_visible", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def getAdd(self):
|
||||||
|
"""
|
||||||
|
Get the alias details as a dictionary for adding, sets default values.
|
||||||
|
:return: Dictionary containing alias details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias = {
|
||||||
|
"address": self.address,
|
||||||
|
"goto": self.goto,
|
||||||
|
"active": self.active if self.active is not None else 1,
|
||||||
|
"sogo_visible": self.sogo_visible if self.sogo_visible is not None else 0
|
||||||
|
}
|
||||||
|
return {key: value for key, value in alias.items() if value is not None}
|
||||||
|
|
||||||
|
def getEdit(self):
|
||||||
|
"""
|
||||||
|
Get the alias details as a dictionary for editing, sets no default values.
|
||||||
|
:return: Dictionary containing mailbox details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias = {
|
||||||
|
"address": self.address,
|
||||||
|
"goto": self.goto,
|
||||||
|
"active": self.active,
|
||||||
|
"sogo_visible": self.sogo_visible
|
||||||
|
}
|
||||||
|
return {key: value for key, value in alias.items() if value is not None}
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.getAlias(self.id)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.deleteAlias(self.id)
|
||||||
|
|
||||||
|
def add(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.addAlias(self.getAdd())
|
||||||
|
|
||||||
|
def edit(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.editAlias(self.id, self.getEdit())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Manage aliases (add, delete, get, edit)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
|
||||||
|
parser.add_argument("--id", help="Alias object ID (required for get, edit, delete)")
|
||||||
|
parser.add_argument("--address", help="Alias email address (e.g. alias@example.com)")
|
||||||
|
parser.add_argument("--goto", help="Destination address(es), comma-separated (e.g. user1@example.com,user2@example.com)")
|
||||||
|
parser.add_argument("--active", choices=["1", "0"], help="Activate (1) or deactivate (0) the alias")
|
||||||
|
parser.add_argument("--sogo-visible", choices=["1", "0"], help="Show alias in SOGo addressbook (1 = yes, 0 = no)")
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
class BaseModel:
|
||||||
|
parser_command = ""
|
||||||
|
required_args = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def has_required_args(cls, args):
|
||||||
|
"""
|
||||||
|
Validate that all required arguments are present.
|
||||||
|
"""
|
||||||
|
object_name = args.object if hasattr(args, "object") else args.get("object")
|
||||||
|
required_lists = cls.required_args.get(object_name, False)
|
||||||
|
|
||||||
|
if not required_lists:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for required_set in required_lists:
|
||||||
|
result = True
|
||||||
|
for required_args in required_set:
|
||||||
|
if isinstance(args, dict):
|
||||||
|
if not args.get(required_args):
|
||||||
|
result = False
|
||||||
|
break
|
||||||
|
elif not hasattr(args, required_args):
|
||||||
|
result = False
|
||||||
|
break
|
||||||
|
if result:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
print(f"Required arguments for '{object_name}': {required_lists}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from modules.Sogo import Sogo
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class CalendarModel(BaseModel):
|
||||||
|
parser_command = "calendar"
|
||||||
|
required_args = {
|
||||||
|
"add": [["username", "name"]],
|
||||||
|
"delete": [["username", "name"]],
|
||||||
|
"get": [["username"]],
|
||||||
|
"import_ics": [["username", "name", "ics"]],
|
||||||
|
"set_acl": [["username", "name", "sharee_email", "acl"]],
|
||||||
|
"get_acl": [["username", "name"]],
|
||||||
|
"delete_acl": [["username", "name", "sharee_email"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username=None,
|
||||||
|
name=None,
|
||||||
|
sharee_email=None,
|
||||||
|
acl=None,
|
||||||
|
subscribe=None,
|
||||||
|
ics=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.sogo = Sogo(username)
|
||||||
|
|
||||||
|
self.name = name
|
||||||
|
self.acl = acl
|
||||||
|
self.sharee_email = sharee_email
|
||||||
|
self.subscribe = subscribe
|
||||||
|
self.ics = ics
|
||||||
|
|
||||||
|
def add(self):
|
||||||
|
"""
|
||||||
|
Add a new calendar.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
return self.sogo.addCalendar(self.name)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""
|
||||||
|
Delete a calendar.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
calendar_id = self.sogo.getCalendarIdByName(self.name)
|
||||||
|
if not calendar_id:
|
||||||
|
print(f"Calendar '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.deleteCalendar(calendar_id)
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Get the calendar details.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
return self.sogo.getCalendar()
|
||||||
|
|
||||||
|
def set_acl(self):
|
||||||
|
"""
|
||||||
|
Set ACL for the calendar.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
calendar_id = self.sogo.getCalendarIdByName(self.name)
|
||||||
|
if not calendar_id:
|
||||||
|
print(f"Calendar '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.setCalendarACL(calendar_id, self.sharee_email, self.acl, self.subscribe)
|
||||||
|
|
||||||
|
def delete_acl(self):
|
||||||
|
"""
|
||||||
|
Delete the calendar ACL.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
calendar_id = self.sogo.getCalendarIdByName(self.name)
|
||||||
|
if not calendar_id:
|
||||||
|
print(f"Calendar '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.deleteCalendarACL(calendar_id, self.sharee_email)
|
||||||
|
|
||||||
|
def get_acl(self):
|
||||||
|
"""
|
||||||
|
Get the ACL for the calendar.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
calendar_id = self.sogo.getCalendarIdByName(self.name)
|
||||||
|
if not calendar_id:
|
||||||
|
print(f"Calendar '{self.name}' not found for user '{self.username}'.")
|
||||||
|
return None
|
||||||
|
return self.sogo.getCalendarACL(calendar_id)
|
||||||
|
|
||||||
|
def import_ics(self):
|
||||||
|
"""
|
||||||
|
Import a calendar from an ICS file.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
return self.sogo.importCalendar(self.name, self.ics)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Manage calendars (add, delete, get, import_ics, set_acl, get_acl, delete_acl)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, import_ics, set_acl, get_acl, delete_acl")
|
||||||
|
parser.add_argument("--username", required=True, help="Username of the calendar owner (e.g. user@example.com)")
|
||||||
|
parser.add_argument("--name", help="Calendar name")
|
||||||
|
parser.add_argument("--ics", help="Path to ICS file for import")
|
||||||
|
parser.add_argument("--sharee-email", help="Email address to share the calendar with")
|
||||||
|
parser.add_argument("--acl", help="ACL rights for the sharee (e.g. r, w, rw)")
|
||||||
|
parser.add_argument("--subscribe", action='store_true', help="Subscribe the sharee to the calendar")
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
from modules.Mailcow import Mailcow
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class DomainModel(BaseModel):
|
||||||
|
parser_command = "domain"
|
||||||
|
required_args = {
|
||||||
|
"add": [["domain"]],
|
||||||
|
"delete": [["domain"]],
|
||||||
|
"get": [["domain"]],
|
||||||
|
"edit": [["domain"]]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
domain=None,
|
||||||
|
active=None,
|
||||||
|
aliases=None,
|
||||||
|
backupmx=None,
|
||||||
|
defquota=None,
|
||||||
|
description=None,
|
||||||
|
mailboxes=None,
|
||||||
|
maxquota=None,
|
||||||
|
quota=None,
|
||||||
|
relay_all_recipients=None,
|
||||||
|
rl_frame=None,
|
||||||
|
rl_value=None,
|
||||||
|
restart_sogo=None,
|
||||||
|
tags=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.mailcow = Mailcow()
|
||||||
|
|
||||||
|
self.domain = domain
|
||||||
|
self.active = active
|
||||||
|
self.aliases = aliases
|
||||||
|
self.backupmx = backupmx
|
||||||
|
self.defquota = defquota
|
||||||
|
self.description = description
|
||||||
|
self.mailboxes = mailboxes
|
||||||
|
self.maxquota = maxquota
|
||||||
|
self.quota = quota
|
||||||
|
self.relay_all_recipients = relay_all_recipients
|
||||||
|
self.rl_frame = rl_frame
|
||||||
|
self.rl_value = rl_value
|
||||||
|
self.restart_sogo = restart_sogo
|
||||||
|
self.tags = tags
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data):
|
||||||
|
return cls(
|
||||||
|
domain=data.get("domain"),
|
||||||
|
active=data.get("active", None),
|
||||||
|
aliases=data.get("aliases", None),
|
||||||
|
backupmx=data.get("backupmx", None),
|
||||||
|
defquota=data.get("defquota", None),
|
||||||
|
description=data.get("description", None),
|
||||||
|
mailboxes=data.get("mailboxes", None),
|
||||||
|
maxquota=data.get("maxquota", None),
|
||||||
|
quota=data.get("quota", None),
|
||||||
|
relay_all_recipients=data.get("relay_all_recipients", None),
|
||||||
|
rl_frame=data.get("rl_frame", None),
|
||||||
|
rl_value=data.get("rl_value", None),
|
||||||
|
restart_sogo=data.get("restart_sogo", None),
|
||||||
|
tags=data.get("tags", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def getAdd(self):
|
||||||
|
"""
|
||||||
|
Get the domain details as a dictionary for adding, sets default values.
|
||||||
|
:return: Dictionary containing domain details.
|
||||||
|
"""
|
||||||
|
domain = {
|
||||||
|
"domain": self.domain,
|
||||||
|
"active": self.active if self.active is not None else 1,
|
||||||
|
"aliases": self.aliases if self.aliases is not None else 400,
|
||||||
|
"backupmx": self.backupmx if self.backupmx is not None else 0,
|
||||||
|
"defquota": self.defquota if self.defquota is not None else 3072,
|
||||||
|
"description": self.description if self.description is not None else "",
|
||||||
|
"mailboxes": self.mailboxes if self.mailboxes is not None else 10,
|
||||||
|
"maxquota": self.maxquota if self.maxquota is not None else 10240,
|
||||||
|
"quota": self.quota if self.quota is not None else 10240,
|
||||||
|
"relay_all_recipients": self.relay_all_recipients if self.relay_all_recipients is not None else 0,
|
||||||
|
"rl_frame": self.rl_frame,
|
||||||
|
"rl_value": self.rl_value,
|
||||||
|
"restart_sogo": self.restart_sogo if self.restart_sogo is not None else 0,
|
||||||
|
"tags": self.tags if self.tags is not None else []
|
||||||
|
}
|
||||||
|
return {key: value for key, value in domain.items() if value is not None}
|
||||||
|
|
||||||
|
def getEdit(self):
|
||||||
|
"""
|
||||||
|
Get the domain details as a dictionary for editing, sets no default values.
|
||||||
|
:return: Dictionary containing domain details.
|
||||||
|
"""
|
||||||
|
domain = {
|
||||||
|
"domain": self.domain,
|
||||||
|
"active": self.active,
|
||||||
|
"aliases": self.aliases,
|
||||||
|
"backupmx": self.backupmx,
|
||||||
|
"defquota": self.defquota,
|
||||||
|
"description": self.description,
|
||||||
|
"mailboxes": self.mailboxes,
|
||||||
|
"maxquota": self.maxquota,
|
||||||
|
"quota": self.quota,
|
||||||
|
"relay_all_recipients": self.relay_all_recipients,
|
||||||
|
"rl_frame": self.rl_frame,
|
||||||
|
"rl_value": self.rl_value,
|
||||||
|
"restart_sogo": self.restart_sogo,
|
||||||
|
"tags": self.tags
|
||||||
|
}
|
||||||
|
return {key: value for key, value in domain.items() if value is not None}
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Get the domain details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.getDomain(self.domain)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""
|
||||||
|
Delete the domain from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.deleteDomain(self.domain)
|
||||||
|
|
||||||
|
def add(self):
|
||||||
|
"""
|
||||||
|
Add the domain to the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.addDomain(self.getAdd())
|
||||||
|
|
||||||
|
def edit(self):
|
||||||
|
"""
|
||||||
|
Edit the domain in the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.editDomain(self.domain, self.getEdit())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Manage domains (add, delete, get, edit)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
|
||||||
|
parser.add_argument("--domain", required=True, help="Domain name (e.g. domain.tld)")
|
||||||
|
parser.add_argument("--active", choices=["1", "0"], help="Activate (1) or deactivate (0) the domain")
|
||||||
|
parser.add_argument("--aliases", help="Number of aliases allowed for the domain")
|
||||||
|
parser.add_argument("--backupmx", choices=["1", "0"], help="Enable (1) or disable (0) backup MX")
|
||||||
|
parser.add_argument("--defquota", help="Default quota for mailboxes in MB")
|
||||||
|
parser.add_argument("--description", help="Description of the domain")
|
||||||
|
parser.add_argument("--mailboxes", help="Number of mailboxes allowed for the domain")
|
||||||
|
parser.add_argument("--maxquota", help="Maximum quota for the domain in MB")
|
||||||
|
parser.add_argument("--quota", help="Quota used by the domain in MB")
|
||||||
|
parser.add_argument("--relay-all-recipients", choices=["1", "0"], help="Relay all recipients (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--rl-frame", help="Rate limit frame (e.g., s, m, h)")
|
||||||
|
parser.add_argument("--rl-value", help="Rate limit value")
|
||||||
|
parser.add_argument("--restart-sogo", help="Restart SOGo after changes (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--tags", nargs="*", help="Tags for the domain")
|
||||||
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
from modules.Mailcow import Mailcow
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class DomainadminModel(BaseModel):
|
||||||
|
parser_command = "domainadmin"
|
||||||
|
required_args = {
|
||||||
|
"add": [["username", "domains", "password"]],
|
||||||
|
"delete": [["username"]],
|
||||||
|
"get": [["username"]],
|
||||||
|
"edit": [["username"]]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username=None,
|
||||||
|
domains=None,
|
||||||
|
password=None,
|
||||||
|
active=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.mailcow = Mailcow()
|
||||||
|
|
||||||
|
self.username = username
|
||||||
|
self.domains = domains
|
||||||
|
self.password = password
|
||||||
|
self.password2 = password
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data):
|
||||||
|
return cls(
|
||||||
|
username=data.get("username"),
|
||||||
|
domains=data.get("domains"),
|
||||||
|
password=data.get("password"),
|
||||||
|
password2=data.get("password"),
|
||||||
|
active=data.get("active", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def getAdd(self):
|
||||||
|
"""
|
||||||
|
Get the domain admin details as a dictionary for adding, sets default values.
|
||||||
|
:return: Dictionary containing domain admin details.
|
||||||
|
"""
|
||||||
|
domainadmin = {
|
||||||
|
"username": self.username,
|
||||||
|
"domains": self.domains,
|
||||||
|
"password": self.password,
|
||||||
|
"password2": self.password2,
|
||||||
|
"active": self.active if self.active is not None else "1"
|
||||||
|
}
|
||||||
|
return {key: value for key, value in domainadmin.items() if value is not None}
|
||||||
|
|
||||||
|
def getEdit(self):
|
||||||
|
"""
|
||||||
|
Get the domain admin details as a dictionary for editing, sets no default values.
|
||||||
|
:return: Dictionary containing domain admin details.
|
||||||
|
"""
|
||||||
|
domainadmin = {
|
||||||
|
"username": self.username,
|
||||||
|
"domains": self.domains,
|
||||||
|
"password": self.password,
|
||||||
|
"password2": self.password2,
|
||||||
|
"active": self.active
|
||||||
|
}
|
||||||
|
return {key: value for key, value in domainadmin.items() if value is not None}
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Get the domain admin details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.getDomainadmin(self.username)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""
|
||||||
|
Delete the domain admin from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.deleteDomainadmin(self.username)
|
||||||
|
|
||||||
|
def add(self):
|
||||||
|
"""
|
||||||
|
Add the domain admin to the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.addDomainadmin(self.getAdd())
|
||||||
|
|
||||||
|
def edit(self):
|
||||||
|
"""
|
||||||
|
Edit the domain admin in the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.editDomainadmin(self.username, self.getEdit())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Manage domain admins (add, delete, get, edit)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
|
||||||
|
parser.add_argument("--username", help="Username for the domain admin")
|
||||||
|
parser.add_argument("--domains", help="Comma-separated list of domains")
|
||||||
|
parser.add_argument("--password", help="Password for the domain admin")
|
||||||
|
parser.add_argument("--active", choices=["1", "0"], help="Activate (1) or deactivate (0) the domain admin")
|
||||||
|
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
from modules.Mailcow import Mailcow
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class MailboxModel(BaseModel):
|
||||||
|
parser_command = "mailbox"
|
||||||
|
required_args = {
|
||||||
|
"add": [["username", "password"]],
|
||||||
|
"delete": [["username"]],
|
||||||
|
"get": [["username"]],
|
||||||
|
"edit": [["username"]]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
password=None,
|
||||||
|
username=None,
|
||||||
|
domain=None,
|
||||||
|
local_part=None,
|
||||||
|
active=None,
|
||||||
|
sogo_access=None,
|
||||||
|
name=None,
|
||||||
|
authsource=None,
|
||||||
|
quota=None,
|
||||||
|
force_pw_update=None,
|
||||||
|
tls_enforce_in=None,
|
||||||
|
tls_enforce_out=None,
|
||||||
|
tags=None,
|
||||||
|
sender_acl=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.mailcow = Mailcow()
|
||||||
|
|
||||||
|
if username is not None and "@" in username:
|
||||||
|
self.username = username
|
||||||
|
self.local_part, self.domain = username.split("@")
|
||||||
|
else:
|
||||||
|
self.username = f"{local_part}@{domain}"
|
||||||
|
self.local_part = local_part
|
||||||
|
self.domain = domain
|
||||||
|
|
||||||
|
self.password = password
|
||||||
|
self.password2 = password
|
||||||
|
self.active = active
|
||||||
|
self.sogo_access = sogo_access
|
||||||
|
self.name = name
|
||||||
|
self.authsource = authsource
|
||||||
|
self.quota = quota
|
||||||
|
self.force_pw_update = force_pw_update
|
||||||
|
self.tls_enforce_in = tls_enforce_in
|
||||||
|
self.tls_enforce_out = tls_enforce_out
|
||||||
|
self.tags = tags
|
||||||
|
self.sender_acl = sender_acl
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data):
|
||||||
|
return cls(
|
||||||
|
domain=data.get("domain"),
|
||||||
|
local_part=data.get("local_part"),
|
||||||
|
password=data.get("password"),
|
||||||
|
password2=data.get("password"),
|
||||||
|
active=data.get("active", None),
|
||||||
|
sogo_access=data.get("sogo_access", None),
|
||||||
|
name=data.get("name", None),
|
||||||
|
authsource=data.get("authsource", None),
|
||||||
|
quota=data.get("quota", None),
|
||||||
|
force_pw_update=data.get("force_pw_update", None),
|
||||||
|
tls_enforce_in=data.get("tls_enforce_in", None),
|
||||||
|
tls_enforce_out=data.get("tls_enforce_out", None),
|
||||||
|
tags=data.get("tags", None),
|
||||||
|
sender_acl=data.get("sender_acl", None)
|
||||||
|
)
|
||||||
|
|
||||||
|
def getAdd(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details as a dictionary for adding, sets default values.
|
||||||
|
:return: Dictionary containing mailbox details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mailbox = {
|
||||||
|
"domain": self.domain,
|
||||||
|
"local_part": self.local_part,
|
||||||
|
"password": self.password,
|
||||||
|
"password2": self.password2,
|
||||||
|
"active": self.active if self.active is not None else 1,
|
||||||
|
"name": self.name if self.name is not None else "",
|
||||||
|
"authsource": self.authsource if self.authsource is not None else "mailcow",
|
||||||
|
"quota": self.quota if self.quota is not None else 0,
|
||||||
|
"force_pw_update": self.force_pw_update if self.force_pw_update is not None else 0,
|
||||||
|
"tls_enforce_in": self.tls_enforce_in if self.tls_enforce_in is not None else 0,
|
||||||
|
"tls_enforce_out": self.tls_enforce_out if self.tls_enforce_out is not None else 0,
|
||||||
|
"tags": self.tags if self.tags is not None else []
|
||||||
|
}
|
||||||
|
return {key: value for key, value in mailbox.items() if value is not None}
|
||||||
|
|
||||||
|
def getEdit(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details as a dictionary for editing, sets no default values.
|
||||||
|
:return: Dictionary containing mailbox details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
mailbox = {
|
||||||
|
"domain": self.domain,
|
||||||
|
"local_part": self.local_part,
|
||||||
|
"password": self.password,
|
||||||
|
"password2": self.password2,
|
||||||
|
"active": self.active,
|
||||||
|
"name": self.name,
|
||||||
|
"authsource": self.authsource,
|
||||||
|
"quota": self.quota,
|
||||||
|
"force_pw_update": self.force_pw_update,
|
||||||
|
"tls_enforce_in": self.tls_enforce_in,
|
||||||
|
"tls_enforce_out": self.tls_enforce_out,
|
||||||
|
"tags": self.tags
|
||||||
|
}
|
||||||
|
return {key: value for key, value in mailbox.items() if value is not None}
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.getMailbox(self.username)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.deleteMailbox(self.username)
|
||||||
|
|
||||||
|
def add(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.addMailbox(self.getAdd())
|
||||||
|
|
||||||
|
def edit(self):
|
||||||
|
"""
|
||||||
|
Get the mailbox details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.editMailbox(self.username, self.getEdit())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Manage mailboxes (add, delete, get, edit)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
|
||||||
|
parser.add_argument("--username", help="Full email address of the mailbox (e.g. user@example.com)")
|
||||||
|
parser.add_argument("--password", help="Password for the mailbox (required for add)")
|
||||||
|
parser.add_argument("--active", choices=["1", "0"], help="Activate (1) or deactivate (0) the mailbox")
|
||||||
|
parser.add_argument("--sogo-access", choices=["1", "0"], help="Redirect mailbox to SOGo after web login (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--name", help="Display name of the mailbox owner")
|
||||||
|
parser.add_argument("--authsource", help="Authentication source (default: mailcow)")
|
||||||
|
parser.add_argument("--quota", help="Mailbox quota in bytes (0 = unlimited)")
|
||||||
|
parser.add_argument("--force-pw-update", choices=["1", "0"], help="Force password update on next login (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--tls-enforce-in", choices=["1", "0"], help="Enforce TLS for incoming emails (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--tls-enforce-out", choices=["1", "0"], help="Enforce TLS for outgoing emails (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--tags", help="Comma-separated list of tags for the mailbox")
|
||||||
|
parser.add_argument("--sender-acl", help="Comma-separated list of allowed sender addresses for this mailbox")
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from modules.Dovecot import Dovecot
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class MaildirModel(BaseModel):
|
||||||
|
parser_command = "maildir"
|
||||||
|
required_args = {
|
||||||
|
"encrypt": [],
|
||||||
|
"decrypt": [],
|
||||||
|
"restore": [["username", "item"], ["list"]]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
username=None,
|
||||||
|
source=None,
|
||||||
|
item=None,
|
||||||
|
overwrite=None,
|
||||||
|
list=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.dovecot = Dovecot()
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
self.username = username
|
||||||
|
self.source = source
|
||||||
|
self.item = item
|
||||||
|
self.overwrite = overwrite
|
||||||
|
self.list = list
|
||||||
|
|
||||||
|
def encrypt(self):
|
||||||
|
"""
|
||||||
|
Encrypt the maildir for the specified user or all.
|
||||||
|
:return: Response from Dovecot.
|
||||||
|
"""
|
||||||
|
return self.dovecot.encryptMaildir(self.source_dir, self.output_dir)
|
||||||
|
|
||||||
|
def decrypt(self):
|
||||||
|
"""
|
||||||
|
Decrypt the maildir for the specified user or all.
|
||||||
|
:return: Response from Dovecot.
|
||||||
|
"""
|
||||||
|
return self.dovecot.decryptMaildir(self.source_dir, self.output_dir)
|
||||||
|
|
||||||
|
def restore(self):
|
||||||
|
"""
|
||||||
|
Restore or List maildir data for the specified user.
|
||||||
|
:return: Response from Dovecot.
|
||||||
|
"""
|
||||||
|
if self.list:
|
||||||
|
return self.dovecot.listDeletedMaildirs()
|
||||||
|
return self.dovecot.restoreMaildir(self.username, self.item)
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Manage maildir (encrypt, decrypt, restore)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: encrypt, decrypt, restore")
|
||||||
|
parser.add_argument("--item", help="Item to restore")
|
||||||
|
parser.add_argument("--username", help="Username to restore the item to")
|
||||||
|
parser.add_argument("--list", action="store_true", help="List items to restore")
|
||||||
|
parser.add_argument("--source-dir", help="Path to the source maildir to import/encrypt/decrypt")
|
||||||
|
parser.add_argument("--output-dir", help="Directory to store encrypted/decrypted files inside the Dovecot container")
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import json
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
from modules.Mailer import Mailer
|
||||||
|
|
||||||
|
class MailerModel(BaseModel):
|
||||||
|
parser_command = "mail"
|
||||||
|
required_args = {
|
||||||
|
"send": [["sender", "recipient", "subject", "body"]]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
sender=None,
|
||||||
|
recipient=None,
|
||||||
|
subject=None,
|
||||||
|
body=None,
|
||||||
|
context=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.sender = sender
|
||||||
|
self.recipient = recipient
|
||||||
|
self.subject = subject
|
||||||
|
self.body = body
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def send(self):
|
||||||
|
if self.context is not None:
|
||||||
|
try:
|
||||||
|
self.context = json.loads(self.context)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
return f"Invalid context JSON: {e}"
|
||||||
|
else:
|
||||||
|
self.context = {}
|
||||||
|
|
||||||
|
mailer = Mailer(
|
||||||
|
smtp_host="postfix-mailcow",
|
||||||
|
smtp_port=25,
|
||||||
|
username=self.sender,
|
||||||
|
password="",
|
||||||
|
use_tls=True
|
||||||
|
)
|
||||||
|
res = mailer.send_mail(
|
||||||
|
subject=self.subject,
|
||||||
|
from_addr=self.sender,
|
||||||
|
to_addrs=self.recipient.split(","),
|
||||||
|
template=self.body,
|
||||||
|
context=self.context
|
||||||
|
)
|
||||||
|
return res
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Send emails via SMTP"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: send")
|
||||||
|
parser.add_argument("--sender", required=True, help="Email sender address")
|
||||||
|
parser.add_argument("--recipient", required=True, help="Email recipient address (comma-separated for multiple)")
|
||||||
|
parser.add_argument("--subject", required=True, help="Email subject")
|
||||||
|
parser.add_argument("--body", required=True, help="Email body (Jinja2 template supported)")
|
||||||
|
parser.add_argument("--context", help="Context for Jinja2 template rendering (JSON format)")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from modules.Mailcow import Mailcow
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class StatusModel(BaseModel):
|
||||||
|
parser_command = "status"
|
||||||
|
required_args = {
|
||||||
|
"version": [[]],
|
||||||
|
"vmail": [[]],
|
||||||
|
"containers": [[]]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.mailcow = Mailcow()
|
||||||
|
|
||||||
|
def version(self):
|
||||||
|
"""
|
||||||
|
Get the version of the mailcow instance.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.getStatusVersion()
|
||||||
|
|
||||||
|
def vmail(self):
|
||||||
|
"""
|
||||||
|
Get the vmail details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.getStatusVmail()
|
||||||
|
|
||||||
|
def containers(self):
|
||||||
|
"""
|
||||||
|
Get the status of containers in the mailcow instance.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.getStatusContainers()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Get information about mailcow (version, vmail, containers)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: version, vmail, containers")
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
from modules.Mailcow import Mailcow
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
class SyncjobModel(BaseModel):
|
||||||
|
parser_command = "syncjob"
|
||||||
|
required_args = {
|
||||||
|
"add": [["username", "host1", "port1", "user1", "password1", "enc1"]],
|
||||||
|
"delete": [["id"]],
|
||||||
|
"get": [["username"]],
|
||||||
|
"edit": [["id"]],
|
||||||
|
"run": [["id"]]
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
id=None,
|
||||||
|
username=None,
|
||||||
|
host1=None,
|
||||||
|
port1=None,
|
||||||
|
user1=None,
|
||||||
|
password1=None,
|
||||||
|
enc1=None,
|
||||||
|
mins_interval=None,
|
||||||
|
subfolder2=None,
|
||||||
|
maxage=None,
|
||||||
|
maxbytespersecond=None,
|
||||||
|
timeout1=None,
|
||||||
|
timeout2=None,
|
||||||
|
exclude=None,
|
||||||
|
custom_parameters=None,
|
||||||
|
delete2duplicates=None,
|
||||||
|
delete1=None,
|
||||||
|
delete2=None,
|
||||||
|
automap=None,
|
||||||
|
skipcrossduplicates=None,
|
||||||
|
subscribeall=None,
|
||||||
|
active=None,
|
||||||
|
force=None,
|
||||||
|
**kwargs
|
||||||
|
):
|
||||||
|
self.mailcow = Mailcow()
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
self.id = id
|
||||||
|
self.username = username
|
||||||
|
self.host1 = host1
|
||||||
|
self.port1 = port1
|
||||||
|
self.user1 = user1
|
||||||
|
self.password1 = password1
|
||||||
|
self.enc1 = enc1
|
||||||
|
self.mins_interval = mins_interval
|
||||||
|
self.subfolder2 = subfolder2
|
||||||
|
self.maxage = maxage
|
||||||
|
self.maxbytespersecond = maxbytespersecond
|
||||||
|
self.timeout1 = timeout1
|
||||||
|
self.timeout2 = timeout2
|
||||||
|
self.exclude = exclude
|
||||||
|
self.custom_parameters = custom_parameters
|
||||||
|
self.delete2duplicates = delete2duplicates
|
||||||
|
self.delete1 = delete1
|
||||||
|
self.delete2 = delete2
|
||||||
|
self.automap = automap
|
||||||
|
self.skipcrossduplicates = skipcrossduplicates
|
||||||
|
self.subscribeall = subscribeall
|
||||||
|
self.active = active
|
||||||
|
self.force = force
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data):
|
||||||
|
return cls(
|
||||||
|
username=data.get("username"),
|
||||||
|
host1=data.get("host1"),
|
||||||
|
port1=data.get("port1"),
|
||||||
|
user1=data.get("user1"),
|
||||||
|
password1=data.get("password1"),
|
||||||
|
enc1=data.get("enc1"),
|
||||||
|
mins_interval=data.get("mins_interval", None),
|
||||||
|
subfolder2=data.get("subfolder2", None),
|
||||||
|
maxage=data.get("maxage", None),
|
||||||
|
maxbytespersecond=data.get("maxbytespersecond", None),
|
||||||
|
timeout1=data.get("timeout1", None),
|
||||||
|
timeout2=data.get("timeout2", None),
|
||||||
|
exclude=data.get("exclude", None),
|
||||||
|
custom_parameters=data.get("custom_parameters", None),
|
||||||
|
delete2duplicates=data.get("delete2duplicates", None),
|
||||||
|
delete1=data.get("delete1", None),
|
||||||
|
delete2=data.get("delete2", None),
|
||||||
|
automap=data.get("automap", None),
|
||||||
|
skipcrossduplicates=data.get("skipcrossduplicates", None),
|
||||||
|
subscribeall=data.get("subscribeall", None),
|
||||||
|
active=data.get("active", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
def getAdd(self):
|
||||||
|
"""
|
||||||
|
Get the sync job details as a dictionary for adding, sets default values.
|
||||||
|
:return: Dictionary containing sync job details.
|
||||||
|
"""
|
||||||
|
syncjob = {
|
||||||
|
"username": self.username,
|
||||||
|
"host1": self.host1,
|
||||||
|
"port1": self.port1,
|
||||||
|
"user1": self.user1,
|
||||||
|
"password1": self.password1,
|
||||||
|
"enc1": self.enc1,
|
||||||
|
"mins_interval": self.mins_interval if self.mins_interval is not None else 20,
|
||||||
|
"subfolder2": self.subfolder2 if self.subfolder2 is not None else "",
|
||||||
|
"maxage": self.maxage if self.maxage is not None else 0,
|
||||||
|
"maxbytespersecond": self.maxbytespersecond if self.maxbytespersecond is not None else 0,
|
||||||
|
"timeout1": self.timeout1 if self.timeout1 is not None else 600,
|
||||||
|
"timeout2": self.timeout2 if self.timeout2 is not None else 600,
|
||||||
|
"exclude": self.exclude if self.exclude is not None else "(?i)spam|(?i)junk",
|
||||||
|
"custom_parameters": self.custom_parameters if self.custom_parameters is not None else "",
|
||||||
|
"delete2duplicates": 1 if self.delete2duplicates else 0,
|
||||||
|
"delete1": 1 if self.delete1 else 0,
|
||||||
|
"delete2": 1 if self.delete2 else 0,
|
||||||
|
"automap": 1 if self.automap else 0,
|
||||||
|
"skipcrossduplicates": 1 if self.skipcrossduplicates else 0,
|
||||||
|
"subscribeall": 1 if self.subscribeall else 0,
|
||||||
|
"active": 1 if self.active else 0
|
||||||
|
}
|
||||||
|
return {key: value for key, value in syncjob.items() if value is not None}
|
||||||
|
|
||||||
|
def getEdit(self):
|
||||||
|
"""
|
||||||
|
Get the sync job details as a dictionary for editing, sets no default values.
|
||||||
|
:return: Dictionary containing sync job details.
|
||||||
|
"""
|
||||||
|
syncjob = {
|
||||||
|
"username": self.username,
|
||||||
|
"host1": self.host1,
|
||||||
|
"port1": self.port1,
|
||||||
|
"user1": self.user1,
|
||||||
|
"password1": self.password1,
|
||||||
|
"enc1": self.enc1,
|
||||||
|
"mins_interval": self.mins_interval,
|
||||||
|
"subfolder2": self.subfolder2,
|
||||||
|
"maxage": self.maxage,
|
||||||
|
"maxbytespersecond": self.maxbytespersecond,
|
||||||
|
"timeout1": self.timeout1,
|
||||||
|
"timeout2": self.timeout2,
|
||||||
|
"exclude": self.exclude,
|
||||||
|
"custom_parameters": self.custom_parameters,
|
||||||
|
"delete2duplicates": self.delete2duplicates,
|
||||||
|
"delete1": self.delete1,
|
||||||
|
"delete2": self.delete2,
|
||||||
|
"automap": self.automap,
|
||||||
|
"skipcrossduplicates": self.skipcrossduplicates,
|
||||||
|
"subscribeall": self.subscribeall,
|
||||||
|
"active": self.active
|
||||||
|
}
|
||||||
|
return {key: value for key, value in syncjob.items() if value is not None}
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
"""
|
||||||
|
Get the sync job details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.getSyncjob(self.username)
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""
|
||||||
|
Get the sync job details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.deleteSyncjob(self.id)
|
||||||
|
|
||||||
|
def add(self):
|
||||||
|
"""
|
||||||
|
Get the sync job details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.addSyncjob(self.getAdd())
|
||||||
|
|
||||||
|
def edit(self):
|
||||||
|
"""
|
||||||
|
Get the sync job details from the mailcow API.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.editSyncjob(self.id, self.getEdit())
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""
|
||||||
|
Run the sync job.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.mailcow.runSyncjob(self.id, force=self.force)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_parser(cls, subparsers):
|
||||||
|
parser = subparsers.add_parser(
|
||||||
|
cls.parser_command,
|
||||||
|
help="Manage sync jobs (add, delete, get, edit)"
|
||||||
|
)
|
||||||
|
parser.add_argument("object", choices=list(cls.required_args.keys()), help="Action to perform: add, delete, get, edit")
|
||||||
|
parser.add_argument("--id", help="Syncjob object ID (required for edit, delete, run)")
|
||||||
|
parser.add_argument("--username", help="Target mailbox username (e.g. user@example.com)")
|
||||||
|
parser.add_argument("--host1", help="Source IMAP server hostname")
|
||||||
|
parser.add_argument("--port1", help="Source IMAP server port")
|
||||||
|
parser.add_argument("--user1", help="Source IMAP account username")
|
||||||
|
parser.add_argument("--password1", help="Source IMAP account password")
|
||||||
|
parser.add_argument("--enc1", choices=["PLAIN", "SSL", "TLS"], help="Encryption for source server connection")
|
||||||
|
parser.add_argument("--mins-interval", help="Sync interval in minutes (default: 20)")
|
||||||
|
parser.add_argument("--subfolder2", help="Destination subfolder (default: empty)")
|
||||||
|
parser.add_argument("--maxage", help="Maximum mail age in days (default: 0 = unlimited)")
|
||||||
|
parser.add_argument("--maxbytespersecond", help="Maximum bandwidth in bytes/sec (default: 0 = unlimited)")
|
||||||
|
parser.add_argument("--timeout1", help="Timeout for source server in seconds (default: 600)")
|
||||||
|
parser.add_argument("--timeout2", help="Timeout for destination server in seconds (default: 600)")
|
||||||
|
parser.add_argument("--exclude", help="Regex pattern to exclude folders (default: (?i)spam|(?i)junk)")
|
||||||
|
parser.add_argument("--custom-parameters", help="Additional imapsync parameters")
|
||||||
|
parser.add_argument("--delete2duplicates", choices=["1", "0"], help="Delete duplicates on destination (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--del1", choices=["1", "0"], help="Delete mails on source after sync (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--del2", choices=["1", "0"], help="Delete mails on destination after sync (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--automap", choices=["1", "0"], help="Enable folder automapping (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--skipcrossduplicates", choices=["1", "0"], help="Skip cross-account duplicates (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--subscribeall", choices=["1", "0"], help="Subscribe to all folders (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--active", choices=["1", "0"], help="Activate syncjob (1 = yes, 0 = no)")
|
||||||
|
parser.add_argument("--force", action="store_true", help="Force the syncjob to run even if it is not active")
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import docker
|
||||||
|
from docker.errors import APIError
|
||||||
|
|
||||||
|
class Docker:
|
||||||
|
def __init__(self):
|
||||||
|
self.client = docker.from_env()
|
||||||
|
|
||||||
|
def exec_command(self, container_name, cmd, user=None):
|
||||||
|
"""
|
||||||
|
Execute a command in a container by its container name.
|
||||||
|
:param container_name: The name of the container.
|
||||||
|
:param cmd: The command to execute as a list (e.g., ["ls", "-la"]).
|
||||||
|
:param user: The user to execute the command as (optional).
|
||||||
|
:return: A standardized response with status, output, and exit_code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filters = {"name": container_name}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for container in self.client.containers.list(filters=filters):
|
||||||
|
exec_result = container.exec_run(cmd, user=user)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"exit_code": exec_result.exit_code,
|
||||||
|
"output": exec_result.output.decode("utf-8")
|
||||||
|
}
|
||||||
|
except APIError as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"exit_code": "APIError",
|
||||||
|
"output": str(e)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"exit_code": "Exception",
|
||||||
|
"output": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def start_container(self, container_name):
|
||||||
|
"""
|
||||||
|
Start a container by its container name.
|
||||||
|
:param container_name: The name of the container.
|
||||||
|
:return: A standardized response with status, output, and exit_code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filters = {"name": container_name}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for container in self.client.containers.list(filters=filters):
|
||||||
|
container.start()
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"exit_code": "0",
|
||||||
|
"output": f"Container '{container_name}' started successfully."
|
||||||
|
}
|
||||||
|
except APIError as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"exit_code": "APIError",
|
||||||
|
"output": str(e)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error_type": "Exception",
|
||||||
|
"output": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def stop_container(self, container_name):
|
||||||
|
"""
|
||||||
|
Stop a container by its container name.
|
||||||
|
:param container_name: The name of the container.
|
||||||
|
:return: A standardized response with status, output, and exit_code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filters = {"name": container_name}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for container in self.client.containers.list(filters=filters):
|
||||||
|
container.stop()
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"exit_code": "0",
|
||||||
|
"output": f"Container '{container_name}' stopped successfully."
|
||||||
|
}
|
||||||
|
except APIError as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"exit_code": "APIError",
|
||||||
|
"output": str(e)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"exit_code": "Exception",
|
||||||
|
"output": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
def restart_container(self, container_name):
|
||||||
|
"""
|
||||||
|
Restart a container by its container name.
|
||||||
|
:param container_name: The name of the container.
|
||||||
|
:return: A standardized response with status, output, and exit_code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
filters = {"name": container_name}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for container in self.client.containers.list(filters=filters):
|
||||||
|
container.restart()
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"exit_code": "0",
|
||||||
|
"output": f"Container '{container_name}' restarted successfully."
|
||||||
|
}
|
||||||
|
except APIError as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"exit_code": "APIError",
|
||||||
|
"output": str(e)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"exit_code": "Exception",
|
||||||
|
"output": str(e)
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from modules.Docker import Docker
|
||||||
|
|
||||||
|
class Dovecot:
|
||||||
|
def __init__(self):
|
||||||
|
self.docker = Docker()
|
||||||
|
|
||||||
|
def decryptMaildir(self, source_dir="/var/vmail/", output_dir=None):
|
||||||
|
"""
|
||||||
|
Decrypt files in /var/vmail using doveadm if they are encrypted.
|
||||||
|
:param output_dir: Directory inside the Dovecot container to store decrypted files, Default overwrite.
|
||||||
|
"""
|
||||||
|
private_key = "/mail_crypt/ecprivkey.pem"
|
||||||
|
public_key = "/mail_crypt/ecpubkey.pem"
|
||||||
|
|
||||||
|
if output_dir:
|
||||||
|
# Ensure the output directory exists inside the container
|
||||||
|
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c 'mkdir -p {output_dir} && chown vmail:vmail {output_dir}'")
|
||||||
|
if mkdir_result.get("status") != "success":
|
||||||
|
print(f"Error creating output directory: {mkdir_result.get('output')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
find_command = [
|
||||||
|
"find", source_dir, "-type", "f", "-regextype", "egrep", "-regex", ".*S=.*W=.*"
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
find_result = self.docker.exec_command("dovecot-mailcow", " ".join(find_command))
|
||||||
|
if find_result.get("status") != "success":
|
||||||
|
print(f"Error finding files: {find_result.get('output')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
files = find_result.get("output", "").splitlines()
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
head_command = f"head -c7 {file}"
|
||||||
|
head_result = self.docker.exec_command("dovecot-mailcow", head_command)
|
||||||
|
if head_result.get("status") == "success" and head_result.get("output", "").strip() == "CRYPTED":
|
||||||
|
if output_dir:
|
||||||
|
# Preserve the directory structure in the output directory
|
||||||
|
relative_path = os.path.relpath(file, source_dir)
|
||||||
|
output_file = os.path.join(output_dir, relative_path)
|
||||||
|
current_path = output_dir
|
||||||
|
for part in os.path.dirname(relative_path).split(os.sep):
|
||||||
|
current_path = os.path.join(current_path, part)
|
||||||
|
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c '[ ! -d {current_path} ] && mkdir {current_path} && chown vmail:vmail {current_path}'")
|
||||||
|
if mkdir_result.get("status") != "success":
|
||||||
|
print(f"Error creating directory {current_path}: {mkdir_result.get('output')}")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Overwrite the original file
|
||||||
|
output_file = file
|
||||||
|
|
||||||
|
decrypt_command = (
|
||||||
|
f"bash -c 'doveadm fs get compress lz4:1:crypt:private_key_path={private_key}:public_key_path={public_key}:posix:prefix=/ {file} > {output_file}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
decrypt_result = self.docker.exec_command("dovecot-mailcow", decrypt_command)
|
||||||
|
if decrypt_result.get("status") == "success":
|
||||||
|
print(f"Decrypted {file}")
|
||||||
|
|
||||||
|
# Verify the file size and set permissions
|
||||||
|
size_check_command = f"bash -c '[ -s {output_file} ] && chmod 600 {output_file} && chown vmail:vmail {output_file} || rm -f {output_file}'"
|
||||||
|
size_check_result = self.docker.exec_command("dovecot-mailcow", size_check_command)
|
||||||
|
if size_check_result.get("status") != "success":
|
||||||
|
print(f"Error setting permissions for {output_file}: {size_check_result.get('output')}\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during decryption: {e}")
|
||||||
|
|
||||||
|
return "Done"
|
||||||
|
|
||||||
|
def encryptMaildir(self, source_dir="/var/vmail/", output_dir=None):
|
||||||
|
"""
|
||||||
|
Encrypt files in /var/vmail using doveadm if they are not already encrypted.
|
||||||
|
:param source_dir: Directory inside the Dovecot container to encrypt files.
|
||||||
|
:param output_dir: Directory inside the Dovecot container to store encrypted files, Default overwrite.
|
||||||
|
"""
|
||||||
|
private_key = "/mail_crypt/ecprivkey.pem"
|
||||||
|
public_key = "/mail_crypt/ecpubkey.pem"
|
||||||
|
|
||||||
|
if output_dir:
|
||||||
|
# Ensure the output directory exists inside the container
|
||||||
|
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"mkdir -p {output_dir}")
|
||||||
|
if mkdir_result.get("status") != "success":
|
||||||
|
print(f"Error creating output directory: {mkdir_result.get('output')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
find_command = [
|
||||||
|
"find", source_dir, "-type", "f", "-regextype", "egrep", "-regex", ".*S=.*W=.*"
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
find_result = self.docker.exec_command("dovecot-mailcow", " ".join(find_command))
|
||||||
|
if find_result.get("status") != "success":
|
||||||
|
print(f"Error finding files: {find_result.get('output')}")
|
||||||
|
return
|
||||||
|
|
||||||
|
files = find_result.get("output", "").splitlines()
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
head_command = f"head -c7 {file}"
|
||||||
|
head_result = self.docker.exec_command("dovecot-mailcow", head_command)
|
||||||
|
if head_result.get("status") == "success" and head_result.get("output", "").strip() != "CRYPTED":
|
||||||
|
if output_dir:
|
||||||
|
# Preserve the directory structure in the output directory
|
||||||
|
relative_path = os.path.relpath(file, source_dir)
|
||||||
|
output_file = os.path.join(output_dir, relative_path)
|
||||||
|
current_path = output_dir
|
||||||
|
for part in os.path.dirname(relative_path).split(os.sep):
|
||||||
|
current_path = os.path.join(current_path, part)
|
||||||
|
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c '[ ! -d {current_path} ] && mkdir {current_path} && chown vmail:vmail {current_path}'")
|
||||||
|
if mkdir_result.get("status") != "success":
|
||||||
|
print(f"Error creating directory {current_path}: {mkdir_result.get('output')}")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Overwrite the original file
|
||||||
|
output_file = file
|
||||||
|
|
||||||
|
encrypt_command = (
|
||||||
|
f"bash -c 'doveadm fs put crypt private_key_path={private_key}:public_key_path={public_key}:posix:prefix=/ {file} {output_file}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
encrypt_result = self.docker.exec_command("dovecot-mailcow", encrypt_command)
|
||||||
|
if encrypt_result.get("status") == "success":
|
||||||
|
print(f"Encrypted {file}")
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
permissions_command = f"bash -c 'chmod 600 {output_file} && chown 5000:5000 {output_file}'"
|
||||||
|
permissions_result = self.docker.exec_command("dovecot-mailcow", permissions_command)
|
||||||
|
if permissions_result.get("status") != "success":
|
||||||
|
print(f"Error setting permissions for {output_file}: {permissions_result.get('output')}\n")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during encryption: {e}")
|
||||||
|
|
||||||
|
return "Done"
|
||||||
|
|
||||||
|
def listDeletedMaildirs(self, source_dir="/var/vmail/_garbage"):
|
||||||
|
"""
|
||||||
|
List deleted maildirs in the specified garbage directory.
|
||||||
|
:param source_dir: Directory to search for deleted maildirs.
|
||||||
|
:return: List of maildirs.
|
||||||
|
"""
|
||||||
|
list_command = ["bash", "-c", f"ls -la {source_dir}"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.docker.exec_command("dovecot-mailcow", list_command)
|
||||||
|
if result.get("status") != "success":
|
||||||
|
print(f"Error listing deleted maildirs: {result.get('output')}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
lines = result.get("output", "").splitlines()
|
||||||
|
maildirs = {}
|
||||||
|
|
||||||
|
for idx, line in enumerate(lines):
|
||||||
|
parts = line.split()
|
||||||
|
if "_" in line:
|
||||||
|
folder_name = parts[-1]
|
||||||
|
time, maildir = folder_name.split("_", 1)
|
||||||
|
|
||||||
|
if maildir.endswith("_index"):
|
||||||
|
main_item = maildir[:-6]
|
||||||
|
if main_item in maildirs:
|
||||||
|
maildirs[main_item]["has_index"] = True
|
||||||
|
else:
|
||||||
|
maildirs[maildir] = {"item": idx, "time": time, "name": maildir, "has_index": False}
|
||||||
|
|
||||||
|
return list(maildirs.values())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during listing deleted maildirs: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def restoreMaildir(self, username, item, source_dir="/var/vmail/_garbage"):
|
||||||
|
"""
|
||||||
|
Restore a maildir item for a specific user from the deleted maildirs.
|
||||||
|
:param username: Username to restore the item to.
|
||||||
|
:param item: Item to restore (e.g., mailbox, folder).
|
||||||
|
:param source_dir: Directory containing deleted maildirs.
|
||||||
|
:return: Response from Dovecot.
|
||||||
|
"""
|
||||||
|
username_splitted = username.split("@")
|
||||||
|
maildirs = self.listDeletedMaildirs()
|
||||||
|
|
||||||
|
maildir = None
|
||||||
|
for mdir in maildirs:
|
||||||
|
if mdir["item"] == int(item):
|
||||||
|
maildir = mdir
|
||||||
|
break
|
||||||
|
if not maildir:
|
||||||
|
return {"status": "error", "message": "Maildir not found."}
|
||||||
|
|
||||||
|
restore_command = f"mv {source_dir}/{maildir['time']}_{maildir['name']} /var/vmail/{username_splitted[1]}/{username_splitted[0]}"
|
||||||
|
restore_index_command = f"mv {source_dir}/{maildir['time']}_{maildir['name']}_index /var/vmail_index/{username}"
|
||||||
|
|
||||||
|
result = self.docker.exec_command("dovecot-mailcow", ["bash", "-c", restore_command])
|
||||||
|
if result.get("status") != "success":
|
||||||
|
return {"status": "error", "message": "Failed to restore maildir."}
|
||||||
|
|
||||||
|
result = self.docker.exec_command("dovecot-mailcow", ["bash", "-c", restore_index_command])
|
||||||
|
if result.get("status") != "success":
|
||||||
|
return {"status": "error", "message": "Failed to restore maildir index."}
|
||||||
|
|
||||||
|
return "Done"
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import mysql.connector
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from modules.Docker import Docker
|
||||||
|
|
||||||
|
|
||||||
|
class Mailcow:
|
||||||
|
def __init__(self):
|
||||||
|
self.apiUrl = "/api/v1"
|
||||||
|
self.ignore_ssl_errors = True
|
||||||
|
|
||||||
|
self.baseUrl = f"https://{os.getenv('IPv4_NETWORK', '172.22.1')}.247:{os.getenv('HTTPS_PORT', '443')}"
|
||||||
|
self.host = os.getenv("MAILCOW_HOSTNAME", "")
|
||||||
|
self.apiKey = ""
|
||||||
|
if self.ignore_ssl_errors:
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
self.db_config = {
|
||||||
|
'user': os.getenv('DBUSER'),
|
||||||
|
'password': os.getenv('DBPASS'),
|
||||||
|
'database': os.getenv('DBNAME'),
|
||||||
|
'unix_socket': '/var/run/mysqld/mysqld.sock',
|
||||||
|
}
|
||||||
|
|
||||||
|
self.docker = Docker()
|
||||||
|
|
||||||
|
|
||||||
|
# API Functions
|
||||||
|
def addDomain(self, domain):
|
||||||
|
"""
|
||||||
|
Add a domain to the mailcow instance.
|
||||||
|
:param domain: Dictionary containing domain details.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.post('/add/domain', domain)
|
||||||
|
|
||||||
|
def addMailbox(self, mailbox):
|
||||||
|
"""
|
||||||
|
Add a mailbox to the mailcow instance.
|
||||||
|
:param mailbox: Dictionary containing mailbox details.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.post('/add/mailbox', mailbox)
|
||||||
|
|
||||||
|
def addAlias(self, alias):
|
||||||
|
"""
|
||||||
|
Add an alias to the mailcow instance.
|
||||||
|
:param alias: Dictionary containing alias details.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.post('/add/alias', alias)
|
||||||
|
|
||||||
|
def addSyncjob(self, syncjob):
|
||||||
|
"""
|
||||||
|
Add a sync job to the mailcow instance.
|
||||||
|
:param syncjob: Dictionary containing sync job details.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.post('/add/syncjob', syncjob)
|
||||||
|
|
||||||
|
def addDomainadmin(self, domainadmin):
|
||||||
|
"""
|
||||||
|
Add a domain admin to the mailcow instance.
|
||||||
|
:param domainadmin: Dictionary containing domain admin details.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.post('/add/domain-admin', domainadmin)
|
||||||
|
|
||||||
|
def deleteDomain(self, domain):
|
||||||
|
"""
|
||||||
|
Delete a domain from the mailcow instance.
|
||||||
|
:param domain: Name of the domain to delete.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [domain]
|
||||||
|
return self.post('/delete/domain', items)
|
||||||
|
|
||||||
|
def deleteAlias(self, id):
|
||||||
|
"""
|
||||||
|
Delete an alias from the mailcow instance.
|
||||||
|
:param id: ID of the alias to delete.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [id]
|
||||||
|
return self.post('/delete/alias', items)
|
||||||
|
|
||||||
|
def deleteSyncjob(self, id):
|
||||||
|
"""
|
||||||
|
Delete a sync job from the mailcow instance.
|
||||||
|
:param id: ID of the sync job to delete.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [id]
|
||||||
|
return self.post('/delete/syncjob', items)
|
||||||
|
|
||||||
|
def deleteMailbox(self, mailbox):
|
||||||
|
"""
|
||||||
|
Delete a mailbox from the mailcow instance.
|
||||||
|
:param mailbox: Name of the mailbox to delete.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [mailbox]
|
||||||
|
return self.post('/delete/mailbox', items)
|
||||||
|
|
||||||
|
def deleteDomainadmin(self, username):
|
||||||
|
"""
|
||||||
|
Delete a domain admin from the mailcow instance.
|
||||||
|
:param username: Username of the domain admin to delete.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [username]
|
||||||
|
return self.post('/delete/domain-admin', items)
|
||||||
|
|
||||||
|
def post(self, endpoint, data):
|
||||||
|
"""
|
||||||
|
Make a POST request to the mailcow API.
|
||||||
|
:param endpoint: The API endpoint to post to.
|
||||||
|
:param data: Data to be sent in the POST request.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Host": self.host
|
||||||
|
}
|
||||||
|
if self.apiKey:
|
||||||
|
headers["X-Api-Key"] = self.apiKey
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
json=data,
|
||||||
|
headers=headers,
|
||||||
|
verify=not self.ignore_ssl_errors
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def getDomain(self, domain):
|
||||||
|
"""
|
||||||
|
Get a domain from the mailcow instance.
|
||||||
|
:param domain: Name of the domain to get.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.get(f'/get/domain/{domain}')
|
||||||
|
|
||||||
|
def getMailbox(self, username):
|
||||||
|
"""
|
||||||
|
Get a mailbox from the mailcow instance.
|
||||||
|
:param mailbox: Dictionary containing mailbox details (e.g. {"username": "user@example.com"})
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.get(f'/get/mailbox/{username}')
|
||||||
|
|
||||||
|
def getAlias(self, id):
|
||||||
|
"""
|
||||||
|
Get an alias from the mailcow instance.
|
||||||
|
:param alias: Dictionary containing alias details (e.g. {"address": "alias@example.com"})
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.get(f'/get/alias/{id}')
|
||||||
|
|
||||||
|
def getSyncjob(self, id):
|
||||||
|
"""
|
||||||
|
Get a sync job from the mailcow instance.
|
||||||
|
:param syncjob: Dictionary containing sync job details (e.g. {"id": "123"})
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.get(f'/get/syncjobs/{id}')
|
||||||
|
|
||||||
|
def getDomainadmin(self, username):
|
||||||
|
"""
|
||||||
|
Get a domain admin from the mailcow instance.
|
||||||
|
:param username: Username of the domain admin to get.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.get(f'/get/domain-admin/{username}')
|
||||||
|
|
||||||
|
def getStatusVersion(self):
|
||||||
|
"""
|
||||||
|
Get the version of the mailcow instance.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.get('/get/status/version')
|
||||||
|
|
||||||
|
def getStatusVmail(self):
|
||||||
|
"""
|
||||||
|
Get the vmail status from the mailcow instance.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.get('/get/status/vmail')
|
||||||
|
|
||||||
|
def getStatusContainers(self):
|
||||||
|
"""
|
||||||
|
Get the status of containers from the mailcow instance.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
return self.get('/get/status/containers')
|
||||||
|
|
||||||
|
def get(self, endpoint, params=None):
|
||||||
|
"""
|
||||||
|
Make a GET request to the mailcow API.
|
||||||
|
:param endpoint: The API endpoint to get from.
|
||||||
|
:param params: Parameters to be sent in the GET request.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Host": self.host
|
||||||
|
}
|
||||||
|
if self.apiKey:
|
||||||
|
headers["X-Api-Key"] = self.apiKey
|
||||||
|
response = requests.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
headers=headers,
|
||||||
|
verify=not self.ignore_ssl_errors
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def editDomain(self, domain, attributes):
|
||||||
|
"""
|
||||||
|
Edit an existing domain in the mailcow instance.
|
||||||
|
:param domain: Name of the domain to edit
|
||||||
|
:param attributes: Dictionary containing the new domain attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [domain]
|
||||||
|
return self.edit('/edit/domain', items, attributes)
|
||||||
|
|
||||||
|
def editMailbox(self, mailbox, attributes):
|
||||||
|
"""
|
||||||
|
Edit an existing mailbox in the mailcow instance.
|
||||||
|
:param mailbox: Name of the mailbox to edit
|
||||||
|
:param attributes: Dictionary containing the new mailbox attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [mailbox]
|
||||||
|
return self.edit('/edit/mailbox', items, attributes)
|
||||||
|
|
||||||
|
def editAlias(self, alias, attributes):
|
||||||
|
"""
|
||||||
|
Edit an existing alias in the mailcow instance.
|
||||||
|
:param alias: Name of the alias to edit
|
||||||
|
:param attributes: Dictionary containing the new alias attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [alias]
|
||||||
|
return self.edit('/edit/alias', items, attributes)
|
||||||
|
|
||||||
|
def editSyncjob(self, syncjob, attributes):
|
||||||
|
"""
|
||||||
|
Edit an existing sync job in the mailcow instance.
|
||||||
|
:param syncjob: Name of the sync job to edit
|
||||||
|
:param attributes: Dictionary containing the new sync job attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [syncjob]
|
||||||
|
return self.edit('/edit/syncjob', items, attributes)
|
||||||
|
|
||||||
|
def editDomainadmin(self, username, attributes):
|
||||||
|
"""
|
||||||
|
Edit an existing domain admin in the mailcow instance.
|
||||||
|
:param username: Username of the domain admin to edit
|
||||||
|
:param attributes: Dictionary containing the new domain admin attributes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = [username]
|
||||||
|
return self.edit('/edit/domain-admin', items, attributes)
|
||||||
|
|
||||||
|
def edit(self, endpoint, items, attributes):
|
||||||
|
"""
|
||||||
|
Make a POST request to edit items in the mailcow API.
|
||||||
|
:param items: List of items to edit.
|
||||||
|
:param attributes: Dictionary containing the new attributes for the items.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Host": self.host
|
||||||
|
}
|
||||||
|
if self.apiKey:
|
||||||
|
headers["X-Api-Key"] = self.apiKey
|
||||||
|
data = {
|
||||||
|
"items": items,
|
||||||
|
"attr": attributes
|
||||||
|
}
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
json=data,
|
||||||
|
headers=headers,
|
||||||
|
verify=not self.ignore_ssl_errors
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
# System Functions
|
||||||
|
def runSyncjob(self, id, force=False):
|
||||||
|
"""
|
||||||
|
Run a sync job.
|
||||||
|
:param id: ID of the sync job to run.
|
||||||
|
:return: Response from the imapsync script.
|
||||||
|
"""
|
||||||
|
|
||||||
|
creds_path = "/app/sieve.creds"
|
||||||
|
|
||||||
|
conn = mysql.connector.connect(**self.db_config)
|
||||||
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
|
with open(creds_path, 'r') as file:
|
||||||
|
master_user, master_pass = file.read().strip().split(':')
|
||||||
|
|
||||||
|
query = ("SELECT * FROM imapsync WHERE id = %s")
|
||||||
|
cursor.execute(query, (id,))
|
||||||
|
|
||||||
|
success = False
|
||||||
|
syncjob = cursor.fetchone()
|
||||||
|
if not syncjob:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return f"Sync job with ID {id} not found."
|
||||||
|
if syncjob['active'] == 0 and not force:
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
return f"Sync job with ID {id} is not active."
|
||||||
|
|
||||||
|
enc1_flag = "--tls1" if syncjob['enc1'] == "TLS" else "--ssl1" if syncjob['enc1'] == "SSL" else None
|
||||||
|
|
||||||
|
|
||||||
|
passfile1_path = f"/tmp/passfile1_{id}.txt"
|
||||||
|
passfile2_path = f"/tmp/passfile2_{id}.txt"
|
||||||
|
passfile1_cmd = [
|
||||||
|
"sh", "-c",
|
||||||
|
f"echo {syncjob['password1']} > {passfile1_path}"
|
||||||
|
]
|
||||||
|
passfile2_cmd = [
|
||||||
|
"sh", "-c",
|
||||||
|
f"echo {master_pass} > {passfile2_path}"
|
||||||
|
]
|
||||||
|
|
||||||
|
self.docker.exec_command("dovecot-mailcow", passfile1_cmd)
|
||||||
|
self.docker.exec_command("dovecot-mailcow", passfile2_cmd)
|
||||||
|
|
||||||
|
imapsync_cmd = [
|
||||||
|
"/usr/local/bin/imapsync",
|
||||||
|
"--tmpdir", "/tmp",
|
||||||
|
"--nofoldersizes",
|
||||||
|
"--addheader"
|
||||||
|
]
|
||||||
|
|
||||||
|
if int(syncjob['timeout1']) > 0:
|
||||||
|
imapsync_cmd.extend(['--timeout1', str(syncjob['timeout1'])])
|
||||||
|
if int(syncjob['timeout2']) > 0:
|
||||||
|
imapsync_cmd.extend(['--timeout2', str(syncjob['timeout2'])])
|
||||||
|
if syncjob['exclude']:
|
||||||
|
imapsync_cmd.extend(['--exclude', syncjob['exclude']])
|
||||||
|
if syncjob['subfolder2']:
|
||||||
|
imapsync_cmd.extend(['--subfolder2', syncjob['subfolder2']])
|
||||||
|
if int(syncjob['maxage']) > 0:
|
||||||
|
imapsync_cmd.extend(['--maxage', str(syncjob['maxage'])])
|
||||||
|
if int(syncjob['maxbytespersecond']) > 0:
|
||||||
|
imapsync_cmd.extend(['--maxbytespersecond', str(syncjob['maxbytespersecond'])])
|
||||||
|
if int(syncjob['delete2duplicates']) == 1:
|
||||||
|
imapsync_cmd.append("--delete2duplicates")
|
||||||
|
if int(syncjob['subscribeall']) == 1:
|
||||||
|
imapsync_cmd.append("--subscribeall")
|
||||||
|
if int(syncjob['delete1']) == 1:
|
||||||
|
imapsync_cmd.append("--delete")
|
||||||
|
if int(syncjob['delete2']) == 1:
|
||||||
|
imapsync_cmd.append("--delete2")
|
||||||
|
if int(syncjob['automap']) == 1:
|
||||||
|
imapsync_cmd.append("--automap")
|
||||||
|
if int(syncjob['skipcrossduplicates']) == 1:
|
||||||
|
imapsync_cmd.append("--skipcrossduplicates")
|
||||||
|
if enc1_flag:
|
||||||
|
imapsync_cmd.append(enc1_flag)
|
||||||
|
|
||||||
|
imapsync_cmd.extend([
|
||||||
|
"--host1", syncjob['host1'],
|
||||||
|
"--user1", syncjob['user1'],
|
||||||
|
"--passfile1", passfile1_path,
|
||||||
|
"--port1", str(syncjob['port1']),
|
||||||
|
"--host2", "localhost",
|
||||||
|
"--user2", f"{syncjob['user2']}*{master_user}",
|
||||||
|
"--passfile2", passfile2_path
|
||||||
|
])
|
||||||
|
|
||||||
|
if syncjob['dry'] == 1:
|
||||||
|
imapsync_cmd.append("--dry")
|
||||||
|
|
||||||
|
imapsync_cmd.extend([
|
||||||
|
"--no-modulesversion",
|
||||||
|
"--noreleasecheck"
|
||||||
|
])
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL WHERE id = %s", (id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
result = self.docker.exec_command("dovecot-mailcow", imapsync_cmd)
|
||||||
|
print(result)
|
||||||
|
|
||||||
|
success = result['status'] == "success" and result['exit_code'] == 0
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE imapsync SET returned_text = %s, success = %s, exit_status = %s WHERE id = %s",
|
||||||
|
(result['output'], int(success), result['exit_code'], id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE imapsync SET returned_text = %s, success = 0 WHERE id = %s",
|
||||||
|
(str(e), id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
cursor.execute("UPDATE imapsync SET last_run = NOW(), is_running = 0 WHERE id = %s", (id,))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
delete_passfile1_cmd = [
|
||||||
|
"sh", "-c",
|
||||||
|
f"rm -f {passfile1_path}"
|
||||||
|
]
|
||||||
|
delete_passfile2_cmd = [
|
||||||
|
"sh", "-c",
|
||||||
|
f"rm -f {passfile2_path}"
|
||||||
|
]
|
||||||
|
self.docker.exec_command("dovecot-mailcow", delete_passfile1_cmd)
|
||||||
|
self.docker.exec_command("dovecot-mailcow", delete_passfile2_cmd)
|
||||||
|
|
||||||
|
cursor.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return "Sync job completed successfully." if success else "Sync job failed."
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import smtplib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from jinja2 import Environment, BaseLoader
|
||||||
|
|
||||||
|
class Mailer:
|
||||||
|
def __init__(self, smtp_host, smtp_port, username, password, use_tls=True):
|
||||||
|
self.smtp_host = smtp_host
|
||||||
|
self.smtp_port = smtp_port
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.use_tls = use_tls
|
||||||
|
self.server = None
|
||||||
|
self.env = Environment(loader=BaseLoader())
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
print("Connecting to the SMTP server...")
|
||||||
|
self.server = smtplib.SMTP(self.smtp_host, self.smtp_port)
|
||||||
|
if self.use_tls:
|
||||||
|
self.server.starttls()
|
||||||
|
print("TLS activated!")
|
||||||
|
if self.username and self.password:
|
||||||
|
self.server.login(self.username, self.password)
|
||||||
|
print("Authenticated!")
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
if self.server:
|
||||||
|
try:
|
||||||
|
if self.server.sock:
|
||||||
|
self.server.quit()
|
||||||
|
except smtplib.SMTPServerDisconnected:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self.server = None
|
||||||
|
|
||||||
|
def render_inline_template(self, template_string, context):
|
||||||
|
template = self.env.from_string(template_string)
|
||||||
|
return template.render(context)
|
||||||
|
|
||||||
|
def send_mail(self, subject, from_addr, to_addrs, template, context = {}):
|
||||||
|
try:
|
||||||
|
if template == "":
|
||||||
|
print("Cannot send email, template is empty!")
|
||||||
|
return "Failed: Template is empty."
|
||||||
|
|
||||||
|
body = self.render_inline_template(template, context)
|
||||||
|
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg['From'] = from_addr
|
||||||
|
msg['To'] = ', '.join(to_addrs) if isinstance(to_addrs, list) else to_addrs
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg.attach(MIMEText(body, 'html'))
|
||||||
|
|
||||||
|
self.connect()
|
||||||
|
self.server.sendmail(from_addr, to_addrs, msg.as_string())
|
||||||
|
self.disconnect()
|
||||||
|
return f"Success: Email sent to {msg['To']}"
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during send_mail: {type(e).__name__}: {e}")
|
||||||
|
return f"Failed: {type(e).__name__}: {e}"
|
||||||
|
finally:
|
||||||
|
self.disconnect()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from jinja2 import Environment, Template
|
||||||
|
import csv
|
||||||
|
|
||||||
|
def split_at(value, sep, idx):
|
||||||
|
try:
|
||||||
|
return value.split(sep)[idx]
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
class Reader:
|
||||||
|
"""
|
||||||
|
Reader class to handle reading and processing of CSV and JSON files for mailcow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def read_csv(self, file_path, delimiter=',', encoding='iso-8859-1'):
|
||||||
|
"""
|
||||||
|
Read a CSV file and return a list of dictionaries.
|
||||||
|
Each dictionary represents a row in the CSV file.
|
||||||
|
:param file_path: Path to the CSV file.
|
||||||
|
:param delimiter: Delimiter used in the CSV file (default: ',').
|
||||||
|
"""
|
||||||
|
with open(file_path, mode='r', encoding=encoding) as file:
|
||||||
|
reader = csv.DictReader(file, delimiter=delimiter)
|
||||||
|
reader.fieldnames = [h.replace(" ", "_") if h else h for h in reader.fieldnames]
|
||||||
|
return [row for row in reader]
|
||||||
|
|
||||||
|
def map_csv_data(self, data, mapping_file_path, encoding='iso-8859-1'):
|
||||||
|
"""
|
||||||
|
Map CSV data to a specific structure based on the provided Jinja2 template file.
|
||||||
|
:param data: List of dictionaries representing CSV rows.
|
||||||
|
:param mapping_file_path: Path to the Jinja2 template file.
|
||||||
|
:return: List of dictionaries with mapped data.
|
||||||
|
"""
|
||||||
|
with open(mapping_file_path, 'r', encoding=encoding) as tpl_file:
|
||||||
|
template_content = tpl_file.read()
|
||||||
|
env = Environment()
|
||||||
|
env.filters['split_at'] = split_at
|
||||||
|
template = env.from_string(template_content)
|
||||||
|
|
||||||
|
mapped_data = []
|
||||||
|
for row in data:
|
||||||
|
rendered = template.render(**row)
|
||||||
|
try:
|
||||||
|
mapped_row = eval(rendered)
|
||||||
|
except Exception:
|
||||||
|
mapped_row = rendered
|
||||||
|
mapped_data.append(mapped_row)
|
||||||
|
return mapped_data
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
import os
|
||||||
|
from uuid import uuid4
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
|
||||||
|
class Sogo:
|
||||||
|
def __init__(self, username, password=""):
|
||||||
|
self.apiUrl = "/SOGo/so"
|
||||||
|
self.davUrl = "/SOGo/dav"
|
||||||
|
self.ignore_ssl_errors = True
|
||||||
|
|
||||||
|
self.baseUrl = f"https://{os.getenv('IPv4_NETWORK', '172.22.1')}.247:{os.getenv('HTTPS_PORT', '443')}"
|
||||||
|
self.host = os.getenv("MAILCOW_HOSTNAME", "")
|
||||||
|
if self.ignore_ssl_errors:
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
|
||||||
|
def addCalendar(self, calendar_name):
|
||||||
|
"""
|
||||||
|
Add a new calendar to the sogo instance.
|
||||||
|
:param calendar_name: Name of the calendar to be created
|
||||||
|
:return: Response from the sogo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.post(f"/{self.username}/Calendar/createFolder", {
|
||||||
|
"name": calendar_name
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def getCalendarIdByName(self, calendar_name):
|
||||||
|
"""
|
||||||
|
Get the calendar ID by its name.
|
||||||
|
:param calendar_name: Name of the calendar to find
|
||||||
|
:return: Calendar ID if found, otherwise None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Calendar/calendarslist")
|
||||||
|
try:
|
||||||
|
for calendar in res.json()["calendars"]:
|
||||||
|
if calendar['name'] == calendar_name:
|
||||||
|
return calendar['id']
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def getCalendar(self):
|
||||||
|
"""
|
||||||
|
Get calendar list.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Calendar/calendarslist")
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def deleteCalendar(self, calendar_id):
|
||||||
|
"""
|
||||||
|
Delete a calendar.
|
||||||
|
:param calendar_id: ID of the calendar to be deleted
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
res = self.get(f"/{self.username}/Calendar/{calendar_id}/delete")
|
||||||
|
return res.status_code == 204
|
||||||
|
|
||||||
|
def importCalendar(self, calendar_name, ics_file):
|
||||||
|
"""
|
||||||
|
Import a calendar from an ICS file.
|
||||||
|
:param calendar_name: Name of the calendar to import into
|
||||||
|
:param ics_file: Path to the ICS file to import
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(ics_file, "rb") as f:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Could not open ICS file '{ics_file}': {e}")
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
new_calendar = self.addCalendar(calendar_name)
|
||||||
|
selected_calendar = new_calendar.json()["id"]
|
||||||
|
|
||||||
|
url = f"{self.baseUrl}{self.apiUrl}/{self.username}/Calendar/{selected_calendar}/import"
|
||||||
|
auth = (self.username, self.password)
|
||||||
|
with open(ics_file, "rb") as f:
|
||||||
|
files = {'icsFile': (ics_file, f, 'text/calendar')}
|
||||||
|
res = requests.post(
|
||||||
|
url,
|
||||||
|
files=files,
|
||||||
|
auth=auth,
|
||||||
|
verify=not self.ignore_ssl_errors
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def setCalendarACL(self, calendar_id, sharee_email, acl="r", subscribe=False):
|
||||||
|
"""
|
||||||
|
Set CalDAV calendar permissions for a user (sharee).
|
||||||
|
:param calendar_id: ID of the calendar to share
|
||||||
|
:param sharee_email: Email of the user to share with
|
||||||
|
:param acl: "w" for write, "r" for read-only or combination "rw" for read-write
|
||||||
|
:param subscribe: True will scubscribe the sharee to the calendar
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Access rights
|
||||||
|
if acl == "" or len(acl) > 2:
|
||||||
|
return "Invalid acl level specified. Use 'w', 'r' or combinations like 'rw'."
|
||||||
|
rights = [{
|
||||||
|
"c_email": sharee_email,
|
||||||
|
"uid": sharee_email,
|
||||||
|
"userClass": "normal-user",
|
||||||
|
"rights": {
|
||||||
|
"Public": "None",
|
||||||
|
"Private": "None",
|
||||||
|
"Confidential": "None",
|
||||||
|
"canCreateObjects": 0,
|
||||||
|
"canEraseObjects": 0
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
if "w" in acl:
|
||||||
|
rights[0]["rights"]["canCreateObjects"] = 1
|
||||||
|
rights[0]["rights"]["canEraseObjects"] = 1
|
||||||
|
if "r" in acl:
|
||||||
|
rights[0]["rights"]["Public"] = "Viewer"
|
||||||
|
rights[0]["rights"]["Private"] = "Viewer"
|
||||||
|
rights[0]["rights"]["Confidential"] = "Viewer"
|
||||||
|
|
||||||
|
r_add = self.get(f"/{self.username}/Calendar/{calendar_id}/addUserInAcls?uid={sharee_email}")
|
||||||
|
if r_add.status_code < 200 or r_add.status_code > 299:
|
||||||
|
try:
|
||||||
|
return r_add.json()
|
||||||
|
except ValueError:
|
||||||
|
return r_add.text
|
||||||
|
|
||||||
|
r_save = self.post(f"/{self.username}/Calendar/{calendar_id}/saveUserRights", rights)
|
||||||
|
if r_save.status_code < 200 or r_save.status_code > 299:
|
||||||
|
try:
|
||||||
|
return r_save.json()
|
||||||
|
except ValueError:
|
||||||
|
return r_save.text
|
||||||
|
|
||||||
|
if subscribe:
|
||||||
|
r_subscribe = self.get(f"/{self.username}/Calendar/{calendar_id}/subscribeUsers?uids={sharee_email}")
|
||||||
|
if r_subscribe.status_code < 200 or r_subscribe.status_code > 299:
|
||||||
|
try:
|
||||||
|
return r_subscribe.json()
|
||||||
|
except ValueError:
|
||||||
|
return r_subscribe.text
|
||||||
|
|
||||||
|
return r_save.status_code == 200
|
||||||
|
|
||||||
|
def getCalendarACL(self, calendar_id):
|
||||||
|
"""
|
||||||
|
Get CalDAV calendar permissions for a user (sharee).
|
||||||
|
:param calendar_id: ID of the calendar to get ACL from
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Calendar/{calendar_id}/acls")
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def deleteCalendarACL(self, calendar_id, sharee_email):
|
||||||
|
"""
|
||||||
|
Delete a calendar ACL for a user (sharee).
|
||||||
|
:param calendar_id: ID of the calendar to delete ACL from
|
||||||
|
:param sharee_email: Email of the user whose ACL to delete
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Calendar/{calendar_id}/removeUserFromAcls?uid={sharee_email}")
|
||||||
|
return res.status_code == 204
|
||||||
|
|
||||||
|
def addAddressbook(self, addressbook_name):
|
||||||
|
"""
|
||||||
|
Add a new addressbook to the sogo instance.
|
||||||
|
:param addressbook_name: Name of the addressbook to be created
|
||||||
|
:return: Response from the sogo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.post(f"/{self.username}/Contacts/createFolder", {
|
||||||
|
"name": addressbook_name
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def getAddressbookIdByName(self, addressbook_name):
|
||||||
|
"""
|
||||||
|
Get the addressbook ID by its name.
|
||||||
|
:param addressbook_name: Name of the addressbook to find
|
||||||
|
:return: Addressbook ID if found, otherwise None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Contacts/addressbooksList")
|
||||||
|
try:
|
||||||
|
for addressbook in res.json()["addressbooks"]:
|
||||||
|
if addressbook['name'] == addressbook_name:
|
||||||
|
return addressbook['id']
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def deleteAddressbook(self, addressbook_id):
|
||||||
|
"""
|
||||||
|
Delete an addressbook.
|
||||||
|
:param addressbook_id: ID of the addressbook to be deleted
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/delete")
|
||||||
|
return res.status_code == 204
|
||||||
|
|
||||||
|
def getAddressbookList(self):
|
||||||
|
"""
|
||||||
|
Get addressbook list.
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Contacts/addressbooksList")
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def setAddressbookACL(self, addressbook_id, sharee_email, acl="r", subscribe=False):
|
||||||
|
"""
|
||||||
|
Set CalDAV addressbook permissions for a user (sharee).
|
||||||
|
:param addressbook_id: ID of the addressbook to share
|
||||||
|
:param sharee_email: Email of the user to share with
|
||||||
|
:param acl: "w" for write, "r" for read-only or combination "rw" for read-write
|
||||||
|
:param subscribe: True will subscribe the sharee to the addressbook
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Access rights
|
||||||
|
if acl == "" or len(acl) > 2:
|
||||||
|
print("Invalid acl level specified. Use 's', 'w', 'r' or combinations like 'rws'.")
|
||||||
|
return "Invalid acl level specified. Use 'w', 'r' or combinations like 'rw'."
|
||||||
|
rights = [{
|
||||||
|
"c_email": sharee_email,
|
||||||
|
"uid": sharee_email,
|
||||||
|
"userClass": "normal-user",
|
||||||
|
"rights": {
|
||||||
|
"canCreateObjects": 0,
|
||||||
|
"canEditObjects": 0,
|
||||||
|
"canEraseObjects": 0,
|
||||||
|
"canViewObjects": 0,
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
if "w" in acl:
|
||||||
|
rights[0]["rights"]["canCreateObjects"] = 1
|
||||||
|
rights[0]["rights"]["canEditObjects"] = 1
|
||||||
|
rights[0]["rights"]["canEraseObjects"] = 1
|
||||||
|
if "r" in acl:
|
||||||
|
rights[0]["rights"]["canViewObjects"] = 1
|
||||||
|
|
||||||
|
r_add = self.get(f"/{self.username}/Contacts/{addressbook_id}/addUserInAcls?uid={sharee_email}")
|
||||||
|
if r_add.status_code < 200 or r_add.status_code > 299:
|
||||||
|
try:
|
||||||
|
return r_add.json()
|
||||||
|
except ValueError:
|
||||||
|
return r_add.text
|
||||||
|
|
||||||
|
r_save = self.post(f"/{self.username}/Contacts/{addressbook_id}/saveUserRights", rights)
|
||||||
|
if r_save.status_code < 200 or r_save.status_code > 299:
|
||||||
|
try:
|
||||||
|
return r_save.json()
|
||||||
|
except ValueError:
|
||||||
|
return r_save.text
|
||||||
|
|
||||||
|
if subscribe:
|
||||||
|
r_subscribe = self.get(f"/{self.username}/Contacts/{addressbook_id}/subscribeUsers?uids={sharee_email}")
|
||||||
|
if r_subscribe.status_code < 200 or r_subscribe.status_code > 299:
|
||||||
|
try:
|
||||||
|
return r_subscribe.json()
|
||||||
|
except ValueError:
|
||||||
|
return r_subscribe.text
|
||||||
|
|
||||||
|
return r_save.status_code == 200
|
||||||
|
|
||||||
|
def getAddressbookACL(self, addressbook_id):
|
||||||
|
"""
|
||||||
|
Get CalDAV addressbook permissions for a user (sharee).
|
||||||
|
:param addressbook_id: ID of the addressbook to get ACL from
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/acls")
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def deleteAddressbookACL(self, addressbook_id, sharee_email):
|
||||||
|
"""
|
||||||
|
Delete an addressbook ACL for a user (sharee).
|
||||||
|
:param addressbook_id: ID of the addressbook to delete ACL from
|
||||||
|
:param sharee_email: Email of the user whose ACL to delete
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/removeUserFromAcls?uid={sharee_email}")
|
||||||
|
return res.status_code == 204
|
||||||
|
|
||||||
|
def getAddressbookNewGuid(self, addressbook_id):
|
||||||
|
"""
|
||||||
|
Request a new GUID for a SOGo addressbook.
|
||||||
|
:param addressbook_id: ID of the addressbook
|
||||||
|
:return: JSON response from SOGo or None if not found
|
||||||
|
"""
|
||||||
|
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/newguid")
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def addAddressbookContact(self, addressbook_id, contact_name, contact_email):
|
||||||
|
"""
|
||||||
|
Save a vCard as a contact in the specified addressbook.
|
||||||
|
:param addressbook_id: ID of the addressbook
|
||||||
|
:param contact_name: Name of the contact
|
||||||
|
:param contact_email: Email of the contact
|
||||||
|
:return: JSON response from SOGo or None if not found
|
||||||
|
"""
|
||||||
|
vcard_id = self.getAddressbookNewGuid(addressbook_id)
|
||||||
|
contact_data = {
|
||||||
|
"id": vcard_id["id"],
|
||||||
|
"pid": vcard_id["pid"],
|
||||||
|
"c_cn": contact_name,
|
||||||
|
"emails": [{
|
||||||
|
"type": "pref",
|
||||||
|
"value": contact_email
|
||||||
|
}],
|
||||||
|
"isNew": True,
|
||||||
|
"c_component": "vcard",
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = f"/{self.username}/Contacts/{addressbook_id}/{vcard_id['id']}/saveAsContact"
|
||||||
|
res = self.post(endpoint, contact_data)
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def getAddressbookContacts(self, addressbook_id, contact_email=None):
|
||||||
|
"""
|
||||||
|
Get all contacts from the specified addressbook.
|
||||||
|
:param addressbook_id: ID of the addressbook
|
||||||
|
:return: JSON response with contacts or None if not found
|
||||||
|
"""
|
||||||
|
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/view")
|
||||||
|
try:
|
||||||
|
res_json = res.json()
|
||||||
|
headers = res_json.get("headers", [])
|
||||||
|
if not headers or len(headers) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
field_names = headers[0]
|
||||||
|
contacts = []
|
||||||
|
for row in headers[1:]:
|
||||||
|
contact = dict(zip(field_names, row))
|
||||||
|
contacts.append(contact)
|
||||||
|
|
||||||
|
if contact_email:
|
||||||
|
contact = {}
|
||||||
|
for c in contacts:
|
||||||
|
if c["c_mail"] == contact_email or c["c_cn"] == contact_email:
|
||||||
|
contact = c
|
||||||
|
break
|
||||||
|
return contact
|
||||||
|
|
||||||
|
return contacts
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def addAddressbookContactList(self, addressbook_id, contact_name, contact_email=None):
|
||||||
|
"""
|
||||||
|
Add a new contact list to the addressbook.
|
||||||
|
:param addressbook_id: ID of the addressbook
|
||||||
|
:param contact_name: Name of the contact list
|
||||||
|
:param contact_email: Comma-separated emails to include in the list
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
gal_domain = self.username.split("@")[-1]
|
||||||
|
vlist_id = self.getAddressbookNewGuid(addressbook_id)
|
||||||
|
contact_emails = contact_email.split(",") if contact_email else []
|
||||||
|
contacts = self.getAddressbookContacts(addressbook_id)
|
||||||
|
|
||||||
|
refs = []
|
||||||
|
for contact in contacts:
|
||||||
|
if contact['c_mail'] in contact_emails:
|
||||||
|
refs.append({
|
||||||
|
"refs": [],
|
||||||
|
"categories": [],
|
||||||
|
"c_screenname": contact.get("c_screenname", ""),
|
||||||
|
"pid": contact.get("pid", vlist_id["pid"]),
|
||||||
|
"id": contact.get("id", ""),
|
||||||
|
"notes": [""],
|
||||||
|
"empty": " ",
|
||||||
|
"hasphoto": contact.get("hasphoto", 0),
|
||||||
|
"c_cn": contact.get("c_cn", ""),
|
||||||
|
"c_uid": contact.get("c_uid", None),
|
||||||
|
"containername": contact.get("containername", f"GAL {gal_domain}"), # or your addressbook name
|
||||||
|
"sourceid": contact.get("sourceid", gal_domain),
|
||||||
|
"c_component": contact.get("c_component", "vcard"),
|
||||||
|
"c_sn": contact.get("c_sn", ""),
|
||||||
|
"c_givenname": contact.get("c_givenname", ""),
|
||||||
|
"c_name": contact.get("c_name", contact.get("id", "")),
|
||||||
|
"c_telephonenumber": contact.get("c_telephonenumber", ""),
|
||||||
|
"fn": contact.get("fn", ""),
|
||||||
|
"c_mail": contact.get("c_mail", ""),
|
||||||
|
"emails": contact.get("emails", []),
|
||||||
|
"c_o": contact.get("c_o", ""),
|
||||||
|
"reference": contact.get("id", ""),
|
||||||
|
"birthday": contact.get("birthday", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
contact_data = {
|
||||||
|
"refs": refs,
|
||||||
|
"categories": [],
|
||||||
|
"c_screenname": None,
|
||||||
|
"pid": vlist_id["pid"],
|
||||||
|
"c_component": "vlist",
|
||||||
|
"notes": [""],
|
||||||
|
"empty": " ",
|
||||||
|
"isNew": True,
|
||||||
|
"id": vlist_id["id"],
|
||||||
|
"c_cn": contact_name,
|
||||||
|
"birthday": ""
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint = f"/{self.username}/Contacts/{addressbook_id}/{vlist_id['id']}/saveAsList"
|
||||||
|
res = self.post(endpoint, contact_data)
|
||||||
|
try:
|
||||||
|
return res.json()
|
||||||
|
except ValueError:
|
||||||
|
return res.text
|
||||||
|
|
||||||
|
def deleteAddressbookItem(self, addressbook_id, contact_name):
|
||||||
|
"""
|
||||||
|
Delete an addressbook item by its ID.
|
||||||
|
:param addressbook_id: ID of the addressbook item to delete
|
||||||
|
:param contact_name: Name of the contact to delete
|
||||||
|
:return: Response from SOGo API.
|
||||||
|
"""
|
||||||
|
res = self.getAddressbookContacts(addressbook_id, contact_name)
|
||||||
|
|
||||||
|
if "id" not in res:
|
||||||
|
print(f"Contact '{contact_name}' not found in addressbook '{addressbook_id}'.")
|
||||||
|
return None
|
||||||
|
res = self.post(f"/{self.username}/Contacts/{addressbook_id}/batchDelete", {
|
||||||
|
"uids": [res["id"]],
|
||||||
|
})
|
||||||
|
return res.status_code == 204
|
||||||
|
|
||||||
|
def get(self, endpoint, params=None):
|
||||||
|
"""
|
||||||
|
Make a GET request to the mailcow API.
|
||||||
|
:param endpoint: The API endpoint to get.
|
||||||
|
:param params: Optional parameters for the GET request.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
url = f"{self.baseUrl}{self.apiUrl}{endpoint}"
|
||||||
|
auth = (self.username, self.password)
|
||||||
|
headers = {"Host": self.host}
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
url,
|
||||||
|
params=params,
|
||||||
|
auth=auth,
|
||||||
|
headers=headers,
|
||||||
|
verify=not self.ignore_ssl_errors
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def post(self, endpoint, data):
|
||||||
|
"""
|
||||||
|
Make a POST request to the mailcow API.
|
||||||
|
:param endpoint: The API endpoint to post to.
|
||||||
|
:param data: Data to be sent in the POST request.
|
||||||
|
:return: Response from the mailcow API.
|
||||||
|
"""
|
||||||
|
url = f"{self.baseUrl}{self.apiUrl}{endpoint}"
|
||||||
|
auth = (self.username, self.password)
|
||||||
|
headers = {"Host": self.host}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
url,
|
||||||
|
json=data,
|
||||||
|
auth=auth,
|
||||||
|
headers=headers,
|
||||||
|
verify=not self.ignore_ssl_errors
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import json
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
|
||||||
|
class Utils:
|
||||||
|
def __init(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def normalize_email(self, email):
|
||||||
|
replacements = {
|
||||||
|
"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss",
|
||||||
|
"Ä": "Ae", "Ö": "Oe", "Ü": "Ue"
|
||||||
|
}
|
||||||
|
for orig, repl in replacements.items():
|
||||||
|
email = email.replace(orig, repl)
|
||||||
|
return email
|
||||||
|
|
||||||
|
def generate_password(self, length=8):
|
||||||
|
chars = string.ascii_letters + string.digits
|
||||||
|
return ''.join(random.choices(chars, k=length))
|
||||||
|
|
||||||
|
def pprint(self, data=""):
|
||||||
|
"""
|
||||||
|
Pretty print a dictionary, list, or text.
|
||||||
|
If data is a text containing JSON, it will be printed in a formatted way.
|
||||||
|
"""
|
||||||
|
if isinstance(data, (dict, list)):
|
||||||
|
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
elif isinstance(data, str):
|
||||||
|
try:
|
||||||
|
json_data = json.loads(data)
|
||||||
|
print(json.dumps(json_data, indent=2, ensure_ascii=False))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(data)
|
||||||
|
else:
|
||||||
|
print(data)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
jinja2
|
||||||
|
requests
|
||||||
|
mysql-connector-python
|
||||||
|
pytest
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
|
||||||
|
from models.DomainModel import DomainModel
|
||||||
|
from models.AliasModel import AliasModel
|
||||||
|
|
||||||
|
|
||||||
|
def test_model():
|
||||||
|
# Generate random alias
|
||||||
|
random_alias = f"alias_test{os.urandom(4).hex()}@mailcow.local"
|
||||||
|
|
||||||
|
# Create an instance of AliasModel
|
||||||
|
model = AliasModel(
|
||||||
|
address=random_alias,
|
||||||
|
goto="test@mailcow.local,test2@mailcow.local"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test the parser_command attribute
|
||||||
|
assert model.parser_command == "alias", "Parser command should be 'alias'"
|
||||||
|
|
||||||
|
# add Domain for testing
|
||||||
|
domain_model = DomainModel(domain="mailcow.local")
|
||||||
|
domain_model.add()
|
||||||
|
|
||||||
|
# 1. Alias add tests, should success
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['type'] == "success", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 3, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['msg'][0] == "alias_added", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'alias_added'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# Assign created alias ID for further tests
|
||||||
|
model.id = r_add[0]['msg'][2]
|
||||||
|
|
||||||
|
# 2. Alias add tests, should fail because the alias already exists
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['msg'][0] == "is_alias_or_mailbox", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'is_alias_or_mailbox'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# 3. Alias get tests
|
||||||
|
r_get = model.get()
|
||||||
|
assert isinstance(r_get, dict), f"Expected a dict but received: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "domain" in r_get, f"'domain' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "goto" in r_get, f"'goto' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "address" in r_get, f"'address' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get['domain'] == model.address.split("@")[1], f"Wrong 'domain' received: {r_get['domain']}, expected: {model.address.split('@')[1]}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get['goto'] == model.goto, f"Wrong 'goto' received: {r_get['goto']}, expected: {model.goto}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get['address'] == model.address, f"Wrong 'address' received: {r_get['address']}, expected: {model.address}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
|
||||||
|
# 4. Alias edit tests
|
||||||
|
model.goto = "test@mailcow.local"
|
||||||
|
model.active = 0
|
||||||
|
r_edit = model.edit()
|
||||||
|
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['msg'][0] == "alias_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'alias_modified'\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
|
||||||
|
# 5. Alias delete tests
|
||||||
|
r_delete = model.delete()
|
||||||
|
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['msg'][0] == "alias_removed", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'alias_removed'\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
|
||||||
|
# delete testing Domain
|
||||||
|
domain_model.delete()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running AliasModel tests...")
|
||||||
|
test_model()
|
||||||
|
print("All tests passed!")
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import pytest
|
||||||
|
from models.BaseModel import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Args:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_required_args():
|
||||||
|
BaseModel.required_args = {
|
||||||
|
"test_object": [["arg1"], ["arg2", "arg3"]],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test cases with Args object
|
||||||
|
args = Args(object="non_existent_object")
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = Args(object="test_object")
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = Args(object="test_object", arg1="value")
|
||||||
|
assert BaseModel.has_required_args(args) == True
|
||||||
|
|
||||||
|
args = Args(object="test_object", arg2="value")
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = Args(object="test_object", arg3="value")
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = Args(object="test_object", arg2="value", arg3="value")
|
||||||
|
assert BaseModel.has_required_args(args) == True
|
||||||
|
|
||||||
|
# Test cases with dict object
|
||||||
|
args = {"object": "non_existent_object"}
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = {"object": "test_object"}
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = {"object": "test_object", "arg1": "value"}
|
||||||
|
assert BaseModel.has_required_args(args) == True
|
||||||
|
|
||||||
|
args = {"object": "test_object", "arg2": "value"}
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = {"object": "test_object", "arg3": "value"}
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = {"object": "test_object", "arg2": "value", "arg3": "value"}
|
||||||
|
assert BaseModel.has_required_args(args) == True
|
||||||
|
|
||||||
|
|
||||||
|
BaseModel.required_args = {
|
||||||
|
"test_object": [[]],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Test cases with Args object
|
||||||
|
args = Args(object="non_existent_object")
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = Args(object="test_object")
|
||||||
|
assert BaseModel.has_required_args(args) == True
|
||||||
|
|
||||||
|
# Test cases with dict object
|
||||||
|
args = {"object": "non_existent_object"}
|
||||||
|
assert BaseModel.has_required_args(args) == False
|
||||||
|
|
||||||
|
args = {"object": "test_object"}
|
||||||
|
assert BaseModel.has_required_args(args) == True
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
|
||||||
|
from models.DomainModel import DomainModel
|
||||||
|
|
||||||
|
|
||||||
|
def test_model():
|
||||||
|
# Create an instance of DomainModel
|
||||||
|
model = DomainModel(
|
||||||
|
domain="mailcow.local",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test the parser_command attribute
|
||||||
|
assert model.parser_command == "domain", "Parser command should be 'domain'"
|
||||||
|
|
||||||
|
# 1. Domain add tests, should success
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0 and len(r_add) >= 2, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[1], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[1]['type'] == "success", f"Wrong 'type' received: {r_add[1]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[1], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[1]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[1]['msg']) > 0 and len(r_add[1]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[1]['msg'][0] == "domain_added", f"Wrong 'msg' received: {r_add[1]['msg'][0]}, expected: 'domain_added'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# 2. Domain add tests, should fail because the domain already exists
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['msg'][0] == "domain_exists", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'domain_exists'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# 3. Domain get tests
|
||||||
|
r_get = model.get()
|
||||||
|
assert isinstance(r_get, dict), f"Expected a dict but received: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "domain_name" in r_get, f"'domain_name' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get['domain_name'] == model.domain, f"Wrong 'domain_name' received: {r_get['domain_name']}, expected: {model.domain}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
|
||||||
|
# 4. Domain edit tests
|
||||||
|
model.active = 0
|
||||||
|
r_edit = model.edit()
|
||||||
|
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['msg'][0] == "domain_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'domain_modified'\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
|
||||||
|
# 5. Domain delete tests
|
||||||
|
r_delete = model.delete()
|
||||||
|
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['msg'][0] == "domain_removed", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'domain_removed'\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running DomainModel tests...")
|
||||||
|
test_model()
|
||||||
|
print("All tests passed!")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
|
||||||
|
from models.DomainModel import DomainModel
|
||||||
|
from models.DomainadminModel import DomainadminModel
|
||||||
|
|
||||||
|
|
||||||
|
def test_model():
|
||||||
|
# Generate random domainadmin
|
||||||
|
random_username = f"dadmin_test{os.urandom(4).hex()}"
|
||||||
|
random_password = f"{os.urandom(4).hex()}"
|
||||||
|
|
||||||
|
# Create an instance of DomainadminModel
|
||||||
|
model = DomainadminModel(
|
||||||
|
username=random_username,
|
||||||
|
password=random_password,
|
||||||
|
domains="mailcow.local",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test the parser_command attribute
|
||||||
|
assert model.parser_command == "domainadmin", "Parser command should be 'domainadmin'"
|
||||||
|
|
||||||
|
# add Domain for testing
|
||||||
|
domain_model = DomainModel(domain="mailcow.local")
|
||||||
|
domain_model.add()
|
||||||
|
|
||||||
|
# 1. Domainadmin add tests, should success
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['type'] == "success", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 3, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['msg'][0] == "domain_admin_added", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'domain_admin_added'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# 2. Domainadmin add tests, should fail because the domainadmin already exists
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['msg'][0] == "object_exists", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'object_exists'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# 3. Domainadmin get tests
|
||||||
|
r_get = model.get()
|
||||||
|
assert isinstance(r_get, dict), f"Expected a dict but received: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "selected_domains" in r_get, f"'selected_domains' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "username" in r_get, f"'username' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert set(model.domains.replace(" ", "").split(",")) == set(r_get['selected_domains']), f"Wrong 'selected_domains' received: {r_get['selected_domains']}, expected: {model.domains}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get['username'] == model.username, f"Wrong 'username' received: {r_get['username']}, expected: {model.username}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
|
||||||
|
# 4. Domainadmin edit tests
|
||||||
|
model.active = 0
|
||||||
|
r_edit = model.edit()
|
||||||
|
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['msg'][0] == "domain_admin_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'domain_admin_modified'\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
|
||||||
|
# 5. Domainadmin delete tests
|
||||||
|
r_delete = model.delete()
|
||||||
|
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['msg'][0] == "domain_admin_removed", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'domain_admin_removed'\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
|
||||||
|
# delete testing Domain
|
||||||
|
domain_model.delete()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running DomainadminModel tests...")
|
||||||
|
test_model()
|
||||||
|
print("All tests passed!")
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
|
||||||
|
from models.DomainModel import DomainModel
|
||||||
|
from models.MailboxModel import MailboxModel
|
||||||
|
|
||||||
|
|
||||||
|
def test_model():
|
||||||
|
# Generate random mailbox
|
||||||
|
random_username = f"mbox_test{os.urandom(4).hex()}@mailcow.local"
|
||||||
|
random_password = f"{os.urandom(4).hex()}"
|
||||||
|
|
||||||
|
# Create an instance of MailboxModel
|
||||||
|
model = MailboxModel(
|
||||||
|
username=random_username,
|
||||||
|
password=random_password
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test the parser_command attribute
|
||||||
|
assert model.parser_command == "mailbox", "Parser command should be 'mailbox'"
|
||||||
|
|
||||||
|
# add Domain for testing
|
||||||
|
domain_model = DomainModel(domain="mailcow.local")
|
||||||
|
domain_model.add()
|
||||||
|
|
||||||
|
# 1. Mailbox add tests, should success
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0 and len(r_add) <= 2, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[1], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[1]['type'] == "success", f"Wrong 'type' received: {r_add[1]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[1], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[1]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[1]['msg']) > 0 and len(r_add[1]['msg']) <= 3, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[1]['msg'][0] == "mailbox_added", f"Wrong 'msg' received: {r_add[1]['msg'][0]}, expected: 'mailbox_added'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# 2. Mailbox add tests, should fail because the mailbox already exists
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['msg'][0] == "object_exists", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'object_exists'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# 3. Mailbox get tests
|
||||||
|
r_get = model.get()
|
||||||
|
assert isinstance(r_get, dict), f"Expected a dict but received: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "domain" in r_get, f"'domain' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "local_part" in r_get, f"'local_part' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get['domain'] == model.domain, f"Wrong 'domain' received: {r_get['domain']}, expected: {model.domain}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get['local_part'] == model.local_part, f"Wrong 'local_part' received: {r_get['local_part']}, expected: {model.local_part}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
|
||||||
|
# 4. Mailbox edit tests
|
||||||
|
model.active = 0
|
||||||
|
r_edit = model.edit()
|
||||||
|
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['msg'][0] == "mailbox_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'mailbox_modified'\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
|
||||||
|
# 5. Mailbox delete tests
|
||||||
|
r_delete = model.delete()
|
||||||
|
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['msg'][0] == "mailbox_removed", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'mailbox_removed'\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
|
||||||
|
# delete testing Domain
|
||||||
|
domain_model.delete()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running MailboxModel tests...")
|
||||||
|
test_model()
|
||||||
|
print("All tests passed!")
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
|
||||||
|
from models.StatusModel import StatusModel
|
||||||
|
|
||||||
|
|
||||||
|
def test_model():
|
||||||
|
# Create an instance of StatusModel
|
||||||
|
model = StatusModel()
|
||||||
|
|
||||||
|
# Test the parser_command attribute
|
||||||
|
assert model.parser_command == "status", "Parser command should be 'status'"
|
||||||
|
|
||||||
|
# 1. Status version tests
|
||||||
|
r_version = model.version()
|
||||||
|
assert isinstance(r_version, dict), f"Expected a dict but received: {json.dumps(r_version, indent=2)}"
|
||||||
|
assert "version" in r_version, f"'version' key missing in response: {json.dumps(r_version, indent=2)}"
|
||||||
|
|
||||||
|
# 2. Status vmail tests
|
||||||
|
r_vmail = model.vmail()
|
||||||
|
assert isinstance(r_vmail, dict), f"Expected a dict but received: {json.dumps(r_vmail, indent=2)}"
|
||||||
|
assert "type" in r_vmail, f"'type' key missing in response: {json.dumps(r_vmail, indent=2)}"
|
||||||
|
assert "disk" in r_vmail, f"'disk' key missing in response: {json.dumps(r_vmail, indent=2)}"
|
||||||
|
assert "used" in r_vmail, f"'used' key missing in response: {json.dumps(r_vmail, indent=2)}"
|
||||||
|
assert "total" in r_vmail, f"'total' key missing in response: {json.dumps(r_vmail, indent=2)}"
|
||||||
|
assert "used_percent" in r_vmail, f"'used_percent' key missing in response: {json.dumps(r_vmail, indent=2)}"
|
||||||
|
|
||||||
|
# 3. Status containers tests
|
||||||
|
r_containers = model.containers()
|
||||||
|
assert isinstance(r_containers, dict), f"Expected a dict but received: {json.dumps(r_containers, indent=2)}"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running StatusModel tests...")
|
||||||
|
test_model()
|
||||||
|
print("All tests passed!")
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../")))
|
||||||
|
from models.DomainModel import DomainModel
|
||||||
|
from models.MailboxModel import MailboxModel
|
||||||
|
from models.SyncjobModel import SyncjobModel
|
||||||
|
|
||||||
|
|
||||||
|
def test_model():
|
||||||
|
# Generate random Mailbox
|
||||||
|
random_username = f"mbox_test@mailcow.local"
|
||||||
|
random_password = f"{os.urandom(4).hex()}"
|
||||||
|
|
||||||
|
# Create an instance of SyncjobModel
|
||||||
|
model = SyncjobModel(
|
||||||
|
username=random_username,
|
||||||
|
host1="mailcow.local",
|
||||||
|
port1=993,
|
||||||
|
user1="testuser@mailcow.local",
|
||||||
|
password1="testpassword",
|
||||||
|
enc1="SSL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test the parser_command attribute
|
||||||
|
assert model.parser_command == "syncjob", "Parser command should be 'syncjob'"
|
||||||
|
|
||||||
|
# add Domain and Mailbox for testing
|
||||||
|
domain_model = DomainModel(domain="mailcow.local")
|
||||||
|
domain_model.add()
|
||||||
|
mbox_model = MailboxModel(username=random_username, password=random_password)
|
||||||
|
mbox_model.add()
|
||||||
|
|
||||||
|
# 1. Syncjob add tests, should success
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0 and len(r_add) <= 2, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['type'] == "success", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 3, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['msg'][0] == "mailbox_modified", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'mailbox_modified'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# Assign created syncjob ID for further tests
|
||||||
|
model.id = r_add[0]['msg'][2]
|
||||||
|
|
||||||
|
# 2. Syncjob add tests, should fail because the syncjob already exists
|
||||||
|
r_add = model.add()
|
||||||
|
assert isinstance(r_add, list), f"Expected a array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add) > 0, f"Wrong array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert "type" in r_add[0], f"'type' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['type'] == "danger", f"Wrong 'type' received: {r_add[0]['type']}\n{json.dumps(r_add, indent=2)}"
|
||||||
|
assert "msg" in r_add[0], f"'msg' key missing in response: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert isinstance(r_add[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert len(r_add[0]['msg']) > 0 and len(r_add[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_add, indent=2)}"
|
||||||
|
assert r_add[0]['msg'][0] == "object_exists", f"Wrong 'msg' received: {r_add[0]['msg'][0]}, expected: 'object_exists'\n{json.dumps(r_add, indent=2)}"
|
||||||
|
|
||||||
|
# 3. Syncjob get tests
|
||||||
|
r_get = model.get()
|
||||||
|
assert isinstance(r_get, list), f"Expected a list but received: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "user2" in r_get[0], f"'user2' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "host1" in r_get[0], f"'host1' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "port1" in r_get[0], f"'port1' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "user1" in r_get[0], f"'user1' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert "enc1" in r_get[0], f"'enc1' key missing in response: {json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get[0]['user2'] == model.username, f"Wrong 'user2' received: {r_get[0]['user2']}, expected: {model.username}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get[0]['host1'] == model.host1, f"Wrong 'host1' received: {r_get[0]['host1']}, expected: {model.host1}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get[0]['port1'] == model.port1, f"Wrong 'port1' received: {r_get[0]['port1']}, expected: {model.port1}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get[0]['user1'] == model.user1, f"Wrong 'user1' received: {r_get[0]['user1']}, expected: {model.user1}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
assert r_get[0]['enc1'] == model.enc1, f"Wrong 'enc1' received: {r_get[0]['enc1']}, expected: {model.enc1}\n{json.dumps(r_get, indent=2)}"
|
||||||
|
|
||||||
|
# 4. Syncjob edit tests
|
||||||
|
model.active = 1
|
||||||
|
r_edit = model.edit()
|
||||||
|
assert isinstance(r_edit, list), f"Expected a array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit) > 0, f"Wrong array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "type" in r_edit[0], f"'type' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['type'] == "success", f"Wrong 'type' received: {r_edit[0]['type']}\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
assert "msg" in r_edit[0], f"'msg' key missing in response: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert isinstance(r_edit[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert len(r_edit[0]['msg']) > 0 and len(r_edit[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_edit, indent=2)}"
|
||||||
|
assert r_edit[0]['msg'][0] == "mailbox_modified", f"Wrong 'msg' received: {r_edit[0]['msg'][0]}, expected: 'mailbox_modified'\n{json.dumps(r_edit, indent=2)}"
|
||||||
|
|
||||||
|
# 5. Syncjob delete tests
|
||||||
|
r_delete = model.delete()
|
||||||
|
assert isinstance(r_delete, list), f"Expected a array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete) > 0, f"Wrong array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "type" in r_delete[0], f"'type' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['type'] == "success", f"Wrong 'type' received: {r_delete[0]['type']}\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
assert "msg" in r_delete[0], f"'msg' key missing in response: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert isinstance(r_delete[0]['msg'], list), f"Expected a 'msg' array but received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert len(r_delete[0]['msg']) > 0 and len(r_delete[0]['msg']) <= 2, f"Wrong 'msg' array received: {json.dumps(r_delete, indent=2)}"
|
||||||
|
assert r_delete[0]['msg'][0] == "deleted_syncjob", f"Wrong 'msg' received: {r_delete[0]['msg'][0]}, expected: 'deleted_syncjob'\n{json.dumps(r_delete, indent=2)}"
|
||||||
|
|
||||||
|
# delete testing Domain and Mailbox
|
||||||
|
mbox_model.delete()
|
||||||
|
domain_model.delete()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Running SyncjobModel tests...")
|
||||||
|
test_model()
|
||||||
|
print("All tests passed!")
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
printf "READY\n";
|
||||||
|
|
||||||
|
while read line; do
|
||||||
|
echo "Processing Event: $line" >&2;
|
||||||
|
kill -3 $(cat "/var/run/supervisord.pid")
|
||||||
|
done < /dev/stdin
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
pidfile=/var/run/supervisord.pid
|
||||||
|
|
||||||
|
[program:api]
|
||||||
|
command=python /app/api/main.py
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[eventlistener:processes]
|
||||||
|
command=/usr/local/sbin/stop-supervisor.sh
|
||||||
|
events=PROCESS_STATE_STOPPED, PROCESS_STATE_EXITED, PROCESS_STATE_FATAL
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
@@ -139,14 +135,5 @@ COPY quota_notify.py /usr/local/bin/quota_notify.py
|
|||||||
COPY repl_health.sh /usr/local/bin/repl_health.sh
|
COPY repl_health.sh /usr/local/bin/repl_health.sh
|
||||||
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
|
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=dovecot \
|
|
||||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ try:
|
|||||||
if max_score == "":
|
if max_score == "":
|
||||||
max_score = 9999.0
|
max_score = 9999.0
|
||||||
|
|
||||||
def query_mysql(query, params = None, headers = True, update = False):
|
def query_mysql(query, headers = True, update = False):
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
||||||
@@ -57,10 +57,7 @@ try:
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
cur = cnx.cursor()
|
cur = cnx.cursor()
|
||||||
if params:
|
cur.execute(query)
|
||||||
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] )
|
||||||
@@ -79,7 +76,7 @@ try:
|
|||||||
|
|
||||||
def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
|
def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
|
||||||
if category == "add_header": category = "add header"
|
if category == "add_header": category = "add header"
|
||||||
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = %s AND score < %s AND (action = %s OR "all" = %s)', (rcpt, max_score, category, category))
|
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category))
|
||||||
print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
|
print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
|
||||||
if len(meta_query) == 0:
|
if len(meta_query) == 0:
|
||||||
return
|
return
|
||||||
@@ -133,7 +130,7 @@ try:
|
|||||||
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
|
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
|
||||||
server.quit()
|
server.quit()
|
||||||
for res in meta_query:
|
for res in meta_query:
|
||||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = %s', (res['id'],), update = True)
|
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%d"' % (res['id']), update = True)
|
||||||
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
|
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
|
||||||
break
|
break
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@@ -141,7 +138,7 @@ try:
|
|||||||
print('%s' % (ex))
|
print('%s' % (ex))
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %s AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt', (max_score,))
|
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %f AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt' % (max_score))
|
||||||
|
|
||||||
for record in records:
|
for record in records:
|
||||||
attrs = ''
|
attrs = ''
|
||||||
@@ -159,7 +156,7 @@ try:
|
|||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
|
print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
|
||||||
last_notification = 0
|
last_notification = 0
|
||||||
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = %s', (record['rcpt'],))
|
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = "%s"' % (record['rcpt']))
|
||||||
attrs = attrs_json[0]['attributes']
|
attrs = attrs_json[0]['attributes']
|
||||||
if isinstance(attrs, str):
|
if isinstance(attrs, str):
|
||||||
# if attr is str then just load it
|
# if attr is str then just load it
|
||||||
|
|||||||
@@ -24,12 +24,13 @@ fi
|
|||||||
sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules
|
sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules
|
||||||
|
|
||||||
if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then
|
if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then
|
||||||
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
|
CONTAINER_NAME=rspamd-mailcow
|
||||||
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
|
CONTAINER_ID=$(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | \
|
||||||
REQ_ID="$(date +%s%N)"
|
jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | \
|
||||||
PAYLOAD="{\"cmd\":\"restart\",\"request_id\":\"${REQ_ID}\",\"issued_by\":\"dovecot-sa-rules\"}"
|
jq -rc "select( .name | tostring | contains(\"${CONTAINER_NAME}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
|
||||||
redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning \
|
if [[ ! -z ${CONTAINER_ID} ]]; then
|
||||||
PUBLISH mailcow.control.rspamd "${PAYLOAD}" >/dev/null 2>&1 || true
|
curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(`REDIS_SLAVEOF_PORT`)
|
port(`REDIS_SLAVEOF_PORT`)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
destination d_redis_f2b_channel {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(6379)
|
port(6379)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
destination d_redis_f2b_channel {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# host-agent: dedicated container that reads /host/proc to publish host-level
|
|
||||||
# stats and answer exec.df / exec.host-stats commands. Reuses the same agent
|
|
||||||
# binary; behaviour selected via MAILCOW_AGENT_SERVICE=host.
|
|
||||||
#
|
|
||||||
# Requires:
|
|
||||||
# volumes:
|
|
||||||
# - /proc:/host/proc:ro
|
|
||||||
# - /:/host/rootfs:ro
|
|
||||||
|
|
||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.0
|
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS agent
|
|
||||||
|
|
||||||
FROM alpine:3.20
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
|
||||||
COPY --from=agent /out/mailcow-agent /usr/local/bin/mailcow-agent
|
|
||||||
COPY --from=agent /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=host
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
FROM alpine:3.21
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM alpine:3.23
|
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
@@ -44,14 +40,4 @@ COPY ./docker-entrypoint.sh /app/
|
|||||||
|
|
||||||
RUN chmod +x /app/docker-entrypoint.sh
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=netfilter \
|
|
||||||
MAILCOW_AGENT_MAIN_CMD="/app/docker-entrypoint.sh"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
@@ -18,14 +14,5 @@ RUN mkdir -p /etc/nginx/includes
|
|||||||
COPY ./bootstrap.py /
|
COPY ./bootstrap.py /
|
||||||
COPY ./docker-entrypoint.sh /
|
COPY ./docker-entrypoint.sh /
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=nginx \
|
|
||||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh nginx -g 'daemon off;'"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM alpine:3.21
|
FROM alpine:3.21
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
@@ -22,16 +18,6 @@ ADD olefy.py /app/
|
|||||||
|
|
||||||
RUN chown -R nobody:nobody /app /tmp
|
RUN chown -R nobody:nobody /app /tmp
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
USER nobody
|
USER nobody
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=olefy \
|
CMD ["python3", "-u", "/app/olefy.py"]
|
||||||
MAILCOW_AGENT_MAIN_CMD="python3 -u /app/olefy.py"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
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>"
|
||||||
@@ -17,7 +13,7 @@ ARG MEMCACHED_PECL_VERSION=3.4.0
|
|||||||
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
|
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||||
ARG REDIS_PECL_VERSION=6.3.0
|
ARG REDIS_PECL_VERSION=6.3.0
|
||||||
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
|
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||||
ARG COMPOSER_VERSION=2.9.5
|
ARG COMPOSER_VERSION=2.8.6
|
||||||
|
|
||||||
RUN apk add -U --no-cache autoconf \
|
RUN apk add -U --no-cache autoconf \
|
||||||
aspell-dev \
|
aspell-dev \
|
||||||
@@ -76,7 +72,7 @@ RUN apk add -U --no-cache autoconf \
|
|||||||
&& pecl clear-cache \
|
&& pecl clear-cache \
|
||||||
&& docker-php-ext-configure intl \
|
&& docker-php-ext-configure intl \
|
||||||
&& docker-php-ext-configure exif \
|
&& docker-php-ext-configure exif \
|
||||||
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
||||||
--with-jpeg=/usr/include/ \
|
--with-jpeg=/usr/include/ \
|
||||||
--with-webp \
|
--with-webp \
|
||||||
--with-xpm \
|
--with-xpm \
|
||||||
@@ -113,14 +109,6 @@ RUN apk add -U --no-cache autoconf \
|
|||||||
|
|
||||||
COPY ./docker-entrypoint.sh /
|
COPY ./docker-entrypoint.sh /
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=php-fpm \
|
CMD ["php-fpm"]
|
||||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh php-fpm"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
|
|||||||
@@ -29,35 +29,63 @@ session.save_handler = redis
|
|||||||
session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'"
|
session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'"
|
||||||
' > /usr/local/etc/php/conf.d/session_store.ini
|
' > /usr/local/etc/php/conf.d/session_store.ini
|
||||||
|
|
||||||
# Wait for MariaDB. The upstream mariadb image already runs mariadb-upgrade
|
# Check mysql_upgrade (master and slave)
|
||||||
# itself on startup when needed
|
CONTAINER_ID=
|
||||||
echo "Waiting for MariaDB socket at /var/run/mysqld/mysqld.sock..."
|
until [[ ! -z "${CONTAINER_ID}" ]] && [[ "${CONTAINER_ID}" =~ ^[[:alnum:]]*$ ]]; do
|
||||||
WAIT_C=0
|
CONTAINER_ID=$(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"mysql-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
|
||||||
until mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} -e "SELECT 1" >/dev/null 2>&1; do
|
echo "Could not get mysql-mailcow container id... trying again"
|
||||||
WAIT_C=$((WAIT_C+1))
|
sleep 2
|
||||||
if [ ${WAIT_C} -gt 60 ]; then
|
done
|
||||||
echo "MariaDB did not respond after 60s — continuing anyway."
|
echo "MySQL @ ${CONTAINER_ID}"
|
||||||
|
SQL_LOOP_C=0
|
||||||
|
SQL_CHANGED=0
|
||||||
|
until [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; do
|
||||||
|
if [ ${SQL_LOOP_C} -gt 4 ]; then
|
||||||
|
echo "Tried to upgrade MySQL and failed, giving up after ${SQL_LOOP_C} retries and starting container (oops, not good)"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
sleep 1
|
SQL_FULL_UPGRADE_RETURN=$(curl --silent --insecure -XPOST https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_upgrade"}' --silent -H 'Content-type: application/json')
|
||||||
|
SQL_UPGRADE_STATUS=$(echo ${SQL_FULL_UPGRADE_RETURN} | jq -r .type)
|
||||||
|
SQL_LOOP_C=$((SQL_LOOP_C+1))
|
||||||
|
echo "SQL upgrade iteration #${SQL_LOOP_C}"
|
||||||
|
if [[ ${SQL_UPGRADE_STATUS} == 'warning' ]]; then
|
||||||
|
SQL_CHANGED=1
|
||||||
|
echo "MySQL applied an upgrade, debug output:"
|
||||||
|
echo ${SQL_FULL_UPGRADE_RETURN}
|
||||||
|
sleep 3
|
||||||
|
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
|
||||||
|
echo "Waiting for SQL to return, please wait"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
continue
|
||||||
|
elif [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; then
|
||||||
|
echo "MySQL is up-to-date - debug output:"
|
||||||
|
echo ${SQL_FULL_UPGRADE_RETURN}
|
||||||
|
else
|
||||||
|
echo "No valid reponse for mysql_upgrade was received, debug output:"
|
||||||
|
echo ${SQL_FULL_UPGRADE_RETURN}
|
||||||
|
fi
|
||||||
done
|
done
|
||||||
echo "MariaDB is ready."
|
|
||||||
|
|
||||||
# Timezone tables — check if CONVERT_TZ works, import if it returns NULL.
|
# doing post-installation stuff, if SQL was upgraded (master and slave)
|
||||||
# Some Alpine builds drop mariadb-tzinfo-to-sql; fall back to a Python
|
if [ ${SQL_CHANGED} -eq 1 ]; then
|
||||||
# emitter that produces the same INSERT statements from /usr/share/zoneinfo.
|
POSTFIX=$(curl --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
|
||||||
|
if [[ -z "${POSTFIX}" ]] || ! [[ "${POSTFIX}" =~ ^[[:alnum:]]*$ ]]; then
|
||||||
|
echo "Could not determine Postfix container ID, skipping Postfix restart."
|
||||||
|
else
|
||||||
|
echo "Restarting Postfix"
|
||||||
|
curl -X POST --silent --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/restart | jq -r '.msg'
|
||||||
|
echo "Sleeping 5 seconds..."
|
||||||
|
sleep 5
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check mysql tz import (master and slave)
|
||||||
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
|
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
|
||||||
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
|
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
|
||||||
echo "Importing timezone data into mysql.time_zone_* …"
|
SQL_FULL_TZINFO_IMPORT_RETURN=$(curl --silent --insecure -XPOST https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_tzinfo_to_sql"}' --silent -H 'Content-type: application/json')
|
||||||
if command -v mariadb-tzinfo-to-sql >/dev/null 2>&1; then
|
echo "MySQL mysql_tzinfo_to_sql - debug output:"
|
||||||
mariadb-tzinfo-to-sql /usr/share/zoneinfo 2>/dev/null \
|
echo ${SQL_FULL_TZINFO_IMPORT_RETURN}
|
||||||
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
|
|
||||||
elif command -v mysql_tzinfo_to_sql >/dev/null 2>&1; then
|
|
||||||
mysql_tzinfo_to_sql /usr/share/zoneinfo 2>/dev/null \
|
|
||||||
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
|
|
||||||
else
|
|
||||||
echo "No tzinfo-to-sql tool available — skipping timezone import."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM golang:1.25-bookworm AS builder
|
FROM golang:1.25-bookworm AS builder
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -49,14 +45,6 @@ RUN chmod +x /opt/postfix-tlspol.sh \
|
|||||||
/docker-entrypoint.sh
|
/docker-entrypoint.sh
|
||||||
RUN rm -rf /tmp/* /var/tmp/*
|
RUN rm -rf /tmp/* /var/tmp/*
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=postfix-tlspol \
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(`REDIS_SLAVEOF_PORT`)
|
port(`REDIS_SLAVEOF_PORT`)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
filter f_mail { facility(mail); };
|
filter f_mail { facility(mail); };
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(6379)
|
port(6379)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
filter f_mail { facility(mail); };
|
filter f_mail { facility(mail); };
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
@@ -62,14 +58,6 @@ RUN rm -rf /tmp/* /var/tmp/*
|
|||||||
|
|
||||||
EXPOSE 588
|
EXPOSE 588
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=postfix \
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(`REDIS_SLAVEOF_PORT`)
|
port(`REDIS_SLAVEOF_PORT`)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
destination d_redis_f2b_channel {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(6379)
|
port(6379)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
destination d_redis_f2b_channel {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
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.3-1~236eb65
|
ARG RSPAMD_VER=rspamd_3.14.2-82~90302bc
|
||||||
ARG CODENAME=trixie
|
ARG CODENAME=trixie
|
||||||
ENV LC_ALL=C
|
ENV LC_ALL=C
|
||||||
|
|
||||||
@@ -37,16 +33,8 @@ COPY settings.conf /etc/rspamd/settings.conf
|
|||||||
COPY set_worker_password.sh /set_worker_password.sh
|
COPY set_worker_password.sh /set_worker_password.sh
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
|
|
||||||
STOPSIGNAL SIGTERM
|
STOPSIGNAL SIGTERM
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
CMD ["/usr/bin/rspamd", "-f", "-u", "_rspamd", "-g", "_rspamd"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=rspamd \
|
|
||||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/rspamd -f -u _rspamd -g _rspamd"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
|
|||||||
@@ -1,171 +1,47 @@
|
|||||||
# SOGo built from source to enable security patch application
|
FROM debian:bookworm-slim
|
||||||
# Repository: https://github.com/Alinto/sogo
|
|
||||||
# Version: SOGo-5.12.8
|
|
||||||
#
|
|
||||||
# Applied security patches:
|
|
||||||
# -
|
|
||||||
#
|
|
||||||
# To add new patches, modify SOGO_SECURITY_PATCHES ARG below with space-separated commit hashes
|
|
||||||
|
|
||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM debian:bookworm
|
|
||||||
|
|
||||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
ARG SOGO_VERSION=SOGo-5.12.8
|
ARG DEBIAN_VERSION=bookworm
|
||||||
ARG SOPE_VERSION=SOPE-5.12.8
|
ARG SOGO_DEBIAN_REPOSITORY=https://packagingv2.sogo.nu/sogo-nightly-debian/
|
||||||
# Security patches to apply (space-separated commit hashes)
|
|
||||||
ARG SOGO_SECURITY_PATCHES=""
|
|
||||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||||
ARG GOSU_VERSION=1.19
|
ARG GOSU_VERSION=1.19
|
||||||
ENV LC_ALL=C
|
ENV LC_ALL=C
|
||||||
|
|
||||||
# Install dependencies, build SOPE and SOGo, then clean up (all in one layer to minimize image size)
|
# Prerequisites
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
|
||||||
# Build dependencies
|
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git \
|
apt-transport-https \
|
||||||
build-essential \
|
ca-certificates \
|
||||||
gobjc \
|
gettext \
|
||||||
pkg-config \
|
gnupg \
|
||||||
gnustep-make \
|
mariadb-client \
|
||||||
gnustep-base-runtime \
|
rsync \
|
||||||
libgnustep-base-dev \
|
supervisor \
|
||||||
libxml2-dev \
|
syslog-ng \
|
||||||
libldap2-dev \
|
syslog-ng-core \
|
||||||
libssl-dev \
|
syslog-ng-mod-redis \
|
||||||
zlib1g-dev \
|
dirmngr \
|
||||||
libpq-dev \
|
netcat-traditional \
|
||||||
libmariadb-dev-compat \
|
psmisc \
|
||||||
libmemcached-dev \
|
wget \
|
||||||
libsodium-dev \
|
patch \
|
||||||
libcurl4-openssl-dev \
|
|
||||||
libzip-dev \
|
|
||||||
libytnef0-dev \
|
|
||||||
libwbxml2-dev \
|
|
||||||
curl \
|
|
||||||
ca-certificates \
|
|
||||||
# Runtime dependencies
|
|
||||||
apt-transport-https \
|
|
||||||
gettext \
|
|
||||||
gnupg \
|
|
||||||
mariadb-client \
|
|
||||||
rsync \
|
|
||||||
supervisor \
|
|
||||||
syslog-ng \
|
|
||||||
syslog-ng-core \
|
|
||||||
syslog-ng-mod-redis \
|
|
||||||
dirmngr \
|
|
||||||
netcat-traditional \
|
|
||||||
psmisc \
|
|
||||||
wget \
|
|
||||||
patch \
|
|
||||||
libobjc4 \
|
|
||||||
libxml2 \
|
|
||||||
libldap-2.5-0 \
|
|
||||||
libssl3 \
|
|
||||||
zlib1g \
|
|
||||||
libmariadb3 \
|
|
||||||
libmemcached11 \
|
|
||||||
libsodium23 \
|
|
||||||
libcurl4 \
|
|
||||||
libzip4 \
|
|
||||||
libytnef0 \
|
|
||||||
libwbxml2-1 \
|
|
||||||
# Download gosu
|
|
||||||
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
|
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
|
||||||
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
|
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
|
||||||
&& chmod +x /usr/local/bin/gosu \
|
&& chmod +x /usr/local/bin/gosu \
|
||||||
&& gosu nobody true \
|
&& gosu nobody true \
|
||||||
# Build SOPE
|
&& mkdir /usr/share/doc/sogo \
|
||||||
&& git clone --depth 1 --branch ${SOPE_VERSION} https://github.com/Alinto/sope.git /tmp/sope \
|
|
||||||
&& cd /tmp/sope \
|
|
||||||
&& rm -rf .git \
|
|
||||||
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
|
|
||||||
&& ./configure --prefix=/usr --disable-debug --disable-strip \
|
|
||||||
&& make -j$(nproc) \
|
|
||||||
&& make install \
|
|
||||||
&& cd / \
|
|
||||||
&& rm -rf /tmp/sope \
|
|
||||||
# Build SOGo with security patches
|
|
||||||
&& git clone --depth 1 --branch ${SOGO_VERSION} https://github.com/Alinto/sogo.git /tmp/sogo \
|
|
||||||
&& cd /tmp/sogo \
|
|
||||||
&& git config user.email "builder@mailcow.local" \
|
|
||||||
&& git config user.name "SOGo Builder" \
|
|
||||||
&& for patch in ${SOGO_SECURITY_PATCHES}; do \
|
|
||||||
echo "Applying security patch: ${patch}"; \
|
|
||||||
git fetch origin ${patch} && git cherry-pick ${patch}; \
|
|
||||||
done \
|
|
||||||
&& rm -rf .git \
|
|
||||||
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
|
|
||||||
&& ./configure --disable-debug --disable-strip \
|
|
||||||
&& make -j$(nproc) \
|
|
||||||
&& make install \
|
|
||||||
&& cd /tmp/sogo/ActiveSync \
|
|
||||||
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
|
|
||||||
&& make -j$(nproc) install \
|
|
||||||
&& cd / \
|
|
||||||
&& rm -rf /tmp/sogo \
|
|
||||||
# Strip binaries
|
|
||||||
&& strip --strip-unneeded /usr/local/sbin/sogod 2>/dev/null || true \
|
|
||||||
&& strip --strip-unneeded /usr/local/sbin/sogo-tool 2>/dev/null || true \
|
|
||||||
&& strip --strip-unneeded /usr/local/sbin/sogo-ealarms-notify 2>/dev/null || true \
|
|
||||||
&& strip --strip-unneeded /usr/local/sbin/sogo-slapd-sockd 2>/dev/null || true \
|
|
||||||
# Remove build dependencies and clean up
|
|
||||||
&& apt-get purge -y --auto-remove \
|
|
||||||
git \
|
|
||||||
build-essential \
|
|
||||||
gobjc \
|
|
||||||
gnustep-make \
|
|
||||||
libgnustep-base-dev \
|
|
||||||
libxml2-dev \
|
|
||||||
libldap2-dev \
|
|
||||||
libssl-dev \
|
|
||||||
zlib1g-dev \
|
|
||||||
libpq-dev \
|
|
||||||
libmariadb-dev-compat \
|
|
||||||
libmemcached-dev \
|
|
||||||
libsodium-dev \
|
|
||||||
libcurl4-openssl-dev \
|
|
||||||
libzip-dev \
|
|
||||||
libytnef0-dev \
|
|
||||||
curl \
|
|
||||||
&& apt-get autoremove -y \
|
|
||||||
&& apt-get clean \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& rm -rf /usr/share/doc/* \
|
|
||||||
&& rm -rf /usr/share/man/* \
|
|
||||||
&& rm -rf /var/cache/debconf/* \
|
|
||||||
&& rm -rf /tmp/* \
|
|
||||||
&& rm -rf /root/.cache \
|
|
||||||
&& find /usr/local/lib -name '*.a' -delete \
|
|
||||||
&& find /usr/lib -name '*.a' -delete \
|
|
||||||
&& mkdir -p /usr/share/doc/sogo \
|
|
||||||
&& touch /usr/share/doc/sogo/empty.sh \
|
&& touch /usr/share/doc/sogo/empty.sh \
|
||||||
|
&& wget -O- https://keys.openpgp.org/vks/v1/by-fingerprint/74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9 | gpg --dearmor | apt-key add - \
|
||||||
|
&& echo "deb [trusted=yes] ${SOGO_DEBIAN_REPOSITORY} ${DEBIAN_VERSION} main" > /etc/apt/sources.list.d/sogo.list \
|
||||||
|
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
sogo \
|
||||||
|
sogo-activesync \
|
||||||
|
&& apt-get autoclean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& touch /etc/default/locale
|
&& touch /etc/default/locale
|
||||||
|
|
||||||
# Configure library paths
|
|
||||||
RUN echo "/usr/lib64" > /etc/ld.so.conf.d/sogo.conf \
|
|
||||||
&& echo "/usr/local/lib/sogo" >> /etc/ld.so.conf.d/sogo.conf \
|
|
||||||
&& echo "/usr/local/lib/GNUstep/Frameworks/SOGo.framework/Versions/5/sogo" >> /etc/ld.so.conf.d/sogo.conf \
|
|
||||||
&& ldconfig
|
|
||||||
|
|
||||||
# Create sogo user and group
|
|
||||||
RUN groupadd -r -g 999 sogo \
|
|
||||||
&& useradd -r -u 999 -g sogo -d /var/lib/sogo -s /bin/bash -c "SOGo Daemon" sogo \
|
|
||||||
&& mkdir -p /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo \
|
|
||||||
&& chown -R sogo:sogo /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo
|
|
||||||
|
|
||||||
# Create symlinks for SOGo binaries
|
|
||||||
RUN ln -s /usr/local/sbin/sogod /usr/sbin/sogod \
|
|
||||||
&& ln -s /usr/local/sbin/sogo-tool /usr/sbin/sogo-tool \
|
|
||||||
&& ln -s /usr/local/sbin/sogo-ealarms-notify /usr/sbin/sogo-ealarms-notify \
|
|
||||||
&& ln -s /usr/local/sbin/sogo-slapd-sockd /usr/sbin/sogo-slapd-sockd
|
|
||||||
|
|
||||||
# Copy configuration files and scripts
|
|
||||||
COPY ./bootstrap-sogo.sh /bootstrap-sogo.sh
|
COPY ./bootstrap-sogo.sh /bootstrap-sogo.sh
|
||||||
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||||
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
||||||
@@ -178,14 +54,6 @@ COPY docker-entrypoint.sh /
|
|||||||
RUN chmod +x /bootstrap-sogo.sh \
|
RUN chmod +x /bootstrap-sogo.sh \
|
||||||
/usr/local/sbin/stop-supervisor.sh
|
/usr/local/sbin/stop-supervisor.sh
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=sogo \
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
--- /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200
|
--- /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200
|
||||||
+++ /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200
|
+++ /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200
|
||||||
@@ -46,7 +46,7 @@
|
@@ -46,7 +46,7 @@
|
||||||
</md-item-template>
|
</md-item-template>
|
||||||
</md-autocomplete>
|
</md-autocomplete>
|
||||||
|
|||||||
@@ -130,22 +130,18 @@ chmod 600 /var/lib/sogo/GNUstep/Defaults/sogod.plist
|
|||||||
# Patch ACLs
|
# Patch ACLs
|
||||||
#if [[ ${ACL_ANYONE} == 'allow' ]]; then
|
#if [[ ${ACL_ANYONE} == 'allow' ]]; then
|
||||||
# #enable any or authenticated targets for ACL
|
# #enable any or authenticated targets for ACL
|
||||||
# if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
# if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||||
# patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
# patch -R /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||||
# fi
|
# fi
|
||||||
#else
|
#else
|
||||||
# #disable any or authenticated targets for ACL
|
# #disable any or authenticated targets for ACL
|
||||||
# if patch -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
# if patch -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||||
# patch /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
# patch /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||||
# fi
|
# fi
|
||||||
#fi
|
#fi
|
||||||
|
|
||||||
# Apply custom UI patch (reverse patch to ADD buttons)
|
if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
|
||||||
if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
|
patch -R /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
|
||||||
echo "Applying navMailcowBtns patch (reverse to add buttons)..."
|
|
||||||
patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
|
|
||||||
else
|
|
||||||
echo "navMailcowBtns patch already applied or cannot be applied"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rename custom logo, if any
|
# Rename custom logo, if any
|
||||||
@@ -153,7 +149,7 @@ fi
|
|||||||
|
|
||||||
# Rsync web content
|
# Rsync web content
|
||||||
echo "Syncing web content with named volume"
|
echo "Syncing web content with named volume"
|
||||||
rsync -a /usr/local/lib/GNUstep/SOGo/. /sogo_web/
|
rsync -a /usr/lib/GNUstep/SOGo/. /sogo_web/
|
||||||
|
|
||||||
# Chown backup path
|
# Chown backup path
|
||||||
chown -R sogo:sogo /sogo_backup
|
chown -R sogo:sogo /sogo_backup
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(`REDIS_SLAVEOF_PORT`)
|
port(`REDIS_SLAVEOF_PORT`)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "SOGO_LOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
command("LPUSH" "SOGO_LOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
destination d_redis_f2b_channel {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
|
|||||||
persist-name("redis1")
|
persist-name("redis1")
|
||||||
port(6379)
|
port(6379)
|
||||||
auth("`REDISPASS`")
|
auth("`REDISPASS`")
|
||||||
command("LPUSH" "SOGO_LOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
command("LPUSH" "SOGO_LOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
destination d_redis_f2b_channel {
|
destination d_redis_f2b_channel {
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
FROM alpine:3.21
|
||||||
|
|
||||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
|
||||||
|
|
||||||
FROM alpine:3.23
|
|
||||||
|
|
||||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
@@ -33,15 +29,8 @@ COPY supervisord.conf /etc/supervisor/supervisord.conf
|
|||||||
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
||||||
|
|
||||||
RUN chmod +x /healthcheck.sh
|
RUN chmod +x /healthcheck.sh
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s \
|
||||||
|
CMD sh -c '[ -f /tmp/healthcheck_status ] && [ "$(cat /tmp/healthcheck_status)" -eq 0 ] || exit 1'
|
||||||
|
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||||
|
|
||||||
ENV MAILCOW_AGENT_SERVICE=unbound \
|
|
||||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
|
||||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
|
||||||
CMD []
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user