1
0
mirror of https://github.com/mailcow/mailcow-dockerized.git synced 2026-06-13 01:50:34 +00:00

Compare commits

..

99 Commits

Author SHA1 Message Date
FreddleSpl0it 689d753264 [Agent] Replace dockerapi container with Redis-based control bus 2026-05-20 20:54:51 +02:00
Jahongir Qurbonov 4ddcee28e4 Add Uzbek language (#7224) 2026-05-13 16:42:38 +02:00
milkmaker b0d16bbcee [Web] Updated lang.pt-br.json (#7228)
Co-authored-by: Edson Wolf <edsonwolf@vivaldi.net>
2026-05-13 16:42:03 +02:00
FreddleSpl0it 9175e5f086 Merge pull request #7226 from mailcow/feat/sogo-5.12.8
[SOGo] Update to 5.12.8
2026-05-13 10:34:50 +02:00
FreddleSpl0it ff3d571054 [SOGo] Update to 5.12.8 2026-05-13 10:31:23 +02:00
FreddleSpl0it a8e945f3da Merge pull request #7220 from mailcow/fix/06052026
[Web] escape HTML in sieve filter edit view and queue manager
2026-05-12 08:36:36 +02:00
FreddleSpl0it 33547d1d73 [Web] escape HTML in sieve filter edit view and queue manager 2026-05-12 08:31:51 +02:00
milkmaker 539c32d99c Translations update from Weblate (#7218)
* [Web] Updated lang.az-az.json

Co-authored-by: Nemoralis <nemoralis@duck.com>

* [Web] Updated lang.nl-nl.json

Co-authored-by: Tom18314 <tomstokmans5@gmail.com>

* [Web] Updated lang.pl-pl.json

Co-authored-by: pmuch78 <pawel@prolin.pl>

---------

Co-authored-by: Nemoralis <nemoralis@duck.com>
Co-authored-by: Tom18314 <tomstokmans5@gmail.com>
Co-authored-by: pmuch78 <pawel@prolin.pl>
2026-05-08 16:46:10 +02:00
milkmaker d42c3823c4 update postscreen_access.cidr (#7209) 2026-05-02 19:15:14 +02:00
milkmaker 886dbcc419 Translations update from Weblate (#7190)
* [Web] Updated lang.az-az.json

[Web] Added lang.az-az.json

Co-authored-by: Nemoralis <nemoralis@duck.com>
Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.si-si.json

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>

* [Web] Updated lang.pt-br.json

Co-authored-by: André Ribas <andre@ribassu.com>

---------

Co-authored-by: Nemoralis <nemoralis@duck.com>
Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: André Ribas <andre@ribassu.com>
2026-04-11 21:25:03 +02:00
milkmaker dc15994d40 update postscreen_access.cidr (#7177) 2026-04-02 11:50:05 +02:00
FreddleSpl0it ec24825280 Merge pull request #7173 from mailcow/fix/escaping
[Web][Dovecot] Improve input validation and escaping
2026-03-31 09:45:23 +02:00
FreddleSpl0it 5a00b5124b [Web][Dovecot] Add parameterized queries and input validation for quarantine_category 2026-03-29 12:08:45 +02:00
FreddleSpl0it 8c039f694f Improve template URI escaping and parameter handling 2026-03-19 12:48:43 +01:00
FreddleSpl0it 95bf46c1e4 escape HTML in autodiscover logs 2026-03-19 12:44:50 +01:00
FreddleSpl0it edde35156d escape HTML in qitem details 2026-03-19 12:44:30 +01:00
FreddleSpl0it 84e3c32f13 escape HTML in last logins 2026-03-19 12:44:00 +01:00
FreddleSpl0it ecb848493b add missing object-level access control 2026-03-19 12:42:45 +01:00
FreddleSpl0it 8a65b9d1c6 add missing access control 2026-03-19 12:41:47 +01:00
FreddleSpl0it ed9264fd2a [Web] Allow force_tfa for LDAP and Keycloak users 2026-03-13 14:13:25 +01:00
FreddleSpl0it 7817dda43f [ACME] Skip subdomains covered by wildcards (DNS-01 challenge only) 2026-03-13 13:08:18 +01:00
FreddleSpl0it 018e292854 Merge pull request #7134 from mailcow/fix/7112-2
[ACME] Skip autodiscover/mta-sts subdomains covered by wildcard certificates
2026-03-13 12:39:10 +01:00
FreddleSpl0it 127fb1e8f5 [ACME] Skip autodiscover/mta-sts subdomains covered by wildcard certificates 2026-03-13 12:35:22 +01:00
milkmaker 09f09cb850 [Web] Updated lang.hu-hu.json (#7130) 2026-03-12 15:01:54 +01:00
FreddleSpl0it d4bf377a96 Merge pull request #7121 from rezzorix/fix/theme-localstorage-staging
Fix theme localStorage collision with rspamd UI
2026-03-12 07:47:03 +01:00
FreddleSpl0it abd6fe8c79 Merge pull request #7124 from mailcow/fix/7112
[ACME] Fix wildcard certificate conflict with MAILCOW_HOSTNAME
2026-03-12 07:46:02 +01:00
FreddleSpl0it 5f8382ef44 Merge pull request #7123 from mailcow/fix/7115
[Web] Fix LDAP/Keycloak login TypeError - missing JSON decode for attributes
2026-03-12 07:45:06 +01:00
rezzorix 03eccd4e42 added/fix: use mailcow_theme in bundled dark mode JS 2026-03-12 14:11:31 +08:00
FreddleSpl0it 1da8d1c894 [ACME] Fix wildcard certificate conflict with MAILCOW_HOSTNAME 2026-03-11 09:33:16 +01:00
FreddleSpl0it d1feebf164 [Web] Fix LDAP/Keycloak login TypeError - missing JSON decode for attributes 2026-03-11 09:18:03 +01:00
rezzorix 293b885a85 Fix theme localStorage collision with rspamd UI 2026-03-11 13:32:53 +08:00
FreddleSpl0it 1e0850193a Merge pull request #7111 from mailcow/fix/7110
[SOGo] Add ActiveSync support
2026-03-10 13:37:58 +01:00
FreddleSpl0it 33acf56526 [SOGo] Add ActiveSync support 2026-03-10 13:13:00 +01:00
FreddleSpl0it bea9ad7e8f Merge pull request #7108 from mailcow/fix/7105
[SOGo] Fix draft folder creation by adding /var/spool/sogo directory
2026-03-10 10:38:50 +01:00
FreddleSpl0it e7ea3aa608 [SOGo] Fix draft folder creation by adding /var/spool/sogo directory 2026-03-10 10:31:50 +01:00
FreddleSpl0it 5888e248c3 Merge pull request #7103 from mailcow/fix/7102
[Watchdog] Fix Nagios MariaDB client SSL compatibility with Alpine 3.23
2026-03-10 10:04:18 +01:00
FreddleSpl0it 2e176339ba [Watchdog] Fix Nagios MariaDB client SSL compatibility with Alpine 3.23 2026-03-10 10:01:18 +01:00
FreddleSpl0it 8f883f3d37 Rename dns-101 to dns-01 2026-03-09 15:27:33 +01:00
FreddleSpl0it 709117fe19 prevent false positives in option detection in new_options.sh 2026-03-09 15:19:46 +01:00
FreddleSpl0it 82ea418423 Extend --dev flag to skip _modules update 2026-03-09 15:13:36 +01:00
FreddleSpl0it fd24163c6e prevent false positives in option detection in new_options.sh 2026-03-09 15:13:13 +01:00
FreddleSpl0it 3caef45a12 Merge pull request #7100 from mailcow/feat/rspamd-3.14.3
[Rspamd] Update to 3.14.3-1
2026-03-09 08:15:10 +01:00
FreddleSpl0it 8760e7e5db [Rspamd] Update to 3.14.3-1 2026-03-09 07:59:34 +01:00
FreddleSpl0it e848226062 [SOGo] Update paths from /usr/lib/ to /usr/local/lib/ 2026-03-06 16:26:13 +01:00
FreddleSpl0it efaeb77e13 Merge pull request #7093 from mailcow/fix/7054
[SOGo][Web] use incremental updates for mailbox/alias/resource sync in sogo_static_view
2026-03-06 09:23:00 +01:00
FreddleSpl0it 569b4cf985 Merge pull request #7098 from mailcow/feat/sogo-5.12.5
[SOGo] Update to 5.12.5
2026-03-06 09:21:09 +01:00
FreddleSpl0it 6c857243ec [SOGo] Update to 5.12.5 2026-03-06 09:18:10 +01:00
renovate[bot] 905e93627c Update docker/build-push-action action to v7 (#7097) 2026-03-06 07:02:04 +01:00
renovate[bot] 598ea21827 chore(deps): update docker/setup-buildx-action action to v4 (#7096)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-05 11:03:21 +01:00
milkmaker 6012cf4486 [Web] Updated lang.nl-nl.json (#7095)
Co-authored-by: Tom18314 <tomstokmans5@gmail.com>
2026-03-04 23:03:02 +01:00
FreddleSpl0it fe71f84c82 [SOGo] Fix custom UI patches after moving to source-compiled SOGo 2026-03-04 14:55:52 +01:00
FreddleSpl0it 763ecbc93e Update image tags 2026-03-04 13:38:16 +01:00
FreddleSpl0it 97312c1a9d Update alpine images to 3.23 2026-03-04 13:35:11 +01:00
renovate[bot] baf0e7e658 chore(deps): update docker/login-action action to v4 (#7094) 2026-03-04 12:40:35 +01:00
renovate[bot] a4ca5f9363 Update docker/setup-qemu-action action to v4 (#7092) 2026-03-04 12:40:02 +01:00
FreddleSpl0it dcbea71e67 [ACME] auto-create DNS-01 config template on startup 2026-03-04 11:42:40 +01:00
FreddleSpl0it 524f19ff77 Update Docker Images 2026-03-04 11:36:09 +01:00
FreddleSpl0it 4e33c7143f [SOGo][Web] use incremental updates for mailbox/alias/resource sync in sogo_static_view 2026-03-04 11:16:48 +01:00
milkmaker 4c184b25f4 Translations update from Weblate (#7091)
* [Web] Language file updated by 'Cleanup translation files' addon

Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.si-si.json

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>

---------

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2026-03-03 20:25:24 +01:00
FreddleSpl0it ddc2309f1e Merge pull request #7089 from mailcow/fix/7039
[Web] switch from GET to POST for datatable requests
2026-03-03 15:07:17 +01:00
FreddleSpl0it bccfcefd17 Merge pull request #7088 from mailcow/fix/7043
[Web] Add missing EAS and DAV protocol options to mailbox bulk actions
2026-03-03 14:42:47 +01:00
FreddleSpl0it d40252ccce [Web] Add missing EAS and DAV protocol options to mailbox bulk actions 2026-03-03 14:39:41 +01:00
FreddleSpl0it 4abb5cbfab Merge pull request #7086 from mailcow/feat/sogo-build
[SOGo] Build SOGo from source with security patches
2026-03-03 14:24:29 +01:00
FreddleSpl0it b695936273 Merge pull request #6912 from cjlapao/feat-acme-dns
acme: add DNS challenges
2026-03-03 14:23:28 +01:00
DerLinkman d9463c7950 sogo: shrinked image file by ~50% 2026-03-03 14:14:32 +01:00
FreddleSpl0it 579542381a Merge pull request #7060 from jovobe/staging
Bump alpine version of netfilter
2026-03-03 14:06:01 +01:00
FreddleSpl0it ce5659f300 Merge pull request #7082 from JeremieCrinon/fix/show-stopped-containers-api-and-dashboard
fix: show stopped and failed containers in dashboard and API
2026-03-03 14:01:03 +01:00
FreddleSpl0it 1967cb642f Merge pull request #6457 from mailcow/renovate/composer-composer-2.x
chore(deps): update dependency composer/composer to v2.9.5
2026-03-03 13:38:32 +01:00
FreddleSpl0it ba0eb04ebe Merge pull request #6695 from maxi322/feature/check_dns_improvement
check_dns: better time measurement
2026-03-03 13:31:23 +01:00
FreddleSpl0it 609ce6b0d6 Merge pull request #6976 from mailcow/fix/autodiscover-passwordless
feat: Implement passwordless autodiscover endpoint
2026-03-03 13:25:16 +01:00
FreddleSpl0it af61e2d303 [Web] Add fail2ban logging to passwordless autodiscover endpoint 2026-03-03 13:24:44 +01:00
FreddleSpl0it c6e3f517e1 Merge pull request #7047 from jonprocter/staging
Document qitem endpoint in openapi.yaml for editing quarantine mails
2026-03-03 13:11:43 +01:00
FreddleSpl0it 6eaa8d43e9 Merge pull request #7078 from HichemAK/patch-2
Add skip feature to mailcow admin password reset script
2026-03-03 13:09:20 +01:00
FreddleSpl0it c033cd6254 Merge pull request #7077 from mailcow/feat/force-tfa
[Web] Add forced 2FA setup and password update enforcement
2026-03-03 13:01:16 +01:00
FreddleSpl0it 7562578b74 [SOGo] Build SOGo from source with security patches 2026-03-03 11:24:42 +01:00
FreddleSpl0it 43f570e761 [Web] switch from GET to POST for datatable requests 2026-03-03 08:10:16 +01:00
DocFraggle 5e478a32df Fix lua script sub-addressing (#7037)
* Fix lua script sub-addressing

* Fix subject tagging in rspamd.local.lua as well

---------

Co-authored-by: Hailer, Christian <christian.hailer@interhyp.de>
Co-authored-by: DocFraggle <drfraggle@drfraggleshome.de>
2026-03-02 15:49:11 +01:00
milkmaker 22c94a4d2f update postscreen_access.cidr (#7084) 2026-03-01 13:30:57 +01:00
Jérémie Crinon 99dc0f6616 fix: show stopped and failed containers in dashboard and API 2026-02-27 13:41:36 +01:00
Hichem Ammar Khodja 4732f568fa Add skip feature to mailcow admin password reset script
Added option to skip confirmation for resetting the mailcow admin account.
2026-02-24 15:26:56 +01:00
FreddleSpl0it 96d4802cb2 [Web] Switch QR code generation from external API to local library and fix composer.json/lock mismatch 2026-02-24 11:02:47 +01:00
FreddleSpl0it ad5b94af5e [Web] Add forced 2FA setup and password update enforcement 2026-02-24 10:44:33 +01:00
milkmaker 7fce984cac [Web] Updated lang.lv-lv.json (#7069)
Co-authored-by: Edgars Andersons <Edgars+Mailcow+Weblate@gaitenis.id.lv>
2026-02-19 22:15:32 +01:00
renovate[bot] 404e2f0190 Update actions/stale action to v10.2.0 (#7062) 2026-02-18 08:10:48 +01:00
maxi322 1c52eaa3a4 check_dns: better time measurement
alpine does not output ms using date, therefore we use perl to get a more
accurate measurement of the dns response time.
The script output is now even more similar to nagios check_dns.
2026-02-14 18:07:39 +01:00
Johan M. von Behren c7e04b4146 Bump alpine version of netfilter
Bump alpine from 3.21 to 3.23. Closes #7059
2026-02-13 14:26:27 +01:00
renovate[bot] 075959aea9 Update dependency composer/composer to v2.9.5
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-02-12 11:09:08 +00:00
milkmaker 885ba2510e Translations update from Weblate (#7055)
* [Web] Updated lang.cs-cz.json

Co-authored-by: Filip Hajny <filip@hajny.net>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.ru-ru.json

Co-authored-by: Habetdin <15926758+Habetdin@users.noreply.github.com>

---------

Co-authored-by: Filip Hajny <filip@hajny.net>
Co-authored-by: Habetdin <15926758+Habetdin@users.noreply.github.com>
2026-02-09 01:52:35 +01:00
Carlos Lapao 87563249f1 Add DNS-01 challenge variables to update migration for existing instances
Existing mailcow installations were Not receiving the ACME_DNS_CHALLENGE,
ACME_DNS_PROVIDER, and ACME_ACCOUNT_EMAIL options when running update.sh,  as they were only defined in generate_config.sh for new installations.
2026-02-07 13:02:55 +00:00
Jonathan Procter fb1686065d Add endpoint for editing quarantine mails
Added endpoint to edit quarantine items with actions to release or learn as ham. Functionality already existed but was undocumented.
2026-02-03 21:31:32 +00:00
milkmaker 0428f5c9bd update postscreen_access.cidr (#7042) 2026-02-01 22:45:03 +01:00
milkmaker a70c23065c Translations update from Weblate (#7040)
* [Web] Updated lang.vi-vn.json

Co-authored-by: Phu D. Nguyen <sillycat@duck.com>

* [Web] Updated lang.zh-tw.json

Co-authored-by: Proton Chang <protonmo@gmail.com>

* [Web] Updated lang.si-si.json

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>

---------

Co-authored-by: Phu D. Nguyen <sillycat@duck.com>
Co-authored-by: Proton Chang <protonmo@gmail.com>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2026-02-01 22:44:52 +01:00
FreddleSpl0it 2e8897c2cf Merge branch 'staging' into fix/autodiscover-passwordless 2026-01-28 11:10:28 +01:00
DerLinkman 5ca900749c autodiscover: use generalized error logging instead of specific to prevent user enumeration 2025-12-18 16:54:45 +01:00
DerLinkman b005803fe0 Add autodiscover debug script with domain override support
- Add view_autodiscover.sh helper script for testing autodiscover responses
- Support -h/--help flag for usage information
- Support -d/--domain flag to override autodiscover target (useful for testing)
- Auto-detect xmllint availability for formatted output
- Email validation with regex
- Interactive mode if no email provided
- Display response length for debugging
2025-12-17 14:27:53 +01:00
DerLinkman ec77406dba Fix autodiscover.php: Use random error IDs and fix SQL type casting
- Replace hardcoded error IDs with random values (1-10 billion range) for better debugging
- Cast SimpleXMLElement email to string before SQL query to prevent type errors
- Qualify ambiguous 'active' column with table names in JOIN query
- Add proper error XML response for database errors instead of die()
- Ensure all error paths return complete XML documents
2025-12-17 14:27:38 +01:00
DerLinkman ee15721550 feat: implement passwordless autodiscover endpoint
- Remove HTTP Basic Authentication requirement from autodiscover.php
- Extract email address from XML request body instead of AUTH headers
- Validate mailbox existence and active status before returning config
- Improve security by eliminating password transmission
- Add comprehensive error handling for invalid/inactive mailboxes
- Follow industry standards (Microsoft, Google, Apple)
- Maintain backward compatibility with existing email clients
- Keep full logging functionality in Redis AUTODISCOVER_LOG

This change enhances security while improving user experience and
follows modern email client configuration best practices.
2025-12-17 13:39:05 +01:00
Carlos 890295bbfc Add DNS-01 challenge support with configuration files and scripts 2025-11-14 07:10:17 +00:00
Carlos a52e977b89 Add DNS-01 challenge support for ACME certificates and related configurations 2025-11-13 07:19:38 +00:00
519 changed files with 24605 additions and 25208 deletions
@@ -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
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
images:
- "acme-mailcow"
- "clamd-mailcow"
- "controller-mailcow"
- "dockerapi-mailcow"
- "dovecot-mailcow"
- "netfilter-mailcow"
- "olefy-mailcow"
+4 -4
View File
@@ -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
+1
View File
@@ -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/*
+18 -1
View File
@@ -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
;;
+28 -3
View File
@@ -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 []
+56 -10
View File
@@ -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]}
+34
View File
@@ -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
}
+57
View File
@@ -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
+20 -36
View File
@@ -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
+34
View File
@@ -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
+16
View File
@@ -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)
}
+13
View File
@@ -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
)
+6
View File
@@ -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=
+175
View File
@@ -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)
}
+58
View File
@@ -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
+16 -4
View File
@@ -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 []
-34
View File
@@ -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"]
-261
View File
@@ -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
+15 -2
View File
@@ -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
+6 -7
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+21
View File
@@ -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"]
+16 -2
View File
@@ -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 []
+15 -2
View File
@@ -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 []
+15 -1
View File
@@ -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 []
+16 -4
View File
@@ -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 []
+23 -51
View File
@@ -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
+14 -2
View File
@@ -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); };
+14 -2
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+16 -4
View File
@@ -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 []
+163 -31
View File
@@ -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 []
+2 -2
View File
@@ -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>
+11 -7
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+16 -5
View File
@@ -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 []
+17 -2
View File
@@ -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