mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-06-13 01:50:34 +00:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 689d753264 | |||
| 4ddcee28e4 | |||
| b0d16bbcee | |||
| 9175e5f086 | |||
| ff3d571054 | |||
| a8e945f3da | |||
| 33547d1d73 | |||
| 539c32d99c | |||
| d42c3823c4 | |||
| 886dbcc419 | |||
| dc15994d40 | |||
| ec24825280 | |||
| 5a00b5124b | |||
| 8c039f694f | |||
| 95bf46c1e4 | |||
| edde35156d | |||
| 84e3c32f13 | |||
| ecb848493b | |||
| 8a65b9d1c6 | |||
| ed9264fd2a | |||
| 7817dda43f | |||
| 018e292854 | |||
| 127fb1e8f5 | |||
| 09f09cb850 | |||
| d4bf377a96 | |||
| abd6fe8c79 | |||
| 5f8382ef44 | |||
| 03eccd4e42 | |||
| 1da8d1c894 | |||
| d1feebf164 | |||
| 293b885a85 | |||
| 1e0850193a | |||
| 33acf56526 | |||
| bea9ad7e8f | |||
| e7ea3aa608 | |||
| 5888e248c3 | |||
| 2e176339ba | |||
| 8f883f3d37 | |||
| 709117fe19 | |||
| 82ea418423 | |||
| fd24163c6e | |||
| 3caef45a12 | |||
| 8760e7e5db | |||
| e848226062 | |||
| efaeb77e13 | |||
| 569b4cf985 | |||
| 6c857243ec | |||
| 905e93627c | |||
| 598ea21827 | |||
| 6012cf4486 | |||
| fe71f84c82 | |||
| 763ecbc93e | |||
| 97312c1a9d | |||
| baf0e7e658 | |||
| a4ca5f9363 | |||
| dcbea71e67 | |||
| 524f19ff77 | |||
| 4e33c7143f | |||
| 4c184b25f4 | |||
| ddc2309f1e | |||
| bccfcefd17 | |||
| d40252ccce | |||
| 4abb5cbfab | |||
| b695936273 | |||
| d9463c7950 | |||
| 579542381a | |||
| ce5659f300 | |||
| 1967cb642f | |||
| ba0eb04ebe | |||
| 609ce6b0d6 | |||
| af61e2d303 | |||
| c6e3f517e1 | |||
| 6eaa8d43e9 | |||
| c033cd6254 | |||
| 7562578b74 | |||
| 43f570e761 | |||
| 5e478a32df | |||
| 22c94a4d2f | |||
| 99dc0f6616 | |||
| 4732f568fa | |||
| 96d4802cb2 | |||
| ad5b94af5e | |||
| 7fce984cac | |||
| 404e2f0190 | |||
| 1c52eaa3a4 | |||
| c7e04b4146 | |||
| 075959aea9 | |||
| 885ba2510e | |||
| 87563249f1 | |||
| fb1686065d | |||
| 0428f5c9bd | |||
| a70c23065c | |||
| 2e8897c2cf | |||
| 5ca900749c | |||
| b005803fe0 | |||
| ec77406dba | |||
| ee15721550 | |||
| 890295bbfc | |||
| a52e977b89 |
@@ -14,7 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Mark/Close Stale Issues and Pull Requests 🗑️
|
||||
uses: actions/stale@v10.1.1
|
||||
uses: actions/stale@v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.STALE_ACTION_PAT }}
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
images:
|
||||
- "acme-mailcow"
|
||||
- "clamd-mailcow"
|
||||
- "controller-mailcow"
|
||||
- "dockerapi-mailcow"
|
||||
- "dovecot-mailcow"
|
||||
- "netfilter-mailcow"
|
||||
- "olefy-mailcow"
|
||||
|
||||
@@ -16,21 +16,21 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
|
||||
@@ -51,6 +51,7 @@ data/conf/sogo/cron.creds
|
||||
data/conf/sogo/custom-fulllogo.svg
|
||||
data/conf/sogo/custom-shortlogo.svg
|
||||
data/conf/sogo/custom-fulllogo.png
|
||||
data/conf/acme/dns-01.conf
|
||||
data/gitea/
|
||||
data/gogs/
|
||||
data/hooks/dovecot/*
|
||||
|
||||
@@ -57,11 +57,14 @@ adapt_new_options() {
|
||||
"DISABLE_NETFILTER_ISOLATION_RULE"
|
||||
"HTTP_REDIRECT"
|
||||
"ENABLE_IPV6"
|
||||
"ACME_DNS_CHALLENGE"
|
||||
"ACME_DNS_PROVIDER"
|
||||
"ACME_ACCOUNT_EMAIL"
|
||||
)
|
||||
|
||||
sed -i --follow-symlinks '$a\' mailcow.conf
|
||||
for option in ${CONFIG_ARRAY[@]}; do
|
||||
if grep -q "${option}" mailcow.conf; then
|
||||
if grep -q "^#\?${option}=" mailcow.conf; then
|
||||
continue
|
||||
fi
|
||||
|
||||
@@ -292,6 +295,20 @@ adapt_new_options() {
|
||||
echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
|
||||
echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
|
||||
;;
|
||||
ACME_DNS_CHALLENGE)
|
||||
echo '# Enable DNS-01 challenge for ACME (acme-mailcow) - y/n' >> mailcow.conf
|
||||
echo '# This requires you to set ACME_DNS_PROVIDER and ACME_ACCOUNT_EMAIL below' >> mailcow.conf
|
||||
echo 'ACME_DNS_CHALLENGE=n' >> mailcow.conf
|
||||
;;
|
||||
ACME_DNS_PROVIDER)
|
||||
echo '# DNS provider for DNS-01 challenge (e.g. dns_cf, dns_azure, dns_gd, etc.)' >> mailcow.conf
|
||||
echo '# See the dns-01 provider documentation for more information.' >> mailcow.conf
|
||||
echo 'ACME_DNS_PROVIDER=dns_xxx' >> mailcow.conf
|
||||
;;
|
||||
ACME_ACCOUNT_EMAIL)
|
||||
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
|
||||
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
|
||||
;;
|
||||
*)
|
||||
echo "${option}=" >> mailcow.conf
|
||||
;;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
FROM alpine:3.21
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -14,14 +18,35 @@ RUN apk upgrade --no-cache \
|
||||
tini \
|
||||
tzdata \
|
||||
python3 \
|
||||
acme-tiny
|
||||
acme-tiny \
|
||||
git \
|
||||
socat \
|
||||
&& git clone --depth 1 https://github.com/acmesh-official/acme.sh.git /opt/acme.sh \
|
||||
&& chmod +x /opt/acme.sh/acme.sh \
|
||||
&& mkdir -p /var/lib/acme/acme-sh
|
||||
|
||||
ENV ACME_SH_BIN=/opt/acme.sh/acme.sh \
|
||||
ACME_SH_HOME=/opt/acme.sh \
|
||||
ACME_SH_CONFIG_HOME=/var/lib/acme/acme-sh
|
||||
|
||||
COPY acme.sh /srv/acme.sh
|
||||
COPY functions.sh /srv/functions.sh
|
||||
COPY obtain-certificate.sh /srv/obtain-certificate.sh
|
||||
COPY obtain-certificate-dns.sh /srv/obtain-certificate-dns.sh
|
||||
COPY load-dns-config.sh /srv/load-dns-config.sh
|
||||
COPY reload-configurations.sh /srv/reload-configurations.sh
|
||||
COPY expand6.sh /srv/expand6.sh
|
||||
|
||||
RUN chmod +x /srv/*.sh
|
||||
|
||||
CMD ["/sbin/tini", "-g", "--", "/srv/acme.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=acme \
|
||||
MAILCOW_AGENT_MAIN_CMD="/sbin/tini -g -- /srv/acme.sh"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -14,6 +14,17 @@ until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Create DNS-01 configuration template if it doesn't exist
|
||||
if [[ ! -f /etc/acme/dns-01.conf ]]; then
|
||||
mkdir -p /etc/acme
|
||||
cat > /etc/acme/dns-01.conf <<'EOF'
|
||||
# Add here your DNS-01 challenge configuration
|
||||
# For more information, visit the acme.sh documentation:
|
||||
# https://github.com/acmesh-official/acme.sh/wiki/dnsapi
|
||||
EOF
|
||||
echo "Created DNS-01 configuration template at /etc/acme/dns-01.conf"
|
||||
fi
|
||||
|
||||
source /srv/functions.sh
|
||||
# Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6
|
||||
source /srv/expand6.sh
|
||||
@@ -42,17 +53,21 @@ if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
ENABLE_SSL_SNI=y
|
||||
fi
|
||||
|
||||
if [[ "${ACME_DNS_CHALLENGE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
ACME_DNS_CHALLENGE=y
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..."
|
||||
sleep 365d
|
||||
exec $(readlink -f "$0")
|
||||
fi
|
||||
|
||||
log_f "Waiting for Controller .."
|
||||
until ping controller -c1 > /dev/null; do
|
||||
log_f "Waiting for Redis control bus..."
|
||||
until redis-cli -h "${REDIS_SLAVEOF_IP:-redis-mailcow}" -p "${REDIS_SLAVEOF_PORT:-6379}" -a "${REDISPASS}" --no-auth-warning ping > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
log_f "Controller OK"
|
||||
log_f "Redis control bus OK"
|
||||
|
||||
log_f "Waiting for Postfix..."
|
||||
until ping postfix -c1 > /dev/null; do
|
||||
@@ -238,10 +253,20 @@ while true; do
|
||||
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
|
||||
if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
|
||||
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}")
|
||||
fi
|
||||
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
|
||||
|
||||
# Skip if subdomain matches MAILCOW_HOSTNAME
|
||||
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
|
||||
continue
|
||||
fi
|
||||
# Skip if subdomain is covered by a wildcard in ADDITIONAL_SAN
|
||||
if is_covered_by_wildcard "${FULL_SUBDOMAIN}"; then
|
||||
log_f "Subdomain '${FULL_SUBDOMAIN}' is covered by wildcard - skipping explicit subdomain"
|
||||
continue
|
||||
fi
|
||||
# Validate and add subdomain
|
||||
if check_domain "${FULL_SUBDOMAIN}"; then
|
||||
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${FULL_SUBDOMAIN}")
|
||||
fi
|
||||
done
|
||||
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
|
||||
@@ -258,7 +283,10 @@ while true; do
|
||||
fi
|
||||
# Only add mta-sts subdomain for alias domains
|
||||
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||
if check_domain "mta-sts.${alias_domain}"; then
|
||||
# Skip if mta-sts subdomain is covered by a wildcard
|
||||
if is_covered_by_wildcard "mta-sts.${alias_domain}"; then
|
||||
log_f "Alias domain mta-sts subdomain 'mta-sts.${alias_domain}' is covered by wildcard - skipping"
|
||||
elif check_domain "mta-sts.${alias_domain}"; then
|
||||
VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}")
|
||||
fi
|
||||
fi
|
||||
@@ -293,13 +321,31 @@ while true; do
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if MAILCOW_HOSTNAME is covered by a wildcard in ADDITIONAL_SAN
|
||||
MAILCOW_HOSTNAME_COVERED=0
|
||||
if [[ ! -z ${VALIDATED_MAILCOW_HOSTNAME} ]]; then
|
||||
if is_covered_by_wildcard "${VALIDATED_MAILCOW_HOSTNAME}"; then
|
||||
MAILCOW_PARENT_DOMAIN=$(echo ${VALIDATED_MAILCOW_HOSTNAME} | cut -d. -f2-)
|
||||
log_f "MAILCOW_HOSTNAME '${VALIDATED_MAILCOW_HOSTNAME}' is covered by wildcard '*.${MAILCOW_PARENT_DOMAIN}' - skipping explicit hostname"
|
||||
MAILCOW_HOSTNAME_COVERED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Unique domains for server certificate
|
||||
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
|
||||
# create certificate for server name and fqdn SANs only
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
|
||||
SERVER_SAN_VALIDATED=($(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
else
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
fi
|
||||
else
|
||||
# create certificate for all domains, including all subdomains from other domains [*]
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
|
||||
SERVER_SAN_VALIDATED=($(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
else
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
fi
|
||||
fi
|
||||
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
|
||||
CERT_NAME=${SERVER_SAN_VALIDATED[0]}
|
||||
|
||||
@@ -80,6 +80,11 @@ check_domain(){
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${ACME_DNS_CHALLENGE} == "y" ]]; then
|
||||
log_f "ACME_DNS_CHALLENGE=y - skipping IP and HTTP validation for ${DOMAIN}"
|
||||
return 0
|
||||
fi
|
||||
# Check if CNAME without v6 enabled target
|
||||
if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then
|
||||
AAAA_DOMAIN=
|
||||
@@ -130,3 +135,32 @@ verify_challenge_path(){
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if a domain is covered by a wildcard (*.example.com) in ADDITIONAL_SAN
|
||||
# Usage: is_covered_by_wildcard "subdomain.example.com"
|
||||
# Returns: 0 if covered, 1 if not covered
|
||||
# Note: Only returns 0 (covered) when DNS-01 challenge is enabled,
|
||||
# as wildcards cannot be validated with HTTP-01 challenge
|
||||
is_covered_by_wildcard() {
|
||||
local DOMAIN=$1
|
||||
|
||||
# Only skip if DNS challenge is enabled (wildcards require DNS-01)
|
||||
if [[ ${ACME_DNS_CHALLENGE} != "y" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Return early if no ADDITIONAL_SAN is set
|
||||
if [[ -z ${ADDITIONAL_SAN} ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract parent domain (e.g., mail.example.com -> example.com)
|
||||
local PARENT_DOMAIN=$(echo ${DOMAIN} | cut -d. -f2-)
|
||||
|
||||
# Check if ADDITIONAL_SAN contains a wildcard for this parent domain
|
||||
if [[ "${ADDITIONAL_SAN}" == *"*.${PARENT_DOMAIN}"* ]]; then
|
||||
return 0 # Covered by wildcard
|
||||
fi
|
||||
|
||||
return 1 # Not covered
|
||||
}
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_SOURCE="${BASH_SOURCE[0]:-${0}}"
|
||||
if [[ "${SCRIPT_SOURCE}" == "${0}" ]]; then
|
||||
__dns_loader_standalone=1
|
||||
else
|
||||
__dns_loader_standalone=0
|
||||
fi
|
||||
|
||||
CONFIG_PATH="${ACME_DNS_CONFIG_FILE:-/etc/acme/dns-01.conf}"
|
||||
|
||||
if [[ ! -f "${CONFIG_PATH}" ]]; then
|
||||
if [[ $__dns_loader_standalone -eq 1 ]]; then
|
||||
exit 0
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
source /srv/functions.sh
|
||||
|
||||
log_f "Loading DNS-01 configuration from ${CONFIG_PATH}"
|
||||
|
||||
LINE_NO=0
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
LINE_NO=$((LINE_NO+1))
|
||||
line="${line%$'\r'}"
|
||||
line_trimmed="$(printf '%s' "${line}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
[[ -z "${line_trimmed}" ]] && continue
|
||||
[[ "${line_trimmed:0:1}" == "#" ]] && continue
|
||||
if [[ "${line_trimmed}" != *=* ]]; then
|
||||
log_f "Skipping invalid DNS config line ${LINE_NO} (missing key=value)"
|
||||
continue
|
||||
fi
|
||||
KEY="${line_trimmed%%=*}"
|
||||
VALUE="${line_trimmed#*=}"
|
||||
KEY="$(printf '%s' "${KEY}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
VALUE="$(printf '%s' "${VALUE}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
if [[ -z "${KEY}" ]]; then
|
||||
log_f "Skipping invalid DNS config line ${LINE_NO} (empty key)"
|
||||
continue
|
||||
fi
|
||||
if [[ "${VALUE}" =~ ^\".*\"$ ]]; then
|
||||
VALUE="${VALUE:1:-1}"
|
||||
elif [[ "${VALUE}" =~ ^\'.*\'$ ]]; then
|
||||
VALUE="${VALUE:1:-1}"
|
||||
fi
|
||||
export "${KEY}"="${VALUE}"
|
||||
log_f "Exported DNS config key ${KEY}"
|
||||
|
||||
done < "${CONFIG_PATH}"
|
||||
|
||||
if [[ $__dns_loader_standalone -eq 1 ]]; then
|
||||
exit 0
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Return values / exit codes
|
||||
# 0 = cert created successfully
|
||||
# 1 = cert renewed successfully
|
||||
# 2 = cert not due for renewal
|
||||
# * = errors
|
||||
|
||||
source /srv/functions.sh
|
||||
|
||||
CERT_DOMAINS=(${DOMAINS[@]})
|
||||
CERT_DOMAIN=${CERT_DOMAINS[0]}
|
||||
ACME_BASE=/var/lib/acme
|
||||
|
||||
# Load optional DNS provider secrets from /etc/acme/dns-01.conf
|
||||
if [[ -f /srv/load-dns-config.sh ]]; then
|
||||
source /srv/load-dns-config.sh
|
||||
if declare -F log_f >/dev/null; then
|
||||
log_f "ACME_DNS_CHALLENGE is enabled, DNS provider secrets loaded"
|
||||
fi
|
||||
fi
|
||||
|
||||
TYPE=${1}
|
||||
PREFIX=""
|
||||
# only support rsa certificates for now
|
||||
if [[ "${TYPE}" != "rsa" ]]; then
|
||||
log_f "Unknown certificate type '${TYPE}' requested"
|
||||
exit 5
|
||||
fi
|
||||
|
||||
if [[ -z "${ACME_DNS_PROVIDER}" ]]; then
|
||||
log_f "ACME_DNS_PROVIDER is required when ACME_DNS_CHALLENGE is enabled"
|
||||
exit 6
|
||||
fi
|
||||
|
||||
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
|
||||
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
|
||||
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
|
||||
KEY=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}key.pem
|
||||
CSR=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}acme.csr
|
||||
|
||||
if [[ -z ${CERT_DOMAINS[*]} ]]; then
|
||||
log_f "Missing CERT_DOMAINS to obtain a certificate"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [[ "${LE_STAGING}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
if [[ ! -z "${DIRECTORY_URL}" ]]; then
|
||||
log_f "Cannot use DIRECTORY_URL with LE_STAGING=y - ignoring DIRECTORY_URL"
|
||||
fi
|
||||
log_f "Using Let's Encrypt staging servers"
|
||||
ACME_SH_SERVER_ARGS=("--staging")
|
||||
elif [[ ! -z "${DIRECTORY_URL}" ]]; then
|
||||
log_f "Using custom directory URL ${DIRECTORY_URL}"
|
||||
ACME_SH_SERVER_ARGS=("--server" "${DIRECTORY_URL}")
|
||||
else
|
||||
log_f "Using Let's Encrypt production servers"
|
||||
ACME_SH_SERVER_ARGS=("--server" "letsencrypt")
|
||||
fi
|
||||
|
||||
if [[ -f ${DOMAINS_FILE} && "$(cat ${DOMAINS_FILE})" == "${CERT_DOMAINS[*]}" ]]; then
|
||||
if [[ ! -f ${CERT} || ! -f "${KEY}" || -f "${ACME_BASE}/force_renew" ]]; then
|
||||
log_f "Certificate ${CERT} doesn't exist yet or forced renewal - start obtaining"
|
||||
elif ! openssl x509 -checkend 2592000 -noout -in ${CERT} > /dev/null; then
|
||||
log_f "Certificate ${CERT} is due for renewal (< 30 days) - start renewing"
|
||||
else
|
||||
log_f "Certificate ${CERT} validation done, neither changed nor due for renewal."
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
log_f "Certificate ${CERT} missing or changed domains '${CERT_DOMAINS[*]}' - start obtaining"
|
||||
fi
|
||||
|
||||
# Make backup
|
||||
if [[ -f ${CERT} ]]; then
|
||||
DATE=$(date +%Y-%m-%d_%H_%M_%S)
|
||||
BACKUP_DIR=${ACME_BASE}/backups/${CERT_DOMAIN}/${PREFIX}${DATE}
|
||||
log_f "Creating backups in ${BACKUP_DIR} ..."
|
||||
mkdir -p ${BACKUP_DIR}/
|
||||
[[ -f ${DOMAINS_FILE} ]] && cp ${DOMAINS_FILE} ${BACKUP_DIR}/
|
||||
[[ -f ${CERT} ]] && cp ${CERT} ${BACKUP_DIR}/
|
||||
[[ -f ${KEY} ]] && cp ${KEY} ${BACKUP_DIR}/
|
||||
[[ -f ${CSR} ]] && cp ${CSR} ${BACKUP_DIR}/
|
||||
fi
|
||||
|
||||
mkdir -p ${ACME_BASE}/${CERT_DOMAIN}
|
||||
if [[ ! -f ${KEY} ]]; then
|
||||
log_f "Copying shared private key for this certificate..."
|
||||
cp ${SHARED_KEY} ${KEY}
|
||||
chmod 600 ${KEY}
|
||||
fi
|
||||
|
||||
# Generating CSR to keep layout parity with HTTP challenge flow
|
||||
printf "[SAN]\nsubjectAltName=" > /tmp/_SAN
|
||||
printf "DNS:%s," "${CERT_DOMAINS[@]}" >> /tmp/_SAN
|
||||
sed -i '$s/,$//' /tmp/_SAN
|
||||
openssl req -new -sha256 -key ${KEY} -subj "/" -reqexts SAN -config <(cat "$(openssl version -d | sed 's/.*\"\(.*\)\"/\1/g')/openssl.cnf" /tmp/_SAN) > ${CSR}
|
||||
|
||||
log_f "Checking resolver..."
|
||||
until dig letsencrypt.org +time=3 +tries=1 @unbound > /dev/null; do
|
||||
sleep 2
|
||||
done
|
||||
log_f "Resolver OK"
|
||||
|
||||
ACME_SH_BIN_PATH=${ACME_SH_BIN:-/opt/acme.sh/acme.sh}
|
||||
ACME_SH_WORK_HOME=${ACME_SH_CONFIG_HOME:-/var/lib/acme/acme-sh}
|
||||
mkdir -p ${ACME_SH_WORK_HOME}
|
||||
|
||||
if [[ ! -x ${ACME_SH_BIN_PATH} ]]; then
|
||||
log_f "acme.sh binary not found at ${ACME_SH_BIN_PATH}"
|
||||
exit 7
|
||||
fi
|
||||
|
||||
if [[ ! -f ${ACME_SH_WORK_HOME}/account.conf ]]; then
|
||||
if [[ -z "${ACME_ACCOUNT_EMAIL}" ]]; then
|
||||
log_f "ACME_ACCOUNT_EMAIL is required to register a new acme.sh account"
|
||||
exit 8
|
||||
fi
|
||||
log_f "Registering acme.sh account for ${ACME_ACCOUNT_EMAIL}"
|
||||
REGISTER_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}" "--register-account" "-m" "${ACME_ACCOUNT_EMAIL}")
|
||||
REGISTER_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
|
||||
REGISTER_RESPONSE=$("${REGISTER_CMD[@]}" 2>&1)
|
||||
if [[ $? -ne 0 ]]; then
|
||||
log_f "Failed to register acme.sh account: ${REGISTER_RESPONSE}"
|
||||
exit 9
|
||||
fi
|
||||
fi
|
||||
|
||||
TMP_CERT=$(mktemp /tmp/acme-cert.XXXXXX)
|
||||
TMP_FULLCHAIN=$(mktemp /tmp/acme-fullchain.XXXXXX)
|
||||
|
||||
ACME_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}")
|
||||
ACME_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
|
||||
ACME_CMD+=("--issue" "--dns" "${ACME_DNS_PROVIDER}" "--key-file" "${KEY}" "--cert-file" "${TMP_CERT}" "--fullchain-file" "${TMP_FULLCHAIN}" "--force")
|
||||
for domain in "${CERT_DOMAINS[@]}"; do
|
||||
ACME_CMD+=("-d" "${domain}")
|
||||
done
|
||||
|
||||
log_f "Using command ${ACME_CMD[*]}"
|
||||
if [[ -n "${ACME_DNS_PROVIDER}" ]]; then
|
||||
log_f "DNS provider: ${ACME_DNS_PROVIDER}"
|
||||
fi
|
||||
if compgen -A variable | grep -Eq "^DNS_|^ACME_"; then
|
||||
LOG_KEYS=$(env | grep -E "^(DNS_|ACME_)" | cut -d= -f1 | tr '\n' ' ')
|
||||
log_f "Available DNS/ACME env keys: ${LOG_KEYS}" redis_only
|
||||
fi
|
||||
ACME_RESPONSE=$("${ACME_CMD[@]}" 2>&1 | tee /dev/fd/5; exit ${PIPESTATUS[0]})
|
||||
SUCCESS="$?"
|
||||
ACME_RESPONSE_B64=$(echo "${ACME_RESPONSE}" | openssl enc -e -A -base64)
|
||||
log_f "${ACME_RESPONSE_B64}" redis_only b64
|
||||
|
||||
case "$SUCCESS" in
|
||||
0)
|
||||
log_f "Deploying certificate ${CERT}..."
|
||||
if verify_hash_match ${TMP_FULLCHAIN} ${KEY}; then
|
||||
RETURN=0
|
||||
if [[ -f ${CERT} ]]; then
|
||||
RETURN=1
|
||||
fi
|
||||
mv -f ${TMP_FULLCHAIN} ${CERT}
|
||||
rm -f ${TMP_CERT}
|
||||
echo -n ${CERT_DOMAINS[*]} > ${DOMAINS_FILE}
|
||||
log_f "Certificate successfully obtained via DNS challenge"
|
||||
exit ${RETURN}
|
||||
else
|
||||
log_f "Certificate was requested, but key and certificate hashes do not match"
|
||||
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
|
||||
exit 4
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log_f "Failed to obtain certificate ${CERT} for domains '${CERT_DOMAINS[*]}' via DNS challenge"
|
||||
redis-cli -h redis -a ${REDISPASS} --no-auth-warning SET ACME_FAIL_TIME "$(date +%s)"
|
||||
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
|
||||
exit 100${SUCCESS}
|
||||
;;
|
||||
esac
|
||||
@@ -20,6 +20,10 @@ if [[ "${TYPE}" != "rsa" ]]; then
|
||||
log_f "Unknown certificate type '${TYPE}' requested"
|
||||
exit 5
|
||||
fi
|
||||
|
||||
if [[ "${ACME_DNS_CHALLENGE}" == "y" ]]; then
|
||||
exec /srv/obtain-certificate-dns.sh "$@"
|
||||
fi
|
||||
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
|
||||
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
|
||||
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Tell every live replica of nginx / dovecot / postfix to reload (or restart
|
||||
# on cert-amount change) via the mailcow-agent control bus. Replaces the old
|
||||
# dockerapi-based container_id lookup + exec dance.
|
||||
|
||||
# Reading container IDs
|
||||
# Wrapping as array to ensure trimmed content when calling $NGINX etc.
|
||||
NGINX=($(curl --silent --insecure https://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" " "))
|
||||
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" " "))
|
||||
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" " "))
|
||||
|
||||
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} ; }
|
||||
reload_service() {
|
||||
local svc="$1"
|
||||
echo "Reloading ${svc} via mailcow-agent..."
|
||||
if ! mailcow-agent-cli send "${svc}" reload >/dev/null; then
|
||||
echo "Could not publish reload to ${svc}, attempting restart..."
|
||||
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
reload_dovecot(){
|
||||
echo "Reloading Dovecot..."
|
||||
DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type)
|
||||
[[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; }
|
||||
}
|
||||
|
||||
reload_postfix(){
|
||||
echo "Reloading Postfix..."
|
||||
POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://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
|
||||
restart_service() {
|
||||
local svc="$1"
|
||||
echo "Restarting ${svc} via mailcow-agent..."
|
||||
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
||||
}
|
||||
|
||||
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
|
||||
restart_container ${NGINX}
|
||||
restart_container ${DOVECOT}
|
||||
restart_container ${POSTFIX}
|
||||
restart_service nginx
|
||||
restart_service dovecot
|
||||
restart_service postfix
|
||||
else
|
||||
reload_nginx
|
||||
#reload_dovecot
|
||||
restart_container ${DOVECOT}
|
||||
#reload_postfix
|
||||
restart_container ${POSTFIX}
|
||||
reload_service nginx
|
||||
restart_service dovecot
|
||||
restart_service postfix
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Builder image for mailcow-agent. Each service Dockerfile pulls the static
|
||||
# binary from here via:
|
||||
#
|
||||
# COPY --from=ghcr.io/mailcow/agent:VERSION /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
#
|
||||
# For local development: build this image first.
|
||||
#
|
||||
# docker build -t ghcr.io/mailcow/agent:dev data/Dockerfiles/agent/
|
||||
#
|
||||
# CI publishes a versioned tag and the service Dockerfiles pin against it via
|
||||
# ARG AGENT_IMAGE.
|
||||
|
||||
FROM golang:1.22-alpine AS build
|
||||
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOOS=linux
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /out \
|
||||
&& go build -trimpath -ldflags="-s -w" \
|
||||
-o /out/mailcow-agent ./cmd/mailcow-agent \
|
||||
&& cp mailcow-agent-cli /out/mailcow-agent-cli \
|
||||
&& chmod +x /out/mailcow-agent-cli
|
||||
|
||||
# Final stage: tiny image whose only purpose is to be a COPY --from source.
|
||||
FROM scratch
|
||||
COPY --from=build /out/mailcow-agent /out/mailcow-agent
|
||||
COPY --from=build /out/mailcow-agent-cli /out/mailcow-agent-cli
|
||||
@@ -0,0 +1,16 @@
|
||||
# mailcow-agent
|
||||
|
||||
Each mailcow service container (postfix, dovecot, …) runs `mailcow-agent` as
|
||||
ENTRYPOINT. It supervises the original service main process and exposes its
|
||||
control commands over a Redis Pub/Sub bus:
|
||||
|
||||
- `mailcow.control.<service>` — request channel (Backend → Agent)
|
||||
- `mailcow.reply.<request_id>` — per-request reply channel
|
||||
- `mailcow.events.<topic>` — broadcast events
|
||||
- `mailcow.nodes.<service>` (ZSET) + `mailcow.node.<service>.<node_id>` (HASH) — heartbeat registry
|
||||
- `mailcow.stats.<service>.<node_id>` (HASH) — per-node cpu/memory stats
|
||||
|
||||
Service behaviour is selected via `MAILCOW_AGENT_SERVICE=<service>`. The main
|
||||
process command is configured via `MAILCOW_AGENT_MAIN_CMD` (string, executed via
|
||||
`sh -c` so existing entrypoints/supervisord commands keep working).
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Per-container control-bus subscriber. Subscribes to mailcow.control.<service>
|
||||
// on Redis, runs handlers from the per-service command table, publishes
|
||||
// heartbeats and stats. Optionally supervises a child process.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/bus"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/registry"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/services"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/stats"
|
||||
)
|
||||
|
||||
const agentVersion = "0.1.0"
|
||||
|
||||
// atomicSignal shares the last caught terminal signal between the handler
|
||||
// goroutine and main() so it can be forwarded to the supervised child.
|
||||
type atomicSignal struct{ v atomic.Int32 }
|
||||
|
||||
func (a *atomicSignal) Store(s syscall.Signal) { a.v.Store(int32(s)) }
|
||||
func (a *atomicSignal) Load() os.Signal { return syscall.Signal(a.v.Load()) }
|
||||
|
||||
// healthState holds the latest health probe result. Written by the probe loop,
|
||||
// read by the heartbeat loop.
|
||||
type healthState struct {
|
||||
mu sync.RWMutex
|
||||
ok bool
|
||||
detail string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (h *healthState) Set(ok bool, detail string) {
|
||||
h.mu.Lock()
|
||||
h.ok = ok
|
||||
h.detail = detail
|
||||
h.at = time.Now()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *healthState) Snapshot() (ok bool, detail string, at time.Time) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.ok, h.detail, h.at
|
||||
}
|
||||
|
||||
func main() {
|
||||
service := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_SERVICE"))
|
||||
if service == "" {
|
||||
fmt.Fprintf(os.Stderr, "mailcow-agent: MAILCOW_AGENT_SERVICE is required. Known: %v\n", services.Known())
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// `mailcow-agent healthcheck` runs the probe once and exits 0/1
|
||||
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
||||
runHealthcheckOnce(service)
|
||||
}
|
||||
|
||||
nodeID := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_NODE_ID"))
|
||||
if nodeID == "" {
|
||||
h, err := os.Hostname()
|
||||
if err != nil {
|
||||
log.Fatalf("mailcow-agent: hostname: %v", err)
|
||||
}
|
||||
nodeID = h
|
||||
}
|
||||
|
||||
mainCmd := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_MAIN_CMD"))
|
||||
// host-agent has no supervised child; everything else runs one.
|
||||
wantsSupervisor := service != "host" && mainCmd != ""
|
||||
|
||||
rdb, err := newRedis()
|
||||
if err != nil {
|
||||
log.Fatalf("mailcow-agent: redis: %v", err)
|
||||
}
|
||||
defer rdb.Close()
|
||||
|
||||
// Start the supervised process before serving bus requests — restart/stop
|
||||
// handlers assume something is already running.
|
||||
var sup *proc.Supervisor
|
||||
if wantsSupervisor {
|
||||
sup = proc.New(mainCmd)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("mailcow-agent: start main: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
table, err := services.Build(service, sup)
|
||||
if err != nil {
|
||||
log.Fatalf("mailcow-agent: %v", err)
|
||||
}
|
||||
|
||||
// We handle signals ourselves so we can (a) suppress the Go-runtime stack
|
||||
// dump on SIGQUIT (php-fpm-alpine's STOPSIGNAL) and (b) remember which
|
||||
// signal arrived to forward it to the child on shutdown.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT,
|
||||
syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2)
|
||||
defer signal.Stop(sigCh)
|
||||
|
||||
stopSig := atomicSignal{}
|
||||
stopSig.Store(syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
for sig := range sigCh {
|
||||
switch sig {
|
||||
case syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT:
|
||||
stopSig.Store(sig.(syscall.Signal))
|
||||
log.Printf("mailcow-agent: caught %s, beginning graceful shutdown", sig)
|
||||
cancel()
|
||||
return
|
||||
case syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2:
|
||||
if sup != nil {
|
||||
sup.SignalChild(sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Initial state is "ok" so the service isn't flagged unhealthy before the
|
||||
// first probe has run.
|
||||
health := &healthState{ok: true, detail: "", at: time.Now()}
|
||||
if table.HealthProbe != nil {
|
||||
go runHealthLoop(ctx, table.HealthProbe, health, 10*time.Second)
|
||||
}
|
||||
|
||||
hb := registry.Heartbeat{
|
||||
Service: service,
|
||||
NodeID: nodeID,
|
||||
Version: agentVersion,
|
||||
StartedAt: time.Now(),
|
||||
Image: os.Getenv("MAILCOW_AGENT_IMAGE"),
|
||||
Health: health,
|
||||
}
|
||||
go registry.Loop(ctx, rdb, hb, 10*time.Second)
|
||||
|
||||
// cgroup stats for this container. Host metrics come from exec.host-stats.
|
||||
pub := stats.NewPublisher(rdb, service, nodeID)
|
||||
go pub.Run(ctx, 10*time.Second)
|
||||
|
||||
srv := bus.NewServer(rdb, table, nodeID)
|
||||
busErrCh := make(chan error, 1)
|
||||
go func() { busErrCh <- srv.Run(ctx) }()
|
||||
|
||||
log.Printf("mailcow-agent: service=%s node=%s ready (commands=%d)", service, nodeID, len(table.Handlers))
|
||||
|
||||
// Exit only on outside termination or fatal bus error. A crashed/stopped
|
||||
// child should not tear down the container — the operator may want to
|
||||
// issue `start` over the bus afterwards.
|
||||
exitCode := 0
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("mailcow-agent: shutdown signal received")
|
||||
case err := <-busErrCh:
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("mailcow-agent: bus loop exited: %v", err)
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown bounded at 35s.
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 35*time.Second)
|
||||
defer shutCancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
_ = registry.Deregister(shutCtx, rdb, service, nodeID)
|
||||
if sup != nil {
|
||||
// Forward the exact signal we received so the child sees the same
|
||||
// shutdown semantics it would without us in front (e.g. SIGQUIT →
|
||||
// php-fpm graceful drain).
|
||||
if err := sup.StopWithSignal(shutCtx, stopSig.Load()); err != nil {
|
||||
log.Printf("mailcow-agent: stop main: %v", err)
|
||||
}
|
||||
}
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
// runHealthcheckOnce runs the local probe with a tight deadline and exits 0/1.
|
||||
// Used by the `healthcheck` sub-command path.
|
||||
func runHealthcheckOnce(service string) {
|
||||
table, err := services.Build(service, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mailcow-agent healthcheck:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if table.HealthProbe == nil {
|
||||
// Services without a probe are considered healthy.
|
||||
os.Exit(0)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := table.HealthProbe(ctx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "unhealthy:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// runHealthLoop ticks the probe and updates state. Same probe path as the
|
||||
// healthcheck sub-command.
|
||||
func runHealthLoop(ctx context.Context, probe commands.HealthProbe, state *healthState, interval time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
check := func() {
|
||||
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := probe(pctx); err != nil {
|
||||
state.Set(false, err.Error())
|
||||
} else {
|
||||
state.Set(true, "")
|
||||
}
|
||||
}
|
||||
check()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newRedis() (*redis.Client, error) {
|
||||
addr := os.Getenv("REDIS_SLAVEOF_IP")
|
||||
port := os.Getenv("REDIS_SLAVEOF_PORT")
|
||||
if addr == "" {
|
||||
addr = "redis-mailcow"
|
||||
port = "6379"
|
||||
}
|
||||
if port == "" {
|
||||
port = "6379"
|
||||
}
|
||||
pass := os.Getenv("REDISPASS")
|
||||
cli := redis.NewClient(&redis.Options{
|
||||
Addr: addr + ":" + port,
|
||||
Password: pass,
|
||||
DB: 0,
|
||||
MaxRetries: -1,
|
||||
MinRetryBackoff: 200 * time.Millisecond,
|
||||
MaxRetryBackoff: 5 * time.Second,
|
||||
})
|
||||
// Wait up to 2 minutes for Redis to come up before giving up
|
||||
deadline := time.Now().Add(2 * time.Minute)
|
||||
var lastErr error
|
||||
for attempt := 1; time.Now().Before(deadline); attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err := cli.Ping(ctx).Err()
|
||||
cancel()
|
||||
if err == nil {
|
||||
return cli, nil
|
||||
}
|
||||
lastErr = err
|
||||
wait := time.Duration(attempt) * time.Second
|
||||
if wait > 10*time.Second {
|
||||
wait = 10 * time.Second
|
||||
}
|
||||
log.Printf("mailcow-agent: waiting for redis %s (attempt %d): %v", addr, attempt, err)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
return nil, fmt.Errorf("ping %s after 2m: %w", addr, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
module github.com/mailcow/mailcow-dockerized/agent
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package bus implements the Redis Pub/Sub control bus: subscribing to the
|
||||
// service's control channel, dispatching envelopes to a commands.Table, and
|
||||
// publishing responses back to env.ReplyTo.
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/envelope"
|
||||
)
|
||||
|
||||
// ControlChannel assembles the per-service control channel.
|
||||
func ControlChannel(service string) string { return "mailcow.control." + service }
|
||||
|
||||
// Server subscribes to a control channel and dispatches commands.
|
||||
type Server struct {
|
||||
rdb *redis.Client
|
||||
table *commands.Table
|
||||
nodeID string
|
||||
dedupe *lru
|
||||
stop chan struct{}
|
||||
stopped sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewServer wires a fresh server. nodeID is stamped into every Response and is
|
||||
// what the backend's fan-in aggregator uses to attribute results.
|
||||
func NewServer(rdb *redis.Client, table *commands.Table, nodeID string) *Server {
|
||||
return &Server{
|
||||
rdb: rdb,
|
||||
table: table,
|
||||
nodeID: nodeID,
|
||||
dedupe: newLRU(1024),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks, subscribing to ControlChannel(service) and dispatching incoming
|
||||
// envelopes concurrently. It returns when ctx is done or Shutdown is called.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
channel := ControlChannel(s.table.Service)
|
||||
sub := s.rdb.Subscribe(ctx, channel)
|
||||
defer sub.Close()
|
||||
if _, err := sub.Receive(ctx); err != nil {
|
||||
return fmt.Errorf("bus: subscribe %s: %w", channel, err)
|
||||
}
|
||||
msgs := sub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.wg.Wait()
|
||||
return ctx.Err()
|
||||
case <-s.stop:
|
||||
s.wg.Wait()
|
||||
return nil
|
||||
case m, ok := <-msgs:
|
||||
if !ok {
|
||||
s.wg.Wait()
|
||||
return errors.New("bus: subscription channel closed")
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func(payload string) {
|
||||
defer s.wg.Done()
|
||||
s.dispatch(ctx, payload)
|
||||
}(m.Payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown signals Run to stop and waits for in-flight handlers (bounded by
|
||||
// ctx).
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.stopped.Do(func() { close(s.stop) })
|
||||
done := make(chan struct{})
|
||||
go func() { s.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(parent context.Context, payload string) {
|
||||
var req envelope.Request
|
||||
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||
// Malformed envelope: no RequestID/ReplyTo we can trust — drop.
|
||||
return
|
||||
}
|
||||
if req.RequestID != "" && !s.dedupe.add(req.RequestID) {
|
||||
// Duplicate (retry of an idempotent command): silently absorb.
|
||||
return
|
||||
}
|
||||
// Per-node targeting: if args.target_node is set and doesn't match us,
|
||||
// drop silently. The intended replica picks it up and replies.
|
||||
if target, ok := req.Args["target_node"].(string); ok && target != "" && target != s.nodeID {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := handlerContext(parent, req.Deadline)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
resp := envelope.Response{RequestID: req.RequestID, OK: true, Node: s.nodeID}
|
||||
|
||||
if h := s.table.Lookup(req.Cmd); h == nil {
|
||||
resp.OK = false
|
||||
resp.Error = fmt.Sprintf("no handler for cmd %q", req.Cmd)
|
||||
resp.ErrorCode = envelope.ErrCodeUnsupportedCommand
|
||||
} else {
|
||||
result, err := runWithRecover(ctx, h, req.Args)
|
||||
switch {
|
||||
case err == nil:
|
||||
resp.Result = result
|
||||
case errors.Is(err, commands.ErrNotFound):
|
||||
resp.OK = false
|
||||
resp.Error = err.Error()
|
||||
resp.ErrorCode = envelope.ErrCodeNotFound
|
||||
case errors.Is(err, commands.ErrValidation):
|
||||
resp.OK = false
|
||||
resp.Error = err.Error()
|
||||
resp.ErrorCode = envelope.ErrCodeValidation
|
||||
case errors.Is(err, context.DeadlineExceeded), errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
resp.OK = false
|
||||
resp.Error = err.Error()
|
||||
resp.ErrorCode = envelope.ErrCodeTimeout
|
||||
default:
|
||||
resp.OK = false
|
||||
resp.Error = err.Error()
|
||||
resp.ErrorCode = envelope.ErrCodeInternal
|
||||
}
|
||||
}
|
||||
resp.DurationMS = time.Since(start).Milliseconds()
|
||||
|
||||
if req.ReplyTo == "" {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Replies go through a List (RPUSH + EXPIRE), not Pub/Sub. This sidesteps
|
||||
// the "subscribe-before-publish" race and lets the backend fan-in via
|
||||
// BLPOP — important because PhpRedis's subscribe() blocks, so we can't
|
||||
// publish on the same connection after subscribing. Use parent ctx so a
|
||||
// per-handler deadline can't stop us from delivering the timeout response.
|
||||
pipe := s.rdb.Pipeline()
|
||||
pipe.RPush(parent, req.ReplyTo, data)
|
||||
pipe.Expire(parent, req.ReplyTo, 60*time.Second)
|
||||
_, _ = pipe.Exec(parent)
|
||||
}
|
||||
|
||||
func runWithRecover(ctx context.Context, h commands.Handler, args map[string]any) (out any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("handler panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return h(ctx, args)
|
||||
}
|
||||
|
||||
func handlerContext(parent context.Context, deadline time.Time) (context.Context, context.CancelFunc) {
|
||||
if deadline.IsZero() {
|
||||
return context.WithCancel(parent)
|
||||
}
|
||||
return context.WithDeadline(parent, deadline)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// lru is a tiny request-id deduplication cache. The bus treats Pub/Sub
|
||||
// retries (same request_id) as no-ops. Not a security boundary — only a
|
||||
// best-effort guard against accidental double-execution.
|
||||
type lru struct {
|
||||
mu sync.Mutex
|
||||
cap int
|
||||
idx map[string]*list.Element
|
||||
list *list.List
|
||||
}
|
||||
|
||||
func newLRU(cap int) *lru {
|
||||
return &lru{cap: cap, idx: make(map[string]*list.Element, cap), list: list.New()}
|
||||
}
|
||||
|
||||
// add returns true if id is new and was inserted; false if it was already
|
||||
// known (caller should skip the duplicate).
|
||||
func (l *lru) add(id string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, ok := l.idx[id]; ok {
|
||||
return false
|
||||
}
|
||||
e := l.list.PushFront(id)
|
||||
l.idx[id] = e
|
||||
for l.list.Len() > l.cap {
|
||||
old := l.list.Back()
|
||||
l.list.Remove(old)
|
||||
delete(l.idx, old.Value.(string))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package commands defines the per-service handler table. The bus dispatcher
|
||||
// looks up handlers by name and wraps the result in an envelope.Response.
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNotFound signals that the target (queue id, mailbox, …) doesn't live on
|
||||
// this node. For broadcast operations the aggregator still counts success if
|
||||
// any other node returns ok.
|
||||
var ErrNotFound = errors.New("not_found")
|
||||
|
||||
// ErrValidation indicates a missing or malformed argument.
|
||||
var ErrValidation = errors.New("validation")
|
||||
|
||||
// Handler executes a single command for a service.
|
||||
type Handler func(ctx context.Context, args map[string]any) (any, error)
|
||||
|
||||
// HealthProbe returns nil if the supervised service is healthy, error otherwise.
|
||||
// Shared between the `healthcheck` sub-command and the agent's heartbeat loop.
|
||||
type HealthProbe func(ctx context.Context) error
|
||||
|
||||
// Table is the per-service command registry built once at startup.
|
||||
type Table struct {
|
||||
Service string
|
||||
Handlers map[string]Handler
|
||||
HealthProbe HealthProbe
|
||||
}
|
||||
|
||||
// New constructs an empty table for a service.
|
||||
func New(service string) *Table {
|
||||
return &Table{Service: service, Handlers: make(map[string]Handler)}
|
||||
}
|
||||
|
||||
// Register adds a handler. Duplicate registration panics — wiring bugs should
|
||||
// be loud.
|
||||
func (t *Table) Register(cmd string, h Handler) {
|
||||
if _, dup := t.Handlers[cmd]; dup {
|
||||
panic("commands: duplicate handler " + t.Service + "/" + cmd)
|
||||
}
|
||||
t.Handlers[cmd] = h
|
||||
}
|
||||
|
||||
// Lookup returns the handler for cmd or nil.
|
||||
func (t *Table) Lookup(cmd string) Handler {
|
||||
return t.Handlers[cmd]
|
||||
}
|
||||
|
||||
// ArgString extracts a required string argument.
|
||||
func ArgString(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return "", errArg(key, "missing")
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return "", errArg(key, "must be non-empty string")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ArgStringOpt returns an optional string argument with a default.
|
||||
func ArgStringOpt(args map[string]any, key, def string) string {
|
||||
if v, ok := args[key]; ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func errArg(key, reason string) error {
|
||||
return &validationError{key: key, reason: reason}
|
||||
}
|
||||
|
||||
type validationError struct{ key, reason string }
|
||||
|
||||
func (e *validationError) Error() string { return "arg " + e.key + ": " + e.reason }
|
||||
func (e *validationError) Is(target error) bool {
|
||||
return target == ErrValidation
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// RunOptions configures a single Run invocation.
|
||||
type RunOptions struct {
|
||||
// Stdin, if non-nil, is written to the process stdin.
|
||||
Stdin []byte
|
||||
// CombinedOutputCap limits the captured output (truncated at the end).
|
||||
// 0 means unlimited. The agent uses ~1 MiB for cat-queue, smaller for
|
||||
// status-style commands.
|
||||
OutputCap int
|
||||
}
|
||||
|
||||
// RunResult is what every shell-style command returns.
|
||||
type RunResult struct {
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
}
|
||||
|
||||
// Run executes argv[0] argv[1:] under ctx (the bus deadline). It does not
|
||||
// translate exit codes to errors — callers inspect r.ExitCode themselves so
|
||||
// they can map e.g. "queue id not found" exit codes to ErrNotFound.
|
||||
func Run(ctx context.Context, opts RunOptions, argv ...string) (*RunResult, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, fmt.Errorf("commands.Run: empty argv")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if opts.Stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(opts.Stdin)
|
||||
}
|
||||
err := cmd.Run()
|
||||
|
||||
out := stdout.String()
|
||||
errOut := stderr.String()
|
||||
if opts.OutputCap > 0 {
|
||||
if len(out) > opts.OutputCap {
|
||||
out = out[:opts.OutputCap] + "\n…(truncated)"
|
||||
}
|
||||
if len(errOut) > opts.OutputCap {
|
||||
errOut = errOut[:opts.OutputCap] + "\n…(truncated)"
|
||||
}
|
||||
}
|
||||
|
||||
exit := 0
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exit = exitErr.ExitCode()
|
||||
err = nil
|
||||
}
|
||||
return &RunResult{Stdout: out, Stderr: errOut, ExitCode: exit}, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package envelope defines the wire format for the mailcow-agent control bus.
|
||||
package envelope
|
||||
|
||||
import "time"
|
||||
|
||||
// Request is what the backend publishes on mailcow.control.<service>.
|
||||
type Request struct {
|
||||
Cmd string `json:"cmd"`
|
||||
RequestID string `json:"request_id"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
Deadline time.Time `json:"deadline,omitempty"`
|
||||
IssuedBy string `json:"issued_by,omitempty"`
|
||||
}
|
||||
|
||||
// Response is what the agent publishes on the reply_to channel.
|
||||
type Response struct {
|
||||
RequestID string `json:"request_id"`
|
||||
OK bool `json:"ok"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Node string `json:"node,omitempty"`
|
||||
}
|
||||
|
||||
// Error codes returned in Response.ErrorCode. Keep in sync with the V2 schema.
|
||||
const (
|
||||
ErrCodeValidation = "validation"
|
||||
ErrCodeNotFound = "not_found"
|
||||
ErrCodeTimeout = "timeout"
|
||||
ErrCodeUnsupportedCommand = "unsupported_command"
|
||||
ErrCodeInternal = "internal"
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package proc supervises the service's main process — postfix, dovecot,
|
||||
// nginx, … — as a child of the agent. It exposes the high-level lifecycle
|
||||
// verbs (reload/restart/stop/start) used by the per-service command tables.
|
||||
//
|
||||
// "reload" → SIGHUP
|
||||
// "restart" → SIGTERM, wait, exec again
|
||||
// "stop" → SIGTERM, leave stopped
|
||||
// "start" → exec again (only if currently stopped)
|
||||
package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Supervisor wraps a single child process.
|
||||
type Supervisor struct {
|
||||
cmdLine string // shell command (passed to `sh -c …`)
|
||||
stopSignal os.Signal
|
||||
stopGrace time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
stopped bool
|
||||
exitedCh chan struct{}
|
||||
}
|
||||
|
||||
// New constructs a Supervisor. cmdLine is executed via `sh -c` so existing
|
||||
// docker-entrypoint.sh scripts keep working without quoting headaches.
|
||||
func New(cmdLine string) *Supervisor {
|
||||
return &Supervisor{
|
||||
cmdLine: cmdLine,
|
||||
stopSignal: syscall.SIGTERM,
|
||||
stopGrace: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the child process. Returns an error if it cannot be spawned.
|
||||
// The agent's main() also blocks on Wait() to surface exit status.
|
||||
func (s *Supervisor) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cmd != nil && s.cmd.Process != nil && !s.stopped {
|
||||
return errors.New("proc: already running")
|
||||
}
|
||||
// `exec ` prefix tells the shell to replace itself with the command
|
||||
// instead of forking and waiting. Without it, sh stays alive as the
|
||||
// parent of the real service process, signals from us land on the
|
||||
// shell instead of on the service, and SIGHUP for config reloads
|
||||
// silently does nothing. With the prefix the supervised PID *is* the
|
||||
// service after the script's own `exec "$@"` chains through.
|
||||
cmd := exec.Command("/bin/sh", "-c", "exec "+s.cmdLine)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("proc: start: %w", err)
|
||||
}
|
||||
s.cmd = cmd
|
||||
s.stopped = false
|
||||
s.exitedCh = make(chan struct{})
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(s.exitedCh)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the child exits and returns its exit code.
|
||||
func (s *Supervisor) Wait() int {
|
||||
s.mu.Lock()
|
||||
exited := s.exitedCh
|
||||
cmd := s.cmd
|
||||
s.mu.Unlock()
|
||||
if exited == nil {
|
||||
return -1
|
||||
}
|
||||
<-exited
|
||||
if cmd == nil || cmd.ProcessState == nil {
|
||||
return -1
|
||||
}
|
||||
return cmd.ProcessState.ExitCode()
|
||||
}
|
||||
|
||||
// SignalChild forwards a single signal to the supervised child without
|
||||
// changing the supervisor's lifecycle state. Used to relay SIGHUP/USR1/USR2
|
||||
// from the agent's signal handler to the service so operators can still
|
||||
// `docker compose kill -s HUP postfix-mailcow` and see the expected effect.
|
||||
func (s *Supervisor) SignalChild(sig os.Signal) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cmd == nil || s.cmd.Process == nil || s.stopped {
|
||||
return errors.New("proc: not running")
|
||||
}
|
||||
return s.cmd.Process.Signal(sig)
|
||||
}
|
||||
|
||||
// Reload sends SIGHUP. Returns nil if the signal was delivered.
|
||||
func (s *Supervisor) Reload() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cmd == nil || s.cmd.Process == nil || s.stopped {
|
||||
return errors.New("proc: not running")
|
||||
}
|
||||
return s.cmd.Process.Signal(syscall.SIGHUP)
|
||||
}
|
||||
|
||||
// Stop sends the configured stop signal and waits for the process to exit
|
||||
// (bounded by stopGrace). Marks the supervisor as stopped — Start must be
|
||||
// called again to relaunch.
|
||||
func (s *Supervisor) Stop(ctx context.Context) error {
|
||||
return s.StopWithSignal(ctx, s.stopSignal)
|
||||
}
|
||||
|
||||
// StopWithSignal is like Stop but lets the caller override the stop signal.
|
||||
// Used by main() to forward whatever signal Docker sent us (SIGTERM for
|
||||
// most containers, SIGQUIT for php-fpm-alpine which uses SIGQUIT for
|
||||
// graceful shutdown) so the child gets the same signal semantics it would
|
||||
// receive without the agent in front of it.
|
||||
func (s *Supervisor) StopWithSignal(ctx context.Context, sig os.Signal) error {
|
||||
s.mu.Lock()
|
||||
cmd := s.cmd
|
||||
exited := s.exitedCh
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.stopped = true
|
||||
s.mu.Unlock()
|
||||
|
||||
sysSig, ok := sig.(syscall.Signal)
|
||||
if !ok {
|
||||
sysSig = syscall.SIGTERM
|
||||
}
|
||||
pgid, err := syscall.Getpgid(cmd.Process.Pid)
|
||||
if err == nil {
|
||||
_ = syscall.Kill(-pgid, sysSig)
|
||||
} else {
|
||||
_ = cmd.Process.Signal(sysSig)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(s.stopGrace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-exited:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
// Last resort: SIGKILL the whole process group.
|
||||
if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil {
|
||||
_ = syscall.Kill(-pgid, syscall.SIGKILL)
|
||||
} else {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
<-exited
|
||||
return errors.New("proc: forced kill after grace period")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Restart performs Stop+Start using the supervisor's default stop signal.
|
||||
// Different from a Docker-initiated shutdown: here it's an explicit "restart
|
||||
// this service" command, so we want the standard SIGTERM semantics.
|
||||
func (s *Supervisor) Restart(ctx context.Context) error {
|
||||
if err := s.Stop(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Start()
|
||||
}
|
||||
|
||||
// IsRunning reports whether the supervised child is currently alive (started
|
||||
// and not yet exited or stopped).
|
||||
func (s *Supervisor) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.stopped || s.cmd == nil || s.cmd.Process == nil {
|
||||
return false
|
||||
}
|
||||
// exitedCh is closed when the child exits. Non-blocking read.
|
||||
select {
|
||||
case <-s.exitedCh:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// WaitStable blocks for `settle` and returns nil if the supervised child is
|
||||
// still running at the end, otherwise an error describing the exit. Used by
|
||||
// the `restart` command to give the operator real "did it come back up"
|
||||
// feedback instead of an immediate OK.
|
||||
func (s *Supervisor) WaitStable(ctx context.Context, settle time.Duration) error {
|
||||
s.mu.Lock()
|
||||
exited := s.exitedCh
|
||||
s.mu.Unlock()
|
||||
if exited == nil {
|
||||
return errors.New("proc: not running")
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
// Child died within the settle window.
|
||||
s.mu.Lock()
|
||||
cmd := s.cmd
|
||||
s.mu.Unlock()
|
||||
code := -1
|
||||
if cmd != nil && cmd.ProcessState != nil {
|
||||
code = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
return fmt.Errorf("proc: child exited within settle window (code=%d)", code)
|
||||
case <-time.After(settle):
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Forward installs a signal forwarder: SIGINT/SIGTERM/SIGHUP/SIGUSR1/SIGUSR2
|
||||
// received by the agent are propagated to the child. Returns a cancel func
|
||||
// to release the handler.
|
||||
func (s *Supervisor) Forward(signals ...os.Signal) func() {
|
||||
ch := make(chan os.Signal, len(signals)+1)
|
||||
signalNotify(ch, signals...)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case sig := <-ch:
|
||||
s.mu.Lock()
|
||||
cmd := s.cmd
|
||||
s.mu.Unlock()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Signal(sig)
|
||||
}
|
||||
if sig == syscall.SIGTERM || sig == syscall.SIGINT {
|
||||
// On terminal signals propagate and let main exit.
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(done)
|
||||
signalStop(ch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
// Indirection so tests can stub these out if ever needed.
|
||||
var (
|
||||
signalNotify = signal.Notify
|
||||
signalStop = signal.Stop
|
||||
)
|
||||
|
||||
var _ = os.Stdout // anchor import for go vet
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package registry publishes per-node heartbeats to Redis so the backend can
|
||||
// enumerate live containers. Two keys per service:
|
||||
//
|
||||
// ZSET mailcow.nodes.<service> score=unix_ts member=node_id
|
||||
// HASH mailcow.node.<service>.<node_id> { version, started_at, image, health* }
|
||||
//
|
||||
// Both keys have a 30s TTL refreshed every 10s. Deregister clears them on
|
||||
// graceful shutdown.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// HealthSnapshotter returns the latest health probe result so the heartbeat
|
||||
// can attach it to each tick. Implemented by main.healthState.
|
||||
type HealthSnapshotter interface {
|
||||
Snapshot() (ok bool, detail string, at time.Time)
|
||||
}
|
||||
|
||||
// Heartbeat carries the metadata published with every refresh.
|
||||
type Heartbeat struct {
|
||||
Service string
|
||||
NodeID string
|
||||
Version string
|
||||
StartedAt time.Time
|
||||
Image string
|
||||
Health HealthSnapshotter // optional; nil → omit health fields
|
||||
}
|
||||
|
||||
func nodesKey(service string) string { return "mailcow.nodes." + service }
|
||||
func nodeKey(service, node string) string { return "mailcow.node." + service + "." + node }
|
||||
|
||||
// Publish writes one heartbeat tick. Callers run this in a loop.
|
||||
func Publish(ctx context.Context, rdb *redis.Client, h Heartbeat) error {
|
||||
now := time.Now().Unix()
|
||||
fields := map[string]any{
|
||||
"version": h.Version,
|
||||
"started_at": h.StartedAt.UTC().Format(time.RFC3339),
|
||||
"image": h.Image,
|
||||
"node_id": h.NodeID,
|
||||
"service": h.Service,
|
||||
"updated_at": strconv.FormatInt(now, 10),
|
||||
}
|
||||
if h.Health != nil {
|
||||
ok, detail, at := h.Health.Snapshot()
|
||||
if ok {
|
||||
fields["health"] = "ok"
|
||||
} else {
|
||||
fields["health"] = "fail"
|
||||
}
|
||||
fields["health_detail"] = detail
|
||||
fields["health_at"] = strconv.FormatInt(at.Unix(), 10)
|
||||
}
|
||||
pipe := rdb.Pipeline()
|
||||
pipe.ZAdd(ctx, nodesKey(h.Service), redis.Z{Score: float64(now), Member: h.NodeID})
|
||||
pipe.Expire(ctx, nodesKey(h.Service), 5*time.Minute)
|
||||
pipe.HSet(ctx, nodeKey(h.Service, h.NodeID), fields)
|
||||
pipe.Expire(ctx, nodeKey(h.Service, h.NodeID), 30*time.Second)
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry: heartbeat exec: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deregister removes the node from the ZSET and deletes its detail hash.
|
||||
// Called on graceful shutdown so the dashboard reflects intentional stops
|
||||
// immediately rather than waiting for TTL.
|
||||
func Deregister(ctx context.Context, rdb *redis.Client, service, nodeID string) error {
|
||||
pipe := rdb.Pipeline()
|
||||
pipe.ZRem(ctx, nodesKey(service), nodeID)
|
||||
pipe.Del(ctx, nodeKey(service, nodeID))
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// Loop runs Publish on a ticker until ctx is done. It is the typical caller.
|
||||
func Loop(ctx context.Context, rdb *redis.Client, h Heartbeat, interval time.Duration) {
|
||||
// Publish once immediately so the dashboard sees us right away.
|
||||
_ = Publish(ctx, rdb, h)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = Publish(ctx, rdb, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package services
|
||||
|
||||
import "time"
|
||||
|
||||
// nowStamp returns a sortable timestamp used to suffix moved/garbage maildirs
|
||||
// so repeated cleanups don't collide.
|
||||
func nowStamp() string {
|
||||
return time.Now().UTC().Format("20060102T150405Z")
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("dovecot", buildDovecot) }
|
||||
|
||||
const vmailRoot = "/var/vmail"
|
||||
|
||||
func dovecotHealthProbe(ctx context.Context) error {
|
||||
// IMAP greeting on :143 — must be "* OK ..."
|
||||
conn, err := net.DialTimeout("tcp", "127.0.0.1:143", 3*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 64)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read greeting: %w", err)
|
||||
}
|
||||
greeting := string(buf[:n])
|
||||
if !strings.HasPrefix(greeting, "* OK") {
|
||||
return fmt.Errorf("unexpected greeting: %s", strings.TrimSpace(greeting))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDovecot(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("dovecot")
|
||||
t.HealthProbe = dovecotHealthProbe
|
||||
|
||||
// `dovecot reload` re-reads config without restarting the master process.
|
||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "dovecot", "reload")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
addLifecycleExceptReload(t, sup)
|
||||
|
||||
t.Register("exec.fts-rescan", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user := commands.ArgStringOpt(args, "user", "")
|
||||
argv := []string{"doveadm", "fts", "rescan"}
|
||||
if user != "" {
|
||||
argv = append(argv, "-u", user)
|
||||
} else {
|
||||
argv = append(argv, "-A")
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, argv...)
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.sieve-list", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "sieve", "list", "-u", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
return nil, &runError{msg: strings.TrimSpace(r.Stderr)}
|
||||
}
|
||||
scripts := splitNonEmpty(r.Stdout)
|
||||
return map[string]any{"scripts": scripts}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.sieve-print", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
script, err := commands.ArgString(args, "script")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 1 << 20}, "doveadm", "sieve", "get", "-u", user, script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
return nil, &runError{msg: strings.TrimSpace(r.Stderr)}
|
||||
}
|
||||
return map[string]any{"body": r.Stdout}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.acl-get", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// First enumerate mailboxes, then collect ACLs per mailbox.
|
||||
boxes, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "mailbox", "list", "-u", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if boxes.ExitCode != 0 {
|
||||
return nil, &runError{msg: strings.TrimSpace(boxes.Stderr)}
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, mbx := range splitNonEmpty(boxes.Stdout) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "get", "-u", user, mbx)
|
||||
if err != nil || r.ExitCode != 0 {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(r.Stdout), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "ID") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
out = append(out, map[string]any{
|
||||
"mailbox": mbx,
|
||||
"identifier": fields[0],
|
||||
"rights": strings.Join(fields[1:], " "),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]any{"acls": out}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.acl-set", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox, err := commands.ArgString(args, "mailbox")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identifier, err := commands.ArgString(args, "identifier")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rights, err := commands.ArgString(args, "rights")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "set", "-u", user, mailbox, identifier, rights)
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.acl-delete", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox, err := commands.ArgString(args, "mailbox")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identifier, err := commands.ArgString(args, "identifier")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "delete", "-u", user, mailbox, identifier)
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.maildir-cleanup", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
maildir, err := commands.ArgString(args, "maildir")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertSafeMaildirPath(maildir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src := filepath.Join(vmailRoot, maildir)
|
||||
dst := filepath.Join(vmailRoot, "_garbage", maildir+"_"+nowStamp())
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return nil, commands.ErrNotFound
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o770); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, os.Rename(src, dst)
|
||||
})
|
||||
|
||||
t.Register("exec.df", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
dir := commands.ArgStringOpt(args, "dir", "/var/vmail")
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(dir, &st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := uint64(st.Blocks) * uint64(st.Bsize)
|
||||
free := uint64(st.Bavail) * uint64(st.Bsize)
|
||||
used := size - free
|
||||
pct := 0
|
||||
if size > 0 {
|
||||
pct = int(float64(used) / float64(size) * 100)
|
||||
}
|
||||
// Format: Filesystem,Size,Used,Avail,Use%,Mounted-on
|
||||
return fmt.Sprintf("%s,%s,%s,%s,%d%%,%s",
|
||||
"local", humanBytes(size), humanBytes(used), humanBytes(free), pct, dir), nil
|
||||
})
|
||||
|
||||
t.Register("exec.maildir-move", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
from, err := commands.ArgString(args, "from")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to, err := commands.ArgString(args, "to")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertSafeMaildirPath(from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertSafeMaildirPath(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src := filepath.Join(vmailRoot, from)
|
||||
dst := filepath.Join(vmailRoot, to)
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return nil, commands.ErrNotFound
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o770); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, os.Rename(src, dst)
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// addLifecycleExceptReload wires restart/stop/start without overriding reload,
|
||||
// which postfix/dovecot define themselves (canonical CLI command).
|
||||
func addLifecycleExceptReload(t *commands.Table, sup *proc.Supervisor) {
|
||||
if sup == nil {
|
||||
return
|
||||
}
|
||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Restart(ctx)
|
||||
})
|
||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Stop(ctx)
|
||||
})
|
||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Start()
|
||||
})
|
||||
}
|
||||
|
||||
func splitNonEmpty(s string) []string {
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assertSafeMaildirPath blocks path traversal and absolute paths — relative
|
||||
// names under /var/vmail only.
|
||||
func assertSafeMaildirPath(p string) error {
|
||||
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "..") {
|
||||
return &validationErr{msg: "unsafe maildir path"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type validationErr struct{ msg string }
|
||||
|
||||
func (e *validationErr) Error() string { return e.msg }
|
||||
func (e *validationErr) Is(target error) bool { return target == commands.ErrValidation }
|
||||
|
||||
// humanBytes renders a byte count in `df -H` style (1000-based units).
|
||||
func humanBytes(n uint64) string {
|
||||
const unit = 1000
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for x := n / unit; x >= unit; x /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f%c", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
// Services without any exec.* commands of their own — lifecycle only.
|
||||
func init() {
|
||||
Register("clamd", genericBuilder("clamd", tcpProbe("127.0.0.1:3310", 2*time.Second)))
|
||||
Register("olefy", genericBuilder("olefy", tcpProbe("127.0.0.1:10055", 2*time.Second)))
|
||||
Register("postfix-tlspol", genericBuilder("postfix-tlspol", tcpProbe("127.0.0.1:8642", 2*time.Second)))
|
||||
Register("php-fpm", genericBuilder("php-fpm", tcpProbe("127.0.0.1:9001", 2*time.Second)))
|
||||
Register("acme", genericBuilder("acme", nil))
|
||||
Register("watchdog", genericBuilder("watchdog", nil))
|
||||
Register("netfilter", genericBuilder("netfilter", nil))
|
||||
Register("ofelia", genericBuilder("ofelia", nil))
|
||||
Register("dovecot-fts", genericBuilder("dovecot-fts", nil))
|
||||
}
|
||||
|
||||
func genericBuilder(name string, probe commands.HealthProbe) Builder {
|
||||
return func(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New(name)
|
||||
t.HealthProbe = probe
|
||||
addLifecycle(t, sup)
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
func tcpProbe(addr string, timeout time.Duration) commands.HealthProbe {
|
||||
return func(ctx context.Context) error {
|
||||
return probeTCP(addr, timeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
)
|
||||
|
||||
// runError is what we return when a shell command exited non-zero but the
|
||||
// failure is not a "target not found" case. The bus maps it to
|
||||
// ErrCodeInternal.
|
||||
type runError struct{ msg string }
|
||||
|
||||
func (e *runError) Error() string { return e.msg }
|
||||
|
||||
// asError converts a (RunResult, err) pair from commands.Run into a single
|
||||
// error: pre-exec error → return as-is; non-zero exit → wrap stderr.
|
||||
func asError(r *commands.RunResult, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
msg := strings.TrimSpace(r.Stderr)
|
||||
if msg == "" {
|
||||
msg = "command exited " + itoa(r.ExitCode)
|
||||
}
|
||||
return &runError{msg: msg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// asNotFoundOrError is the variant for queue/mailbox operations that may fail
|
||||
// because the target doesn't live on this node. Maps known stderr fragments
|
||||
// to commands.ErrNotFound so broadcast aggregation works.
|
||||
func asNotFoundOrError(r *commands.RunResult, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.ExitCode == 0 {
|
||||
return nil
|
||||
}
|
||||
if matchesAny(r.Stderr, notFoundFragments) {
|
||||
return commands.ErrNotFound
|
||||
}
|
||||
return &runError{msg: strings.TrimSpace(r.Stderr)}
|
||||
}
|
||||
|
||||
func matchesAny(haystack string, fragments []string) bool {
|
||||
for _, f := range fragments {
|
||||
if strings.Contains(haystack, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
// avoid strconv import for a one-shot; small ints only
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := false
|
||||
if i < 0 {
|
||||
neg = true
|
||||
i = -i
|
||||
}
|
||||
var b [20]byte
|
||||
n := len(b)
|
||||
for i > 0 {
|
||||
n--
|
||||
b[n] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
if neg {
|
||||
n--
|
||||
b[n] = '-'
|
||||
}
|
||||
return string(b[n:])
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("host", buildHost) }
|
||||
|
||||
// hostProcRoot is where the host-agent container mounts /proc. If we're not
|
||||
// running as host-agent, falling back to /proc still produces sensible numbers
|
||||
// (the container's own view) so dashboards don't blank out in unit tests.
|
||||
var hostProcRoot = "/host/proc"
|
||||
|
||||
func resolveProc(p string) string {
|
||||
if _, err := os.Stat(hostProcRoot); err == nil {
|
||||
return hostProcRoot + p
|
||||
}
|
||||
return "/proc" + p
|
||||
}
|
||||
|
||||
func buildHost(_ *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("host")
|
||||
// No lifecycle — the host-agent container has no main process to manage.
|
||||
|
||||
t.Register("exec.df", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
path := commands.ArgStringOpt(args, "path", "/")
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return nil, fmt.Errorf("statfs %s: %w", path, err)
|
||||
}
|
||||
size := int64(stat.Blocks) * int64(stat.Bsize)
|
||||
free := int64(stat.Bavail) * int64(stat.Bsize)
|
||||
used := size - free
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"size": size,
|
||||
"used": used,
|
||||
"available": free,
|
||||
}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.host-stats", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return readHostStats()
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func readHostStats() (map[string]any, error) {
|
||||
out := map[string]any{
|
||||
"system_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
"architecture": readArchitecture(),
|
||||
}
|
||||
|
||||
if uptime, err := readUptime(); err == nil {
|
||||
out["uptime"] = int64(uptime)
|
||||
} else {
|
||||
out["uptime"] = int64(0)
|
||||
}
|
||||
|
||||
cores := readCPUCores()
|
||||
cpuUsage, _ := sampleHostCPU(500 * time.Millisecond)
|
||||
out["cpu"] = map[string]any{
|
||||
"cores": cores,
|
||||
"usage": cpuUsage,
|
||||
}
|
||||
|
||||
memTotal, memUsage := readMemoryTotalAndUsagePct()
|
||||
out["memory"] = map[string]any{
|
||||
"total": memTotal, // bytes
|
||||
"usage": memUsage, // percent 0..100
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readArchitecture returns the host's machine architecture (e.g. "x86_64",
|
||||
// "aarch64"). Falls back to a single dash if syscall.Uname fails.
|
||||
func readArchitecture() string {
|
||||
var u syscall.Utsname
|
||||
if err := syscall.Uname(&u); err != nil {
|
||||
return "-"
|
||||
}
|
||||
return charsToString(u.Machine[:])
|
||||
}
|
||||
|
||||
func charsToString(b []int8) string {
|
||||
out := make([]byte, 0, len(b))
|
||||
for _, c := range b {
|
||||
if c == 0 {
|
||||
break
|
||||
}
|
||||
out = append(out, byte(c))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// readCPUCores counts `^processor` lines in /proc/cpuinfo. On a container
|
||||
// with /host/proc bind-mounted this gives the host's logical CPU count,
|
||||
// not the container's cgroup limits.
|
||||
func readCPUCores() int {
|
||||
f, err := os.Open(resolveProc("/cpuinfo"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
n := 0
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
if strings.HasPrefix(sc.Text(), "processor") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// readMemoryTotalAndUsagePct reads /proc/meminfo and returns (total_bytes,
|
||||
// usage_pct_0_100). "Usage" is computed as (Total - Available)/Total which
|
||||
// matches what tools like `free` show as "used".
|
||||
func readMemoryTotalAndUsagePct() (int64, int) {
|
||||
f, err := os.Open(resolveProc("/meminfo"))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var total, available int64
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
total = parseInt64(fields[1]) * 1024
|
||||
case "MemAvailable:":
|
||||
available = parseInt64(fields[1]) * 1024
|
||||
}
|
||||
}
|
||||
if total <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
used := total - available
|
||||
if available <= 0 {
|
||||
used = total
|
||||
}
|
||||
pct := int(float64(used) / float64(total) * 100.0)
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return total, pct
|
||||
}
|
||||
|
||||
func readUptime() (float64, error) {
|
||||
b, err := os.ReadFile(resolveProc("/uptime"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fields := strings.Fields(string(b))
|
||||
if len(fields) < 1 {
|
||||
return 0, fmt.Errorf("malformed uptime")
|
||||
}
|
||||
return strconv.ParseFloat(fields[0], 64)
|
||||
}
|
||||
|
||||
// sampleHostCPU returns CPU utilization (0..100) sampled over `window`.
|
||||
func sampleHostCPU(window time.Duration) (float64, error) {
|
||||
a, err := readCPULine()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
time.Sleep(window)
|
||||
b, err := readCPULine()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
totalA, totalB := sum(a), sum(b)
|
||||
idleA, idleB := a[3], b[3]
|
||||
dTotal, dIdle := totalB-totalA, idleB-idleA
|
||||
if dTotal == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return 100.0 * float64(dTotal-dIdle) / float64(dTotal), nil
|
||||
}
|
||||
|
||||
func readCPULine() ([]int64, error) {
|
||||
f, err := os.Open(resolveProc("/stat"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
if !sc.Scan() {
|
||||
return nil, fmt.Errorf("empty /proc/stat")
|
||||
}
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return nil, fmt.Errorf("unexpected /proc/stat first line")
|
||||
}
|
||||
out := make([]int64, 0, len(fields)-1)
|
||||
for _, f := range fields[1:] {
|
||||
n, err := strconv.ParseInt(f, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sum(xs []int64) int64 {
|
||||
var s int64
|
||||
for _, x := range xs {
|
||||
s += x
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseInt64(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("nginx", buildNginx) }
|
||||
|
||||
func nginxHealthProbe(ctx context.Context) error {
|
||||
if err := probeShell(ctx, 3*time.Second, "nginx", "-t"); err != nil {
|
||||
return err
|
||||
}
|
||||
return probeTCP("127.0.0.1:8081", 2*time.Second)
|
||||
}
|
||||
|
||||
func buildNginx(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("nginx")
|
||||
t.HealthProbe = nginxHealthProbe
|
||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "nginx", "-s", "reload")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
addLifecycleExceptReload(t, sup)
|
||||
t.Register("exec.test-config", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "nginx", "-t")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"ok": r.ExitCode == 0,
|
||||
"output": r.Stderr + r.Stdout,
|
||||
}, nil
|
||||
})
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("postfix", buildPostfix) }
|
||||
|
||||
// notFoundFragments are substrings emitted by postsuper/postqueue when the
|
||||
// requested queue id doesn't live on this node. Broadcast handlers map them
|
||||
// to commands.ErrNotFound so the backend can count partial success.
|
||||
var notFoundFragments = []string{
|
||||
"No such file or directory",
|
||||
"no such file",
|
||||
"unknown",
|
||||
}
|
||||
|
||||
func postfixHealthProbe(ctx context.Context) error {
|
||||
if err := probeSMTPGreeting("127.0.0.1:25", 3*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
return probeShell(ctx, 5*time.Second, "postfix", "status")
|
||||
}
|
||||
|
||||
func buildPostfix(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("postfix")
|
||||
t.HealthProbe = postfixHealthProbe
|
||||
|
||||
// Override generic reload — `postfix reload` is the canonical operation,
|
||||
// not SIGHUP-to-supervisord (which would just rotate logs).
|
||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postfix", "reload")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
// Lifecycle: stop/start/restart still go through the supervisor.
|
||||
if sup != nil {
|
||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Restart(ctx)
|
||||
})
|
||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Stop(ctx)
|
||||
})
|
||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Start()
|
||||
})
|
||||
}
|
||||
|
||||
t.Register("exec.mailq", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 8 << 20}, "postqueue", "-j")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
return nil, &runError{msg: "postqueue failed: " + r.Stderr}
|
||||
}
|
||||
// postqueue -j prints one JSON object per line.
|
||||
entries := make([]map[string]any, 0)
|
||||
for _, line := range strings.Split(strings.TrimSpace(r.Stdout), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &obj); err == nil {
|
||||
entries = append(entries, obj)
|
||||
}
|
||||
}
|
||||
return map[string]any{"queue": entries}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.flush-queue", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postqueue", "-f")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.delete-from-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-d", qid)
|
||||
return nil, asNotFoundOrError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.hold-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-h", qid)
|
||||
return nil, asNotFoundOrError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.unhold-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-H", qid)
|
||||
return nil, asNotFoundOrError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.deliver-now", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postqueue", "-i", qid)
|
||||
return nil, asNotFoundOrError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.cat-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 2 << 20}, "postcat", "-q", qid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
if matchesAny(r.Stderr, notFoundFragments) {
|
||||
return nil, commands.ErrNotFound
|
||||
}
|
||||
return nil, &runError{msg: "postcat failed: " + r.Stderr}
|
||||
}
|
||||
return map[string]any{"body": r.Stdout}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.super-delete", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-d", "ALL")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
)
|
||||
|
||||
// probeTCP opens a TCP connection to addr within timeout. Returns nil if the
|
||||
// port accepts a connection, otherwise the dial error.
|
||||
func probeTCP(addr string, timeout time.Duration) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeSMTPGreeting connects to addr and reads the SMTP greeting line. The
|
||||
// service is considered healthy if the line starts with "220".
|
||||
func probeSMTPGreeting(addr string, timeout time.Duration) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
line, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read greeting: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "220") {
|
||||
return fmt.Errorf("unexpected greeting: %s", strings.TrimSpace(line))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeHTTP issues a GET to url, checks for a 2xx status.
|
||||
func probeHTTP(ctx context.Context, url string, timeout time.Duration) error {
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("http %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeShell runs argv with a timeout and returns nil if exit code is 0.
|
||||
func probeShell(ctx context.Context, timeout time.Duration, argv ...string) error {
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
r, err := commands.Run(cctx, commands.RunOptions{}, argv...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
msg := strings.TrimSpace(r.Stderr)
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("exit %d", r.ExitCode)
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("rspamd", buildRspamd) }
|
||||
|
||||
func rspamdHealthProbe(ctx context.Context) error {
|
||||
return probeHTTP(ctx, "http://127.0.0.1:11334/ping", 3*time.Second)
|
||||
}
|
||||
|
||||
// Override file rspamd reads on startup for the controller's enable_password.
|
||||
const rspamdWorkerPasswordPath = "/etc/rspamd/override.d/worker-controller-password.inc"
|
||||
|
||||
func buildRspamd(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("rspamd")
|
||||
t.HealthProbe = rspamdHealthProbe
|
||||
addLifecycle(t, sup)
|
||||
|
||||
t.Register("exec.set-worker-password", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
password, err := commands.ArgString(args, "password")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// rspamadm pw -e -p <pw> writes the hashed value to stdout.
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "rspamadm", "pw", "-e", "-p", password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
return nil, &runError{msg: "rspamadm pw failed: " + strings.TrimSpace(r.Stderr)}
|
||||
}
|
||||
hash := strings.TrimSpace(r.Stdout)
|
||||
// rspamd distinguishes `password` (read-only access to the controller)
|
||||
// from `enable_password` (write access — restart, settings, learn).
|
||||
content := "enable_password = \"" + hash + "\";\n"
|
||||
if err := os.MkdirAll(filepath.Dir(rspamdWorkerPasswordPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(rspamdWorkerPasswordPath, []byte(content), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Must do a full re-fork of workers (SIGHUP to rspamd master), not
|
||||
// `rspamadm control reload`
|
||||
if sup != nil {
|
||||
return nil, sup.Reload()
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
t.Register("exec.relearn-spam", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
path, err := commands.ArgString(args, "file")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{Stdin: data}, "rspamc", "learn_spam")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.relearn-ham", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
path, err := commands.ArgString(args, "file")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{Stdin: data}, "rspamc", "learn_ham")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package services registers per-service command tables. The agent selects
|
||||
// the right table at startup via MAILCOW_AGENT_SERVICE.
|
||||
//
|
||||
// A service "builder" receives a Supervisor for lifecycle commands; services
|
||||
// that don't supervise a main process (currently just "host") pass nil and
|
||||
// the generic lifecycle commands are skipped.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
// Builder constructs a command table for a service. sup may be nil for
|
||||
// services without a supervised main process.
|
||||
type Builder func(sup *proc.Supervisor) *commands.Table
|
||||
|
||||
var registry = map[string]Builder{}
|
||||
|
||||
// Register installs a builder for a service name. Called from init() in each
|
||||
// per-service file.
|
||||
func Register(service string, b Builder) {
|
||||
if _, dup := registry[service]; dup {
|
||||
panic("services: duplicate registration for " + service)
|
||||
}
|
||||
registry[service] = b
|
||||
}
|
||||
|
||||
// Build returns the table for service, or an error if no builder exists.
|
||||
func Build(service string, sup *proc.Supervisor) (*commands.Table, error) {
|
||||
b, ok := registry[service]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: unknown service %q (set MAILCOW_AGENT_SERVICE correctly)", service)
|
||||
}
|
||||
return b(sup), nil
|
||||
}
|
||||
|
||||
// Known returns the list of registered service names (sorted-ish, depends on
|
||||
// map iteration — for help output only).
|
||||
func Known() []string {
|
||||
out := make([]string, 0, len(registry))
|
||||
for k := range registry {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// restartSettle is how long we wait after a Start to verify the new child
|
||||
// didn't immediately crash. Gives the operator real "did the service come
|
||||
// back up?" feedback instead of an instant OK that hides flapping services.
|
||||
const restartSettle = 3 * time.Second
|
||||
|
||||
// addLifecycle wires reload/restart/stop/start onto t backed by sup. Services
|
||||
// override these (e.g. postfix overrides reload to run `postfix reload`).
|
||||
func addLifecycle(t *commands.Table, sup *proc.Supervisor) {
|
||||
if sup == nil {
|
||||
return
|
||||
}
|
||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Reload()
|
||||
})
|
||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
if err := sup.Restart(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sup.WaitStable(ctx, restartSettle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"status": "restarted", "settled_ms": int(restartSettle / time.Millisecond)}, nil
|
||||
})
|
||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Stop(ctx)
|
||||
})
|
||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
if err := sup.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sup.WaitStable(ctx, restartSettle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"status": "started", "settled_ms": int(restartSettle / time.Millisecond)}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("sogo", buildSogo) }
|
||||
|
||||
func sogoHealthProbe(ctx context.Context) error {
|
||||
return probeHTTP(ctx, "http://127.0.0.1:20000/SOGo.index/", 3*time.Second)
|
||||
}
|
||||
|
||||
func buildSogo(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("sogo")
|
||||
t.HealthProbe = sogoHealthProbe
|
||||
addLifecycle(t, sup)
|
||||
|
||||
t.Register("exec.rename-user", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
oldName, err := commands.ArgString(args, "old")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newName, err := commands.ArgString(args, "new")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "sogo-tool", "rename-user", oldName, newName)
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("unbound", buildUnbound) }
|
||||
|
||||
func unboundHealthProbe(ctx context.Context) error {
|
||||
return probeShell(ctx, 3*time.Second, "dig", "+time=2", "+tries=1", "@127.0.0.1", "mailcow.email", "A")
|
||||
}
|
||||
|
||||
func buildUnbound(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("unbound")
|
||||
t.HealthProbe = unboundHealthProbe
|
||||
addLifecycle(t, sup)
|
||||
t.Register("exec.flush-cache", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "unbound-control", "flush_zone", ".")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package stats reads cgroup CPU + memory usage and publishes them to
|
||||
//
|
||||
// HASH mailcow.stats.<service>.<node_id>
|
||||
//
|
||||
// with a 30s TTL. Supports both cgroup v1 and v2. The numbers are intentionally
|
||||
// approximate — they replace what dockerapi exposed via /containers/<id>/stats.
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Sample is one observation. CPUPercent is the share of one host CPU consumed
|
||||
// since the previous sample (range 0..100*numCPU).
|
||||
type Sample struct {
|
||||
CPUPercent float64
|
||||
MemoryBytes int64
|
||||
MemoryLimit int64
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func statsKey(service, node string) string { return "mailcow.stats." + service + "." + node }
|
||||
|
||||
// Publisher reads cgroup metrics and pushes them to Redis on a ticker.
|
||||
type Publisher struct {
|
||||
rdb *redis.Client
|
||||
service string
|
||||
node string
|
||||
|
||||
// previous CPU sample to derive a delta-based percent
|
||||
prevCPUNanos int64
|
||||
prevAt time.Time
|
||||
}
|
||||
|
||||
// NewPublisher constructs a publisher. Caller drives it via Run.
|
||||
func NewPublisher(rdb *redis.Client, service, node string) *Publisher {
|
||||
return &Publisher{rdb: rdb, service: service, node: node}
|
||||
}
|
||||
|
||||
// Run blocks on a ticker until ctx is done.
|
||||
func (p *Publisher) Run(ctx context.Context, interval time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
// Prime the CPU sample so the first publish has a real delta.
|
||||
if cpu, ok := readCPUNanos(); ok {
|
||||
p.prevCPUNanos = cpu
|
||||
p.prevAt = time.Now()
|
||||
}
|
||||
// Immediate first publish so the dashboard never sees a node without a
|
||||
// stats hash. CPU is 0 in this first sample (no prev delta yet); memory
|
||||
// is already accurate.
|
||||
_ = p.publish(ctx, p.sample())
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = p.publish(ctx, p.sample())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Publisher) sample() Sample {
|
||||
s := Sample{Timestamp: time.Now()}
|
||||
if cpu, ok := readCPUNanos(); ok {
|
||||
if !p.prevAt.IsZero() {
|
||||
dCPU := cpu - p.prevCPUNanos
|
||||
dT := s.Timestamp.Sub(p.prevAt).Nanoseconds()
|
||||
if dT > 0 && dCPU >= 0 {
|
||||
s.CPUPercent = (float64(dCPU) / float64(dT)) * 100.0
|
||||
}
|
||||
}
|
||||
p.prevCPUNanos = cpu
|
||||
p.prevAt = s.Timestamp
|
||||
}
|
||||
if mem, limit, ok := readMemory(); ok {
|
||||
s.MemoryBytes = mem
|
||||
s.MemoryLimit = limit
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Publisher) publish(ctx context.Context, s Sample) error {
|
||||
pipe := p.rdb.Pipeline()
|
||||
pipe.HSet(ctx, statsKey(p.service, p.node), map[string]any{
|
||||
"cpu_percent": strconv.FormatFloat(s.CPUPercent, 'f', 2, 64),
|
||||
"memory_bytes": s.MemoryBytes,
|
||||
"memory_limit": s.MemoryLimit,
|
||||
"timestamp": s.Timestamp.Unix(),
|
||||
"node_id": p.node,
|
||||
"service": p.service,
|
||||
})
|
||||
pipe.Expire(ctx, statsKey(p.service, p.node), 30*time.Second)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- cgroup readers --------------------------------------------------------
|
||||
|
||||
// readCPUNanos returns total CPU-nanoseconds consumed by the current cgroup,
|
||||
// summed across all CPUs. Works for both cgroup v2 (cpu.stat) and v1
|
||||
// (cpuacct.usage).
|
||||
func readCPUNanos() (int64, bool) {
|
||||
if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil {
|
||||
// v2: lines like "usage_usec 12345"
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "usage_usec ") {
|
||||
n, err := strconv.ParseInt(strings.TrimPrefix(line, "usage_usec "), 10, 64)
|
||||
if err == nil {
|
||||
return n * 1000, true // µs → ns
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile("/sys/fs/cgroup/cpuacct/cpuacct.usage"); err == nil {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
if err == nil {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// readMemory returns current usage and limit in bytes.
|
||||
func readMemory() (int64, int64, bool) {
|
||||
// v2
|
||||
if cur, err := readInt("/sys/fs/cgroup/memory.current"); err == nil {
|
||||
limit, _ := readInt("/sys/fs/cgroup/memory.max")
|
||||
return cur, limit, true
|
||||
}
|
||||
// v1
|
||||
if cur, err := readInt("/sys/fs/cgroup/memory/memory.usage_in_bytes"); err == nil {
|
||||
limit, _ := readInt("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
||||
return cur, limit, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func readInt(path string) (int64, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "max" {
|
||||
return -1, nil
|
||||
}
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# mailcow-agent-cli — publish a control-bus command from inside a service
|
||||
# container, optionally collecting one reply. Same wire protocol as the Go
|
||||
# agent (see internal/envelope/envelope.go).
|
||||
#
|
||||
# Usage:
|
||||
# mailcow-agent-cli send <service> <cmd> [json-args]
|
||||
# Fire-and-forget. Prints the number of subscribers reached.
|
||||
# mailcow-agent-cli call <service> <cmd> [json-args] [timeout-seconds]
|
||||
# Publish + wait for one reply on its private reply list. Prints the
|
||||
# reply envelope JSON on stdout.
|
||||
#
|
||||
# Requires the `redis-cli` binary to be present in the calling container.
|
||||
|
||||
set -e
|
||||
|
||||
op="${1:-}"
|
||||
svc="${2:-}"
|
||||
cmd="${3:-}"
|
||||
args="${4:-{\}}"
|
||||
tmo="${5:-10}"
|
||||
|
||||
if [ -z "$op" ] || [ -z "$svc" ] || [ -z "$cmd" ]; then
|
||||
echo "usage: $0 send|call <service> <cmd> [json-args] [timeout-seconds]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
redis_host="${REDIS_SLAVEOF_IP:-redis-mailcow}"
|
||||
redis_port="${REDIS_SLAVEOF_PORT:-6379}"
|
||||
|
||||
rcli() {
|
||||
if [ -n "${REDISPASS:-}" ]; then
|
||||
redis-cli -h "$redis_host" -p "$redis_port" -a "$REDISPASS" --no-auth-warning "$@"
|
||||
else
|
||||
redis-cli -h "$redis_host" -p "$redis_port" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
rid="$(date +%s%N)$$"
|
||||
issued_by="$(hostname 2>/dev/null || echo unknown)"
|
||||
|
||||
case "$op" in
|
||||
send)
|
||||
payload="{\"cmd\":\"${cmd}\",\"request_id\":\"${rid}\",\"args\":${args},\"issued_by\":\"${issued_by}\"}"
|
||||
rcli PUBLISH "mailcow.control.${svc}" "$payload"
|
||||
;;
|
||||
call)
|
||||
reply="mailcow.reply.${rid}"
|
||||
payload="{\"cmd\":\"${cmd}\",\"request_id\":\"${rid}\",\"args\":${args},\"reply_to\":\"${reply}\",\"issued_by\":\"${issued_by}\"}"
|
||||
rcli PUBLISH "mailcow.control.${svc}" "$payload" >/dev/null
|
||||
# BLPOP returns two lines: the list name then the value. Print only the value.
|
||||
rcli BLPOP "$reply" "$tmo" 2>/dev/null | tail -n1
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 send|call <service> <cmd> [json-args] [timeout-seconds]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
@@ -41,7 +45,7 @@ RUN wget -P /src https://www.clamav.net/downloads/production/clamav-${CLAMD_VERS
|
||||
-D ENABLE_MILTER=ON \
|
||||
-D ENABLE_MAN_PAGES=OFF \
|
||||
-D ENABLE_STATIC_LIB=OFF \
|
||||
-D ENABLE_JSON_SHARED=ON \
|
||||
-D ENABLE_JSON_SHARED=ON \
|
||||
&& cmake --build . \
|
||||
&& make DESTDIR="/clamav" -j$(($(nproc) - 1)) install \
|
||||
&& rm -r "/clamav/usr/lib/pkgconfig/" \
|
||||
@@ -104,7 +108,15 @@ COPY healthcheck.sh /healthcheck.sh
|
||||
COPY clamdcheck.sh /usr/local/bin
|
||||
RUN chmod +x /healthcheck.sh
|
||||
RUN chmod +x /usr/local/bin/clamdcheck.sh
|
||||
HEALTHCHECK --start-period=6m CMD "/healthcheck.sh"
|
||||
|
||||
ENTRYPOINT []
|
||||
CMD ["/sbin/tini", "-g", "--", "/clamd.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=clamd \
|
||||
MAILCOW_AGENT_MAIN_CMD="/sbin/tini -g -- /clamd.sh"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
@@ -1,34 +0,0 @@
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --update --no-cache python3 \
|
||||
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"]
|
||||
@@ -1,261 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import uvicorn
|
||||
import json
|
||||
import uuid
|
||||
import async_timeout
|
||||
import asyncio
|
||||
import aiodocker
|
||||
import docker
|
||||
import logging
|
||||
from logging.config import dictConfig
|
||||
from fastapi import FastAPI, Response, Request
|
||||
from modules.DockerApi import DockerApi
|
||||
from redis import asyncio as aioredis
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
dockerapi = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global dockerapi
|
||||
|
||||
# Initialize a custom logger
|
||||
logger = logging.getLogger("dockerapi")
|
||||
logger.setLevel(logging.INFO)
|
||||
# Configure the logger to output logs to the terminal
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter("%(levelname)s: %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger.info("Init APP")
|
||||
|
||||
# Init redis client
|
||||
if os.environ['REDIS_SLAVEOF_IP'] != "":
|
||||
redis_client = redis = await aioredis.from_url(f"redis://{os.environ['REDIS_SLAVEOF_IP']}:{os.environ['REDIS_SLAVEOF_PORT']}/0", password=os.environ['REDISPASS'])
|
||||
else:
|
||||
redis_client = redis = await aioredis.from_url("redis://redis-mailcow:6379/0", password=os.environ['REDISPASS'])
|
||||
|
||||
# Init docker clients
|
||||
sync_docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
|
||||
async_docker_client = aiodocker.Docker(url='unix:///var/run/docker.sock')
|
||||
|
||||
dockerapi = DockerApi(redis_client, sync_docker_client, async_docker_client, logger)
|
||||
|
||||
logger.info("Subscribe to redis channel")
|
||||
# Subscribe to redis channel
|
||||
dockerapi.pubsub = redis.pubsub()
|
||||
await dockerapi.pubsub.subscribe("MC_CHANNEL")
|
||||
asyncio.create_task(handle_pubsub_messages(dockerapi.pubsub))
|
||||
|
||||
|
||||
yield
|
||||
|
||||
# Close docker connections
|
||||
dockerapi.sync_docker_client.close()
|
||||
await dockerapi.async_docker_client.close()
|
||||
|
||||
# Close redis
|
||||
await dockerapi.pubsub.unsubscribe("MC_CHANNEL")
|
||||
await dockerapi.redis_client.close()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Define Routes
|
||||
@app.get("/host/stats")
|
||||
async def get_host_update_stats():
|
||||
global dockerapi
|
||||
|
||||
if dockerapi.host_stats_isUpdating == False:
|
||||
asyncio.create_task(dockerapi.get_host_stats())
|
||||
dockerapi.host_stats_isUpdating = True
|
||||
|
||||
while True:
|
||||
if await dockerapi.redis_client.exists('host_stats'):
|
||||
break
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
stats = json.loads(await dockerapi.redis_client.get('host_stats'))
|
||||
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
|
||||
|
||||
@app.get("/containers/{container_id}/json")
|
||||
async def get_container(container_id : str):
|
||||
global dockerapi
|
||||
|
||||
if container_id and container_id.isalnum():
|
||||
try:
|
||||
for container in (await dockerapi.async_docker_client.containers.list()):
|
||||
if container._id == container_id:
|
||||
container_info = await container.show()
|
||||
return Response(content=json.dumps(container_info, indent=4), media_type="application/json")
|
||||
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "no container found"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
except Exception as e:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "no or invalid id defined"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
@app.get("/containers/json")
|
||||
async def get_containers():
|
||||
global dockerapi
|
||||
|
||||
containers = {}
|
||||
try:
|
||||
for container in (await dockerapi.async_docker_client.containers.list()):
|
||||
container_info = await container.show()
|
||||
containers.update({container_info['Id']: container_info})
|
||||
return Response(content=json.dumps(containers, indent=4), media_type="application/json")
|
||||
except Exception as e:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
@app.post("/containers/{container_id}/{post_action}")
|
||||
async def post_containers(container_id : str, post_action : str, request: Request):
|
||||
global dockerapi
|
||||
|
||||
try:
|
||||
request_json = await request.json()
|
||||
except Exception as err:
|
||||
request_json = {}
|
||||
|
||||
if container_id and container_id.isalnum() and post_action:
|
||||
try:
|
||||
"""Dispatch container_post api call"""
|
||||
if post_action == 'exec':
|
||||
if not request_json or not 'cmd' in request_json:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "cmd is missing"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
if not request_json or not 'task' in request_json:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "task is missing"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
api_call_method_name = '__'.join(['container_post', str(post_action), str(request_json['cmd']), str(request_json['task']) ])
|
||||
else:
|
||||
api_call_method_name = '__'.join(['container_post', str(post_action) ])
|
||||
|
||||
api_call_method = getattr(dockerapi, api_call_method_name, lambda container_id: Response(content=json.dumps({'type': 'danger', 'msg':'container_post - unknown api call' }, indent=4), media_type="application/json"))
|
||||
|
||||
dockerapi.logger.info("api call: %s, container_id: %s" % (api_call_method_name, container_id))
|
||||
return api_call_method(request_json, container_id=container_id)
|
||||
except Exception as e:
|
||||
dockerapi.logger.error("error - container_post: %s" % str(e))
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
else:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "invalid container id or missing action"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
@app.post("/container/{container_id}/stats/update")
|
||||
async def post_container_update_stats(container_id : str):
|
||||
global dockerapi
|
||||
|
||||
# start update task for container if no task is running
|
||||
if container_id not in dockerapi.containerIds_to_update:
|
||||
asyncio.create_task(dockerapi.get_container_stats(container_id))
|
||||
dockerapi.containerIds_to_update.append(container_id)
|
||||
|
||||
while True:
|
||||
if await dockerapi.redis_client.exists(container_id + '_stats'):
|
||||
break
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
stats = json.loads(await dockerapi.redis_client.get(container_id + '_stats'))
|
||||
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
|
||||
|
||||
|
||||
# PubSub Handler
|
||||
async def handle_pubsub_messages(channel: aioredis.client.PubSub):
|
||||
global dockerapi
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with async_timeout.timeout(60):
|
||||
message = await channel.get_message(ignore_subscribe_messages=True, timeout=30)
|
||||
if message is not None:
|
||||
# Parse message
|
||||
data_json = json.loads(message['data'].decode('utf-8'))
|
||||
dockerapi.logger.info(f"PubSub Received - {json.dumps(data_json)}")
|
||||
|
||||
# Handle api_call
|
||||
if 'api_call' in data_json:
|
||||
# api_call: container_post
|
||||
if data_json['api_call'] == "container_post":
|
||||
if 'post_action' in data_json and 'container_name' in data_json:
|
||||
try:
|
||||
"""Dispatch container_post api call"""
|
||||
request_json = {}
|
||||
if data_json['post_action'] == 'exec':
|
||||
if 'request' in data_json:
|
||||
request_json = data_json['request']
|
||||
if 'cmd' in request_json:
|
||||
if 'task' in request_json:
|
||||
api_call_method_name = '__'.join(['container_post', str(data_json['post_action']), str(request_json['cmd']), str(request_json['task']) ])
|
||||
else:
|
||||
dockerapi.logger.error("api call: task missing")
|
||||
else:
|
||||
dockerapi.logger.error("api call: cmd missing")
|
||||
else:
|
||||
dockerapi.logger.error("api call: request missing")
|
||||
else:
|
||||
api_call_method_name = '__'.join(['container_post', str(data_json['post_action'])])
|
||||
|
||||
if api_call_method_name:
|
||||
api_call_method = getattr(dockerapi, api_call_method_name)
|
||||
if api_call_method:
|
||||
dockerapi.logger.info("api call: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
|
||||
api_call_method(request_json, container_name=data_json['container_name'])
|
||||
else:
|
||||
dockerapi.logger.error("api call not found: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
|
||||
except Exception as e:
|
||||
dockerapi.logger.error("container_post: %s" % str(e))
|
||||
else:
|
||||
dockerapi.logger.error("api call: missing container_name, post_action or request")
|
||||
else:
|
||||
dockerapi.logger.error("Unknown PubSub received - %s" % json.dumps(data_json))
|
||||
else:
|
||||
dockerapi.logger.error("Unknown PubSub received - %s" % json.dumps(data_json))
|
||||
|
||||
await asyncio.sleep(0.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=443,
|
||||
ssl_certfile="/app/controller_cert.pem",
|
||||
ssl_keyfile="/app/controller_key.pem",
|
||||
log_level="info",
|
||||
loop="none"
|
||||
)
|
||||
@@ -1,626 +0,0 @@
|
||||
import psutil
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import asyncio
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI, Response, Request
|
||||
|
||||
class DockerApi:
|
||||
def __init__(self, redis_client, sync_docker_client, async_docker_client, logger):
|
||||
self.redis_client = redis_client
|
||||
self.sync_docker_client = sync_docker_client
|
||||
self.async_docker_client = async_docker_client
|
||||
self.logger = logger
|
||||
|
||||
self.host_stats_isUpdating = False
|
||||
self.containerIds_to_update = []
|
||||
|
||||
# api call: container_post - post_action: stop
|
||||
def container_post__stop(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
container.stop()
|
||||
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: start
|
||||
def container_post__start(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
container.start()
|
||||
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: restart
|
||||
def container_post__restart(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
container.restart()
|
||||
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: top
|
||||
def container_post__top(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
res = { 'type': 'success', 'msg': container.top()}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: stats
|
||||
def container_post__stats(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
for stat in container.stats(decode=True, stream=True):
|
||||
res = { 'type': 'success', 'msg': stat}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: delete
|
||||
def container_post__exec__mailq__delete(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
flagged_qids = ['-d %s' % i for i in filtered_qids]
|
||||
sanitized_string = str(' '.join(flagged_qids))
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||
return self.exec_run_handler('generic', postsuper_r)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: hold
|
||||
def container_post__exec__mailq__hold(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
flagged_qids = ['-h %s' % i for i in filtered_qids]
|
||||
sanitized_string = str(' '.join(flagged_qids))
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||
return self.exec_run_handler('generic', postsuper_r)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: cat
|
||||
def container_post__exec__mailq__cat(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
sanitized_string = str(' '.join(filtered_qids))
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postcat_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postcat -q " + sanitized_string], user='postfix')
|
||||
if not postcat_return:
|
||||
postcat_return = 'err: invalid'
|
||||
return self.exec_run_handler('utf8_text_only', postcat_return)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: unhold
|
||||
def container_post__exec__mailq__unhold(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
flagged_qids = ['-H %s' % i for i in filtered_qids]
|
||||
sanitized_string = str(' '.join(flagged_qids))
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||
return self.exec_run_handler('generic', postsuper_r)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: deliver
|
||||
def container_post__exec__mailq__deliver(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
flagged_qids = ['-i %s' % i for i in filtered_qids]
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
for i in flagged_qids:
|
||||
postqueue_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postqueue " + i], user='postfix')
|
||||
# todo: check each exit code
|
||||
res = { 'type': 'success', 'msg': 'Scheduled immediate delivery'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: list
|
||||
def container_post__exec__mailq__list(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
mailq_return = container.exec_run(["/usr/sbin/postqueue", "-j"], user='postfix')
|
||||
return self.exec_run_handler('utf8_text_only', mailq_return)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: flush
|
||||
def container_post__exec__mailq__flush(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postqueue_r = container.exec_run(["/usr/sbin/postqueue", "-f"], user='postfix')
|
||||
return self.exec_run_handler('generic', postqueue_r)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: super_delete
|
||||
def container_post__exec__mailq__super_delete(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postsuper_r = container.exec_run(["/usr/sbin/postsuper", "-d", "ALL"])
|
||||
return self.exec_run_handler('generic', postsuper_r)
|
||||
# api call: container_post - post_action: exec - cmd: system - task: fts_rescan
|
||||
def container_post__exec__system__fts_rescan(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'username' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -u '" + request_json['username'].replace("'", "'\\''") + "'"], user='vmail')
|
||||
if rescan_return.exit_code == 0:
|
||||
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
if 'all' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -A"], user='vmail')
|
||||
if rescan_return.exit_code == 0:
|
||||
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: system - task: df
|
||||
def container_post__exec__system__df(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'dir' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
df_return = container.exec_run(["/bin/bash", "-c", "/bin/df -H '" + request_json['dir'].replace("'", "'\\''") + "' | /usr/bin/tail -n1 | /usr/bin/tr -s [:blank:] | /usr/bin/tr ' ' ','"], user='nobody')
|
||||
if df_return.exit_code == 0:
|
||||
return df_return.output.decode('utf-8').rstrip()
|
||||
else:
|
||||
return "0,0,0,0,0,0"
|
||||
# api call: container_post - post_action: exec - cmd: system - task: mysql_upgrade
|
||||
def container_post__exec__system__mysql_upgrade(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_upgrade -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "'\n"], user='mysql')
|
||||
if sql_return.exit_code == 0:
|
||||
matched = False
|
||||
for line in sql_return.output.decode('utf-8').split("\n"):
|
||||
if 'is already upgraded to' in line:
|
||||
matched = True
|
||||
if matched:
|
||||
res = { 'type': 'success', 'msg':'mysql_upgrade: already upgraded', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
container.restart()
|
||||
res = { 'type': 'warning', 'msg':'mysql_upgrade: upgrade was applied', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'error', 'msg': 'mysql_upgrade: error running command', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
|
||||
def container_post__exec__system__mysql_tzinfo_to_sql(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_tzinfo_to_sql /usr/share/zoneinfo | /bin/sed 's/Local time zone must be set--see zic manual page/FCTY/' | /usr/bin/mysql -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "' mysql \n"], user='mysql')
|
||||
if sql_return.exit_code == 0:
|
||||
res = { 'type': 'info', 'msg': 'mysql_tzinfo_to_sql: command completed successfully', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'error', 'msg': 'mysql_tzinfo_to_sql: error running command', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: reload - task: dovecot
|
||||
def container_post__exec__reload__dovecot(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
|
||||
return self.exec_run_handler('generic', reload_return)
|
||||
# api call: container_post - post_action: exec - cmd: reload - task: postfix
|
||||
def container_post__exec__reload__postfix(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
|
||||
return self.exec_run_handler('generic', reload_return)
|
||||
# api call: container_post - post_action: exec - cmd: reload - task: nginx
|
||||
def container_post__exec__reload__nginx(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
|
||||
return self.exec_run_handler('generic', reload_return)
|
||||
# api call: container_post - post_action: exec - cmd: sieve - task: list
|
||||
def container_post__exec__sieve__list(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'username' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request_json['username'].replace("'", "'\\''") + "'"])
|
||||
return self.exec_run_handler('utf8_text_only', sieve_return)
|
||||
# api call: container_post - post_action: exec - cmd: sieve - task: print
|
||||
def container_post__exec__sieve__print(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'username' in request_json and 'script_name' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]
|
||||
sieve_return = container.exec_run(cmd)
|
||||
return self.exec_run_handler('utf8_text_only', sieve_return)
|
||||
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
|
||||
def container_post__exec__maildir__cleanup(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'maildir' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
sane_name = re.sub(r'\W+', '', request_json['maildir'])
|
||||
vmail_name = request_json['maildir'].replace("'", "'\\''")
|
||||
cmd_vmail = "if [[ -d '/var/vmail/" + vmail_name + "' ]]; then /bin/mv '/var/vmail/" + vmail_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "'; fi"
|
||||
index_name = request_json['maildir'].split("/")
|
||||
if len(index_name) > 1:
|
||||
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
|
||||
cmd_vmail_index = "if [[ -d '/var/vmail_index/" + index_name + "' ]]; then /bin/mv '/var/vmail_index/" + index_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "_index'; fi"
|
||||
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
|
||||
else:
|
||||
cmd = ["/bin/bash", "-c", cmd_vmail]
|
||||
maildir_cleanup = container.exec_run(cmd, user='vmail')
|
||||
return self.exec_run_handler('generic', maildir_cleanup)
|
||||
# api call: container_post - post_action: exec - cmd: maildir - task: move
|
||||
def container_post__exec__maildir__move(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'old_maildir' in request_json and 'new_maildir' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
vmail_name = request_json['old_maildir'].replace("'", "'\\''")
|
||||
new_vmail_name = request_json['new_maildir'].replace("'", "'\\''")
|
||||
cmd_vmail = f"if [[ -d '/var/vmail/{vmail_name}' ]]; then /bin/mv '/var/vmail/{vmail_name}' '/var/vmail/{new_vmail_name}'; fi"
|
||||
|
||||
index_name = request_json['old_maildir'].split("/")
|
||||
new_index_name = request_json['new_maildir'].split("/")
|
||||
if len(index_name) > 1 and len(new_index_name) > 1:
|
||||
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
|
||||
new_index_name = new_index_name[1].replace("'", "'\\''") + "@" + new_index_name[0].replace("'", "'\\''")
|
||||
cmd_vmail_index = f"if [[ -d '/var/vmail_index/{index_name}' ]]; then /bin/mv '/var/vmail_index/{index_name}' '/var/vmail_index/{new_index_name}_index'; fi"
|
||||
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
|
||||
else:
|
||||
cmd = ["/bin/bash", "-c", cmd_vmail]
|
||||
maildir_move = container.exec_run(cmd, user='vmail')
|
||||
return self.exec_run_handler('generic', maildir_move)
|
||||
# api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
|
||||
def container_post__exec__rspamd__worker_password(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'raw' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
cmd = "/usr/bin/rspamadm pw -e -p '" + request_json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
|
||||
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
|
||||
|
||||
matched = False
|
||||
for line in cmd_response.split("\n"):
|
||||
if '$2$' in line:
|
||||
hash = line.strip()
|
||||
hash_out = re.search(r'\$2\$.+$', hash).group(0)
|
||||
rspamd_passphrase_hash = re.sub(r'[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
|
||||
rspamd_password_filename = "/etc/rspamd/override.d/worker-controller-password.inc"
|
||||
cmd = '''/bin/echo 'enable_password = "%s";' > %s && cat %s''' % (rspamd_passphrase_hash, rspamd_password_filename, rspamd_password_filename)
|
||||
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
|
||||
if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
|
||||
container.restart()
|
||||
matched = True
|
||||
if matched:
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully' }
|
||||
self.logger.info('success changing Rspamd password')
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
self.logger.error('failed changing Rspamd password')
|
||||
res = { 'type': 'danger', 'msg': 'command did not complete' }
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: sogo - task: rename
|
||||
def container_post__exec__sogo__rename_user(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'old_username' in request_json and 'new_username' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
old_username = request_json['old_username'].replace("'", "'\\''")
|
||||
new_username = request_json['new_username'].replace("'", "'\\''")
|
||||
|
||||
sogo_return = container.exec_run(["/bin/bash", "-c", f"sogo-tool rename-user '{old_username}' '{new_username}'"], user='sogo')
|
||||
return self.exec_run_handler('generic', sogo_return)
|
||||
# api call: container_post - post_action: exec - cmd: doveadm - task: get_acl
|
||||
def container_post__exec__doveadm__get_acl(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
id = request_json['id'].replace("'", "'\\''")
|
||||
|
||||
shared_folders = container.exec_run(["/bin/bash", "-c", f"doveadm mailbox list -u '{id}'"])
|
||||
shared_folders = shared_folders.output.decode('utf-8')
|
||||
shared_folders = shared_folders.splitlines()
|
||||
|
||||
formatted_acls = []
|
||||
mailbox_seen = []
|
||||
for shared_folder in shared_folders:
|
||||
if "Shared" not in shared_folder:
|
||||
mailbox = shared_folder.replace("'", "'\\''")
|
||||
if mailbox in mailbox_seen:
|
||||
continue
|
||||
|
||||
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"])
|
||||
acls = acls.output.decode('utf-8').strip().splitlines()
|
||||
if len(acls) >= 2:
|
||||
for acl in acls[1:]:
|
||||
user_id, rights = acl.split(maxsplit=1)
|
||||
user_id = user_id.split('=')[1]
|
||||
mailbox_seen.append(mailbox)
|
||||
formatted_acls.append({ 'user': id, 'id': user_id, 'mailbox': mailbox, 'rights': rights.split() })
|
||||
elif "Shared" in shared_folder and "/" in shared_folder:
|
||||
shared_folder = shared_folder.split("/")
|
||||
if len(shared_folder) < 3:
|
||||
continue
|
||||
|
||||
user = shared_folder[1].replace("'", "'\\''")
|
||||
mailbox = '/'.join(shared_folder[2:]).replace("'", "'\\''")
|
||||
if mailbox in mailbox_seen:
|
||||
continue
|
||||
|
||||
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"])
|
||||
acls = acls.output.decode('utf-8').strip().splitlines()
|
||||
if len(acls) >= 2:
|
||||
for acl in acls[1:]:
|
||||
user_id, rights = acl.split(maxsplit=1)
|
||||
user_id = user_id.split('=')[1].replace("'", "'\\''")
|
||||
if user_id == id and mailbox not in mailbox_seen:
|
||||
mailbox_seen.append(mailbox)
|
||||
formatted_acls.append({ 'user': user, 'id': id, 'mailbox': mailbox, 'rights': rights.split() })
|
||||
|
||||
return Response(content=json.dumps(formatted_acls, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: doveadm - task: delete_acl
|
||||
def container_post__exec__doveadm__delete_acl(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
user = request_json['user'].replace("'", "'\\''")
|
||||
mailbox = request_json['mailbox'].replace("'", "'\\''")
|
||||
id = request_json['id'].replace("'", "'\\''")
|
||||
|
||||
if user and mailbox and id:
|
||||
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' 'user={id}'"])
|
||||
return self.exec_run_handler('generic', acl_delete_return)
|
||||
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
|
||||
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
user = request_json['user'].replace("'", "'\\''")
|
||||
mailbox = request_json['mailbox'].replace("'", "'\\''")
|
||||
id = request_json['id'].replace("'", "'\\''")
|
||||
rights = ""
|
||||
|
||||
available_rights = [
|
||||
"admin",
|
||||
"create",
|
||||
"delete",
|
||||
"expunge",
|
||||
"insert",
|
||||
"lookup",
|
||||
"post",
|
||||
"read",
|
||||
"write",
|
||||
"write-deleted",
|
||||
"write-seen"
|
||||
]
|
||||
for right in request_json['rights']:
|
||||
right = right.replace("'", "'\\''").lower()
|
||||
if right in available_rights:
|
||||
rights += right + " "
|
||||
|
||||
if user and mailbox and id and rights:
|
||||
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' 'user={id}' {rights}"])
|
||||
return self.exec_run_handler('generic', acl_set_return)
|
||||
|
||||
|
||||
# Collect host stats
|
||||
async def get_host_stats(self, wait=5):
|
||||
try:
|
||||
system_time = datetime.now()
|
||||
host_stats = {
|
||||
"cpu": {
|
||||
"cores": psutil.cpu_count(),
|
||||
"usage": psutil.cpu_percent()
|
||||
},
|
||||
"memory": {
|
||||
"total": psutil.virtual_memory().total,
|
||||
"usage": psutil.virtual_memory().percent,
|
||||
"swap": psutil.swap_memory()
|
||||
},
|
||||
"uptime": time.time() - psutil.boot_time(),
|
||||
"system_time": system_time.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
"architecture": platform.machine()
|
||||
}
|
||||
|
||||
await self.redis_client.set('host_stats', json.dumps(host_stats), ex=10)
|
||||
except Exception as e:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
|
||||
await asyncio.sleep(wait)
|
||||
self.host_stats_isUpdating = False
|
||||
# Collect container stats
|
||||
async def get_container_stats(self, container_id, wait=5, stop=False):
|
||||
if container_id and container_id.isalnum():
|
||||
try:
|
||||
for container in (await self.async_docker_client.containers.list()):
|
||||
if container._id == container_id:
|
||||
res = await container.stats(stream=False)
|
||||
|
||||
if await self.redis_client.exists(container_id + '_stats'):
|
||||
stats = json.loads(await self.redis_client.get(container_id + '_stats'))
|
||||
else:
|
||||
stats = []
|
||||
stats.append(res[0])
|
||||
if len(stats) > 3:
|
||||
del stats[0]
|
||||
await self.redis_client.set(container_id + '_stats', json.dumps(stats), ex=60)
|
||||
except Exception as e:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
else:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "no or invalid id defined"
|
||||
}
|
||||
|
||||
await asyncio.sleep(wait)
|
||||
if stop == True:
|
||||
# update task was called second time, stop
|
||||
self.containerIds_to_update.remove(container_id)
|
||||
else:
|
||||
# call update task a second time
|
||||
await self.get_container_stats(container_id, wait=0, stop=True)
|
||||
|
||||
def exec_cmd_container(self, container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
|
||||
def recv_socket_data(c_socket, timeout):
|
||||
c_socket.setblocking(0)
|
||||
total_data=[]
|
||||
data=''
|
||||
begin=time.time()
|
||||
while True:
|
||||
if total_data and time.time()-begin > timeout:
|
||||
break
|
||||
elif time.time()-begin > timeout*2:
|
||||
break
|
||||
try:
|
||||
data = c_socket.recv(8192)
|
||||
if data:
|
||||
total_data.append(data.decode('utf-8'))
|
||||
#change the beginning time for measurement
|
||||
begin=time.time()
|
||||
else:
|
||||
#sleep for sometime to indicate a gap
|
||||
time.sleep(0.1)
|
||||
break
|
||||
except:
|
||||
pass
|
||||
return ''.join(total_data)
|
||||
|
||||
try :
|
||||
socket = container.exec_run([shell_cmd], stdin=True, socket=True, user=user).output._sock
|
||||
if not cmd.endswith("\n"):
|
||||
cmd = cmd + "\n"
|
||||
socket.send(cmd.encode('utf-8'))
|
||||
data = recv_socket_data(socket, timeout)
|
||||
socket.close()
|
||||
return data
|
||||
except Exception as e:
|
||||
self.logger.error("error - exec_cmd_container: %s" % str(e))
|
||||
traceback.print_exc(file=sys.stdout)
|
||||
|
||||
def exec_run_handler(self, type, output):
|
||||
if type == 'generic':
|
||||
if output.exit_code == 0:
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully' }
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'danger', 'msg': 'command failed: ' + output.output.decode('utf-8') }
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
if type == 'utf8_text_only':
|
||||
return Response(content=output.output.decode('utf-8'), media_type="text/plain")
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,140 +0,0 @@
|
||||
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)")
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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)")
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
@@ -1,111 +0,0 @@
|
||||
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")
|
||||
@@ -1,162 +0,0 @@
|
||||
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")
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
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")
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
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")
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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")
|
||||
@@ -1,62 +0,0 @@
|
||||
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)")
|
||||
@@ -1,45 +0,0 @@
|
||||
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")
|
||||
@@ -1,221 +0,0 @@
|
||||
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")
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
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"
|
||||
@@ -1,457 +0,0 @@
|
||||
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."
|
||||
@@ -1,64 +0,0 @@
|
||||
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()
|
||||
@@ -1,51 +0,0 @@
|
||||
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
|
||||
@@ -1,512 +0,0 @@
|
||||
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
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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)
|
||||
@@ -1,4 +0,0 @@
|
||||
jinja2
|
||||
requests
|
||||
mysql-connector-python
|
||||
pytest
|
||||
@@ -1,94 +0,0 @@
|
||||
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!")
|
||||
@@ -1,71 +0,0 @@
|
||||
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
|
||||
@@ -1,74 +0,0 @@
|
||||
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!")
|
||||
@@ -1,89 +0,0 @@
|
||||
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!")
|
||||
@@ -1,89 +0,0 @@
|
||||
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!")
|
||||
@@ -1,39 +0,0 @@
|
||||
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!")
|
||||
@@ -1,106 +0,0 @@
|
||||
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!")
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
printf "READY\n";
|
||||
|
||||
while read line; do
|
||||
echo "Processing Event: $line" >&2;
|
||||
kill -3 $(cat "/var/run/supervisord.pid")
|
||||
done < /dev/stdin
|
||||
@@ -1,17 +0,0 @@
|
||||
[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,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
@@ -135,5 +139,14 @@ COPY quota_notify.py /usr/local/bin/quota_notify.py
|
||||
COPY repl_health.sh /usr/local/bin/repl_health.sh
|
||||
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=dovecot \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -47,7 +47,7 @@ try:
|
||||
if max_score == "":
|
||||
max_score = 9999.0
|
||||
|
||||
def query_mysql(query, headers = True, update = False):
|
||||
def query_mysql(query, params = None, headers = True, update = False):
|
||||
while True:
|
||||
try:
|
||||
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
||||
@@ -57,7 +57,10 @@ try:
|
||||
else:
|
||||
break
|
||||
cur = cnx.cursor()
|
||||
cur.execute(query)
|
||||
if params:
|
||||
cur.execute(query, params)
|
||||
else:
|
||||
cur.execute(query)
|
||||
if not update:
|
||||
result = []
|
||||
columns = tuple( [d[0] for d in cur.description] )
|
||||
@@ -76,7 +79,7 @@ try:
|
||||
|
||||
def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
|
||||
if category == "add_header": category = "add header"
|
||||
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category))
|
||||
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = %s AND score < %s AND (action = %s OR "all" = %s)', (rcpt, max_score, category, category))
|
||||
print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
|
||||
if len(meta_query) == 0:
|
||||
return
|
||||
@@ -130,7 +133,7 @@ try:
|
||||
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
|
||||
server.quit()
|
||||
for res in meta_query:
|
||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%d"' % (res['id']), update = True)
|
||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = %s', (res['id'],), update = True)
|
||||
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
|
||||
break
|
||||
except Exception as ex:
|
||||
@@ -138,7 +141,7 @@ try:
|
||||
print('%s' % (ex))
|
||||
time.sleep(3)
|
||||
|
||||
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %f AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt' % (max_score))
|
||||
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %s AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt', (max_score,))
|
||||
|
||||
for record in records:
|
||||
attrs = ''
|
||||
@@ -156,7 +159,7 @@ try:
|
||||
except Exception as ex:
|
||||
print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
|
||||
last_notification = 0
|
||||
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = "%s"' % (record['rcpt']))
|
||||
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = %s', (record['rcpt'],))
|
||||
attrs = attrs_json[0]['attributes']
|
||||
if isinstance(attrs, str):
|
||||
# if attr is str then just load it
|
||||
|
||||
@@ -24,13 +24,12 @@ fi
|
||||
sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules
|
||||
|
||||
if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then
|
||||
CONTAINER_NAME=rspamd-mailcow
|
||||
CONTAINER_ID=$(curl --silent --insecure https://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(\"${CONTAINER_NAME}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
|
||||
if [[ ! -z ${CONTAINER_ID} ]]; then
|
||||
curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
|
||||
fi
|
||||
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
|
||||
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
|
||||
REQ_ID="$(date +%s%N)"
|
||||
PAYLOAD="{\"cmd\":\"restart\",\"request_id\":\"${REQ_ID}\",\"issued_by\":\"dovecot-sa-rules\"}"
|
||||
redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning \
|
||||
PUBLISH mailcow.control.rspamd "${PAYLOAD}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# host-agent: dedicated container that reads /host/proc to publish host-level
|
||||
# stats and answer exec.df / exec.host-stats commands. Reuses the same agent
|
||||
# binary; behaviour selected via MAILCOW_AGENT_SERVICE=host.
|
||||
#
|
||||
# Requires:
|
||||
# volumes:
|
||||
# - /proc:/host/proc:ro
|
||||
# - /:/host/rootfs:ro
|
||||
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.0
|
||||
|
||||
FROM ${AGENT_IMAGE} AS agent
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=agent /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=agent /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=host
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
@@ -1,4 +1,8 @@
|
||||
FROM alpine:3.21
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -40,4 +44,14 @@ COPY ./docker-entrypoint.sh /app/
|
||||
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=netfilter \
|
||||
MAILCOW_AGENT_MAIN_CMD="/app/docker-entrypoint.sh"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM nginx:alpine
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -14,5 +18,14 @@ RUN mkdir -p /etc/nginx/includes
|
||||
COPY ./bootstrap.py /
|
||||
COPY ./docker-entrypoint.sh /
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=nginx \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh nginx -g 'daemon off;'"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
@@ -18,6 +22,16 @@ ADD olefy.py /app/
|
||||
|
||||
RUN chown -R nobody:nobody /app /tmp
|
||||
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
USER nobody
|
||||
|
||||
CMD ["python3", "-u", "/app/olefy.py"]
|
||||
ENV MAILCOW_AGENT_SERVICE=olefy \
|
||||
MAILCOW_AGENT_MAIN_CMD="python3 -u /app/olefy.py"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM php:8.2-fpm-alpine3.21
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
@@ -13,7 +17,7 @@ ARG MEMCACHED_PECL_VERSION=3.4.0
|
||||
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG REDIS_PECL_VERSION=6.3.0
|
||||
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG COMPOSER_VERSION=2.8.6
|
||||
ARG COMPOSER_VERSION=2.9.5
|
||||
|
||||
RUN apk add -U --no-cache autoconf \
|
||||
aspell-dev \
|
||||
@@ -72,7 +76,7 @@ RUN apk add -U --no-cache autoconf \
|
||||
&& pecl clear-cache \
|
||||
&& docker-php-ext-configure intl \
|
||||
&& docker-php-ext-configure exif \
|
||||
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
||||
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
||||
--with-jpeg=/usr/include/ \
|
||||
--with-webp \
|
||||
--with-xpm \
|
||||
@@ -109,6 +113,14 @@ RUN apk add -U --no-cache autoconf \
|
||||
|
||||
COPY ./docker-entrypoint.sh /
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
CMD ["php-fpm"]
|
||||
ENV MAILCOW_AGENT_SERVICE=php-fpm \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh php-fpm"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -29,63 +29,35 @@ session.save_handler = redis
|
||||
session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'"
|
||||
' > /usr/local/etc/php/conf.d/session_store.ini
|
||||
|
||||
# Check mysql_upgrade (master and slave)
|
||||
CONTAINER_ID=
|
||||
until [[ ! -z "${CONTAINER_ID}" ]] && [[ "${CONTAINER_ID}" =~ ^[[:alnum:]]*$ ]]; do
|
||||
CONTAINER_ID=$(curl --silent --insecure https://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)
|
||||
echo "Could not get mysql-mailcow container id... trying again"
|
||||
sleep 2
|
||||
done
|
||||
echo "MySQL @ ${CONTAINER_ID}"
|
||||
SQL_LOOP_C=0
|
||||
SQL_CHANGED=0
|
||||
until [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; do
|
||||
if [ ${SQL_LOOP_C} -gt 4 ]; then
|
||||
echo "Tried to upgrade MySQL and failed, giving up after ${SQL_LOOP_C} retries and starting container (oops, not good)"
|
||||
# Wait for MariaDB. The upstream mariadb image already runs mariadb-upgrade
|
||||
# itself on startup when needed
|
||||
echo "Waiting for MariaDB socket at /var/run/mysqld/mysqld.sock..."
|
||||
WAIT_C=0
|
||||
until mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} -e "SELECT 1" >/dev/null 2>&1; do
|
||||
WAIT_C=$((WAIT_C+1))
|
||||
if [ ${WAIT_C} -gt 60 ]; then
|
||||
echo "MariaDB did not respond after 60s — continuing anyway."
|
||||
break
|
||||
fi
|
||||
SQL_FULL_UPGRADE_RETURN=$(curl --silent --insecure -XPOST https://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
|
||||
sleep 1
|
||||
done
|
||||
echo "MariaDB is ready."
|
||||
|
||||
# doing post-installation stuff, if SQL was upgraded (master and slave)
|
||||
if [ ${SQL_CHANGED} -eq 1 ]; then
|
||||
POSTFIX=$(curl --silent --insecure https://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)
|
||||
# Timezone tables — check if CONVERT_TZ works, import if it returns NULL.
|
||||
# Some Alpine builds drop mariadb-tzinfo-to-sql; fall back to a Python
|
||||
# emitter that produces the same INSERT statements from /usr/share/zoneinfo.
|
||||
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
|
||||
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
|
||||
SQL_FULL_TZINFO_IMPORT_RETURN=$(curl --silent --insecure -XPOST https://controller.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_tzinfo_to_sql"}' --silent -H 'Content-type: application/json')
|
||||
echo "MySQL mysql_tzinfo_to_sql - debug output:"
|
||||
echo ${SQL_FULL_TZINFO_IMPORT_RETURN}
|
||||
echo "Importing timezone data into mysql.time_zone_* …"
|
||||
if command -v mariadb-tzinfo-to-sql >/dev/null 2>&1; then
|
||||
mariadb-tzinfo-to-sql /usr/share/zoneinfo 2>/dev/null \
|
||||
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
|
||||
elif command -v mysql_tzinfo_to_sql >/dev/null 2>&1; then
|
||||
mysql_tzinfo_to_sql /usr/share/zoneinfo 2>/dev/null \
|
||||
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
|
||||
else
|
||||
echo "No tzinfo-to-sql tool available — skipping timezone import."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
@@ -45,6 +49,14 @@ RUN chmod +x /opt/postfix-tlspol.sh \
|
||||
/docker-entrypoint.sh
|
||||
RUN rm -rf /tmp/* /var/tmp/*
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
ENV MAILCOW_AGENT_SERVICE=postfix-tlspol \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
filter f_mail { facility(mail); };
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
filter f_mail { facility(mail); };
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
@@ -58,6 +62,14 @@ RUN rm -rf /tmp/* /var/tmp/*
|
||||
|
||||
EXPOSE 588
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
ENV MAILCOW_AGENT_SERVICE=postfix \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM debian:trixie-slim
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG RSPAMD_VER=rspamd_3.14.2-82~90302bc
|
||||
ARG RSPAMD_VER=rspamd_3.14.3-1~236eb65
|
||||
ARG CODENAME=trixie
|
||||
ENV LC_ALL=C
|
||||
|
||||
@@ -33,8 +37,16 @@ COPY settings.conf /etc/rspamd/settings.conf
|
||||
COPY set_worker_password.sh /set_worker_password.sh
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
CMD ["/usr/bin/rspamd", "-f", "-u", "_rspamd", "-g", "_rspamd"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=rspamd \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/rspamd -f -u _rspamd -g _rspamd"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -1,47 +1,171 @@
|
||||
FROM debian:bookworm-slim
|
||||
# SOGo built from source to enable security patch application
|
||||
# Repository: https://github.com/Alinto/sogo
|
||||
# Version: SOGo-5.12.8
|
||||
#
|
||||
# Applied security patches:
|
||||
# -
|
||||
#
|
||||
# To add new patches, modify SOGO_SECURITY_PATCHES ARG below with space-separated commit hashes
|
||||
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM debian:bookworm
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ARG DEBIAN_VERSION=bookworm
|
||||
ARG SOGO_DEBIAN_REPOSITORY=https://packagingv2.sogo.nu/sogo-nightly-debian/
|
||||
ARG SOGO_VERSION=SOGo-5.12.8
|
||||
ARG SOPE_VERSION=SOPE-5.12.8
|
||||
# Security patches to apply (space-separated commit hashes)
|
||||
ARG SOGO_SECURITY_PATCHES=""
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.19
|
||||
ENV LC_ALL=C
|
||||
|
||||
# Prerequisites
|
||||
RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
apt-transport-https \
|
||||
ca-certificates \
|
||||
gettext \
|
||||
gnupg \
|
||||
mariadb-client \
|
||||
rsync \
|
||||
supervisor \
|
||||
syslog-ng \
|
||||
syslog-ng-core \
|
||||
syslog-ng-mod-redis \
|
||||
dirmngr \
|
||||
netcat-traditional \
|
||||
psmisc \
|
||||
wget \
|
||||
patch \
|
||||
# Install dependencies, build SOPE and SOGo, then clean up (all in one layer to minimize image size)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Build dependencies
|
||||
git \
|
||||
build-essential \
|
||||
gobjc \
|
||||
pkg-config \
|
||||
gnustep-make \
|
||||
gnustep-base-runtime \
|
||||
libgnustep-base-dev \
|
||||
libxml2-dev \
|
||||
libldap2-dev \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
libpq-dev \
|
||||
libmariadb-dev-compat \
|
||||
libmemcached-dev \
|
||||
libsodium-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libzip-dev \
|
||||
libytnef0-dev \
|
||||
libwbxml2-dev \
|
||||
curl \
|
||||
ca-certificates \
|
||||
# Runtime dependencies
|
||||
apt-transport-https \
|
||||
gettext \
|
||||
gnupg \
|
||||
mariadb-client \
|
||||
rsync \
|
||||
supervisor \
|
||||
syslog-ng \
|
||||
syslog-ng-core \
|
||||
syslog-ng-mod-redis \
|
||||
dirmngr \
|
||||
netcat-traditional \
|
||||
psmisc \
|
||||
wget \
|
||||
patch \
|
||||
libobjc4 \
|
||||
libxml2 \
|
||||
libldap-2.5-0 \
|
||||
libssl3 \
|
||||
zlib1g \
|
||||
libmariadb3 \
|
||||
libmemcached11 \
|
||||
libsodium23 \
|
||||
libcurl4 \
|
||||
libzip4 \
|
||||
libytnef0 \
|
||||
libwbxml2-1 \
|
||||
# Download gosu
|
||||
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
|
||||
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
|
||||
&& chmod +x /usr/local/bin/gosu \
|
||||
&& gosu nobody true \
|
||||
&& mkdir /usr/share/doc/sogo \
|
||||
&& touch /usr/share/doc/sogo/empty.sh \
|
||||
&& wget -O- https://keys.openpgp.org/vks/v1/by-fingerprint/74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9 | gpg --dearmor | apt-key add - \
|
||||
&& echo "deb [trusted=yes] ${SOGO_DEBIAN_REPOSITORY} ${DEBIAN_VERSION} main" > /etc/apt/sources.list.d/sogo.list \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
sogo \
|
||||
sogo-activesync \
|
||||
&& apt-get autoclean \
|
||||
# Build SOPE
|
||||
&& git clone --depth 1 --branch ${SOPE_VERSION} https://github.com/Alinto/sope.git /tmp/sope \
|
||||
&& cd /tmp/sope \
|
||||
&& rm -rf .git \
|
||||
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
|
||||
&& ./configure --prefix=/usr --disable-debug --disable-strip \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& cd / \
|
||||
&& rm -rf /tmp/sope \
|
||||
# Build SOGo with security patches
|
||||
&& git clone --depth 1 --branch ${SOGO_VERSION} https://github.com/Alinto/sogo.git /tmp/sogo \
|
||||
&& cd /tmp/sogo \
|
||||
&& git config user.email "builder@mailcow.local" \
|
||||
&& git config user.name "SOGo Builder" \
|
||||
&& for patch in ${SOGO_SECURITY_PATCHES}; do \
|
||||
echo "Applying security patch: ${patch}"; \
|
||||
git fetch origin ${patch} && git cherry-pick ${patch}; \
|
||||
done \
|
||||
&& rm -rf .git \
|
||||
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
|
||||
&& ./configure --disable-debug --disable-strip \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& cd /tmp/sogo/ActiveSync \
|
||||
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
|
||||
&& make -j$(nproc) install \
|
||||
&& cd / \
|
||||
&& rm -rf /tmp/sogo \
|
||||
# Strip binaries
|
||||
&& strip --strip-unneeded /usr/local/sbin/sogod 2>/dev/null || true \
|
||||
&& strip --strip-unneeded /usr/local/sbin/sogo-tool 2>/dev/null || true \
|
||||
&& strip --strip-unneeded /usr/local/sbin/sogo-ealarms-notify 2>/dev/null || true \
|
||||
&& strip --strip-unneeded /usr/local/sbin/sogo-slapd-sockd 2>/dev/null || true \
|
||||
# Remove build dependencies and clean up
|
||||
&& apt-get purge -y --auto-remove \
|
||||
git \
|
||||
build-essential \
|
||||
gobjc \
|
||||
gnustep-make \
|
||||
libgnustep-base-dev \
|
||||
libxml2-dev \
|
||||
libldap2-dev \
|
||||
libssl-dev \
|
||||
zlib1g-dev \
|
||||
libpq-dev \
|
||||
libmariadb-dev-compat \
|
||||
libmemcached-dev \
|
||||
libsodium-dev \
|
||||
libcurl4-openssl-dev \
|
||||
libzip-dev \
|
||||
libytnef0-dev \
|
||||
curl \
|
||||
&& apt-get autoremove -y \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/share/doc/* \
|
||||
&& rm -rf /usr/share/man/* \
|
||||
&& rm -rf /var/cache/debconf/* \
|
||||
&& rm -rf /tmp/* \
|
||||
&& rm -rf /root/.cache \
|
||||
&& find /usr/local/lib -name '*.a' -delete \
|
||||
&& find /usr/lib -name '*.a' -delete \
|
||||
&& mkdir -p /usr/share/doc/sogo \
|
||||
&& touch /usr/share/doc/sogo/empty.sh \
|
||||
&& touch /etc/default/locale
|
||||
|
||||
# Configure library paths
|
||||
RUN echo "/usr/lib64" > /etc/ld.so.conf.d/sogo.conf \
|
||||
&& echo "/usr/local/lib/sogo" >> /etc/ld.so.conf.d/sogo.conf \
|
||||
&& echo "/usr/local/lib/GNUstep/Frameworks/SOGo.framework/Versions/5/sogo" >> /etc/ld.so.conf.d/sogo.conf \
|
||||
&& ldconfig
|
||||
|
||||
# Create sogo user and group
|
||||
RUN groupadd -r -g 999 sogo \
|
||||
&& useradd -r -u 999 -g sogo -d /var/lib/sogo -s /bin/bash -c "SOGo Daemon" sogo \
|
||||
&& mkdir -p /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo \
|
||||
&& chown -R sogo:sogo /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo
|
||||
|
||||
# Create symlinks for SOGo binaries
|
||||
RUN ln -s /usr/local/sbin/sogod /usr/sbin/sogod \
|
||||
&& ln -s /usr/local/sbin/sogo-tool /usr/sbin/sogo-tool \
|
||||
&& ln -s /usr/local/sbin/sogo-ealarms-notify /usr/sbin/sogo-ealarms-notify \
|
||||
&& ln -s /usr/local/sbin/sogo-slapd-sockd /usr/sbin/sogo-slapd-sockd
|
||||
|
||||
# Copy configuration files and scripts
|
||||
COPY ./bootstrap-sogo.sh /bootstrap-sogo.sh
|
||||
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
||||
@@ -54,6 +178,14 @@ COPY docker-entrypoint.sh /
|
||||
RUN chmod +x /bootstrap-sogo.sh \
|
||||
/usr/local/sbin/stop-supervisor.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
ENV MAILCOW_AGENT_SERVICE=sogo \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
--- /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200
|
||||
+++ /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200
|
||||
--- /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200
|
||||
+++ /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200
|
||||
@@ -46,7 +46,7 @@
|
||||
</md-item-template>
|
||||
</md-autocomplete>
|
||||
|
||||
@@ -130,18 +130,22 @@ chmod 600 /var/lib/sogo/GNUstep/Defaults/sogod.plist
|
||||
# Patch ACLs
|
||||
#if [[ ${ACL_ANYONE} == 'allow' ]]; then
|
||||
# #enable any or authenticated targets for ACL
|
||||
# if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||
# patch -R /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||
# if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||
# patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||
# fi
|
||||
#else
|
||||
# #disable any or authenticated targets for ACL
|
||||
# if patch -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||
# patch /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||
# if patch -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
|
||||
# patch /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
|
||||
# fi
|
||||
#fi
|
||||
|
||||
if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
|
||||
patch -R /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
|
||||
# Apply custom UI patch (reverse patch to ADD buttons)
|
||||
if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
|
||||
echo "Applying navMailcowBtns patch (reverse to add buttons)..."
|
||||
patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
|
||||
else
|
||||
echo "navMailcowBtns patch already applied or cannot be applied"
|
||||
fi
|
||||
|
||||
# Rename custom logo, if any
|
||||
@@ -149,7 +153,7 @@ fi
|
||||
|
||||
# Rsync web content
|
||||
echo "Syncing web content with named volume"
|
||||
rsync -a /usr/lib/GNUstep/SOGo/. /sogo_web/
|
||||
rsync -a /usr/local/lib/GNUstep/SOGo/. /sogo_web/
|
||||
|
||||
# Chown backup path
|
||||
chown -R sogo:sogo /sogo_backup
|
||||
|
||||
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "SOGO_LOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "SOGO_LOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -23,7 +23,7 @@ destination d_redis_ui_log {
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "SOGO_LOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
command("LPUSH" "SOGO_LOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
FROM alpine:3.21
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -29,8 +33,15 @@ COPY supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
||||
|
||||
RUN chmod +x /healthcheck.sh
|
||||
HEALTHCHECK --interval=30s --timeout=10s \
|
||||
CMD sh -c '[ -f /tmp/healthcheck_status ] && [ "$(cat /tmp/healthcheck_status)" -eq 0 ] || exit 1'
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=unbound \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
FROM alpine:3.21
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
@@ -37,5 +41,16 @@ RUN apk add --update \
|
||||
COPY watchdog.sh /watchdog.sh
|
||||
COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh
|
||||
COPY check_dns.sh /usr/lib/mailcow/check_dns.sh
|
||||
COPY client.cnf /etc/my.cnf.d/client.cnf
|
||||
|
||||
CMD ["/watchdog.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=watchdog \
|
||||
MAILCOW_AGENT_MAIN_CMD="/watchdog.sh"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user