1
0
mirror of https://github.com/mailcow/mailcow-dockerized.git synced 2026-06-12 17:40:25 +00:00

Compare commits

...

342 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 caaa4a414d [Web] Fix datatables search after PR #7022 2026-01-29 10:26:44 +01:00
FreddleSpl0it c3d841340c [Dovecot][PHP][SOGo] Update Images 2026-01-28 11:28:36 +01:00
FreddleSpl0it 2e8897c2cf Merge branch 'staging' into fix/autodiscover-passwordless 2026-01-28 11:10:28 +01:00
FreddleSpl0it b8cd00111f Merge pull request #7007 from moregeek/feat/allow_preset_passwords
feat: allow preset of passwords via environment vars
2026-01-28 10:18:56 +01:00
FreddleSpl0it 81cda80651 Merge pull request #7021 from mailcow/feat/restrict-alias-sending
[Postfix] Configurable send permissions for alias addresses
2026-01-28 10:03:02 +01:00
FreddleSpl0it c1d4f04c22 Merge branch 'staging' into feat/restrict-alias-sending 2026-01-28 10:02:03 +01:00
FreddleSpl0it 82276cd1ca Merge pull request #7022 from mailcow/feat/eas-dav-access
[Web] Allow admins to limit EAS and DAV access for mailbox users
2026-01-28 09:54:47 +01:00
FreddleSpl0it 56ea4302ed [Web] Allow admins to limit EAS and DAV access for mailbox users 2026-01-28 09:49:33 +01:00
FreddleSpl0it c06112b26e [Postfix] Configurable send permissions for alias addresses 2026-01-27 09:05:51 +01:00
FreddleSpl0it aa5a4f0998 Merge pull request #6710 from mailcow/renovate/tianon-gosu-1.x
chore(deps): update dependency tianon/gosu to v1.19
2026-01-27 08:09:31 +01:00
FreddleSpl0it bf4f471cfd Merge pull request #6837 from mailcow/renovate/php-memcached-dev-php-memcached-3.x
chore(deps): update dependency php-memcached-dev/php-memcached to v3.4.0
2026-01-27 08:08:50 +01:00
FreddleSpl0it 978bff9dbc Merge pull request #6867 from DiscoNova/feat/possible-to-disable-logins-from-autoprotocol-domains
[Web] Disable login UI on autoprotocol domains
2026-01-27 08:08:12 +01:00
FreddleSpl0it 869d9af7dd Merge pull request #6901 from mailcow/renovate/phpredis-phpredis-6.x
chore(deps): update dependency phpredis/phpredis to v6.3.0
2026-01-27 08:05:58 +01:00
FreddleSpl0it af10499ecb Merge pull request #6927 from mailcow/renovate/imagick-imagick-3.x
chore(deps): update dependency imagick/imagick to v3.8.1
2026-01-27 08:04:51 +01:00
FreddleSpl0it a1a4d8ff98 Merge pull request #6947 from mailcow/renovate/krakjoe-apcu-5.x
chore(deps): update dependency krakjoe/apcu to v5.1.28
2026-01-27 08:04:24 +01:00
FreddleSpl0it 95d61e8aa2 Merge pull request #6980 from bluewalk/feat/issue-6489
Configurable displayName(s) - Fixes issue #6489
2026-01-27 08:02:20 +01:00
FreddleSpl0it ec8dd1a54f Merge pull request #6990 from psuet/mobileconfig-with-password-complexity
fix: Password for mobileconfig that conforms to password-complexity policy
2026-01-27 07:56:35 +01:00
milkmaker 382ee34d0e [Web] Updated lang.hu-hu.json (#7020)
Co-authored-by: Sándor <me-github@sandros.hu>
2026-01-26 20:15:47 +01:00
milkmaker 0999c9e9ab Translations update from Weblate (#7014)
* [Web] Updated lang.zh-cn.json

Co-authored-by: 雨 <luotianyi@luotianyi.me>

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

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

---------

Co-authored-by: 雨 <luotianyi@luotianyi.me>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2026-01-23 22:02:55 +01:00
Stefan Morgenthaler c485968e7f feat: allow preset of passwords via environment vars
Signed-off-by: Stefan Morgenthaler <dev@morgenthaler.at>
2026-01-14 11:42:15 +01:00
milkmaker e727620bd3 Translations update from Weblate (#7002)
* [Web] Updated lang.zh-cn.json

Co-authored-by: ガラスのような夢 <i@msdnicrosoft.work>

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

Co-authored-by: Monika Bark <rychert.monika@wp.pl>

---------

Co-authored-by: ガラスのような夢 <i@msdnicrosoft.work>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2026-01-07 17:23:31 +01:00
milkmaker 71fa3ecebc update postscreen_access.cidr (#6987) 2026-01-07 17:22:01 +01:00
Paul Sütterlin 70101d1187 fix: Password for mobileconfig that conforms to password-complexity policy 2026-01-01 16:57:21 +01:00
bluewalk c060c205d3 Fixes issue #6489 2025-12-21 16:56:16 +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
Copilot 038b2efb75 Add MTA-STS support for alias domains (#6972)
* Initial plan

* Add MTA-STS support for alias domains

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Improve domain normalization and code style in mta-sts.php

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add error handling for idn_to_ascii in mta-sts.php

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add database error handling for alias domain query

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add ACME certificate support for MTA-STS on alias domains

Query alias_domain table to find aliases with MTA-STS enabled target domains and request certificates for mta-sts.<alias-domain> subdomains.

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* compose: bump image tag to 1.95

* Add MTA-STS DNS records display for alias domains in UI

When viewing an alias domain's DNS diagnostics, check if the target domain has MTA-STS enabled and display the required DNS records for the alias domain.

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-12-15 16:29:21 +01:00
DerLinkman 1fe4cd03e9 ui: fix global filters ui tickbox reappearing (#6966) 2025-12-12 16:01:18 +01:00
milkmaker 12e02e67ff Translations update from Weblate (#6965)
* [Web] Updated lang.fr-fr.json

Co-authored-by: Keo <contact@kbl.netlib.re>

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

Co-authored-by: Germano Pires Ferreira <germanopires@gmail.com>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

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

Co-authored-by: Monika Bark <rychert.monika@wp.pl>

---------

Co-authored-by: Keo <contact@kbl.netlib.re>
Co-authored-by: Germano Pires Ferreira <germanopires@gmail.com>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-12-12 15:21:04 +01:00
DerLinkman b6f57dfb78 rspamd: update to 3.14.2 2025-12-12 14:06:49 +01:00
Copilot 3ebf2c2d2d Prevent duplicate/plaintext login announcement rendering (#6963)
* Initial plan

* Fix duplicate login announcement display

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-12 12:34:20 +01:00
DerLinkman 1bac6f1ee7 ofelia: revert fixed cron syntax for sa-rules download 2025-12-11 13:29:11 +01:00
DerLinkman 67e7acd6bd rspamd: upgrade to 3.14.1, trixie rebuild + bcc forwarded hosts fix (#6958)
* rspamd: fix bcc + subadress handling when using forward hosts

* rspamd: build against trixie + use version 3.14.1
2025-12-11 09:45:56 +01:00
renovate[bot] 910ce573d6 chore(deps): update peter-evans/create-pull-request action to v8 (#6953) 2025-12-10 19:48:02 +01:00
renovate[bot] 689336b3e1 chore(deps): update dependency tianon/gosu to v1.19
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:59 +00:00
renovate[bot] 01cf72cdef chore(deps): update dependency phpredis/phpredis to v6.3.0
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:54 +00:00
renovate[bot] 4cdb97c699 chore(deps): update dependency php-memcached-dev/php-memcached to v3.4.0
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:50 +00:00
renovate[bot] 1bd795a9c6 chore(deps): update dependency krakjoe/apcu to v5.1.28
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:42 +00:00
renovate[bot] 39f29e6c30 chore(deps): update dependency imagick/imagick to v3.8.1
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-12-10 10:41:38 +00:00
Ashitaka 1ab6af21e3 Merge pull request #6905 from Ashitaka57/6646-pbkdf2-sha512-verify-hash
Support for PBKDF2-SHA512 hash algorithm in verify_hash() (FreeIPA compatibility) (issue 6646)
2025-12-10 11:41:06 +01:00
DerLinkman 5d95c48e0d backup: add image prefetch function to verify latest image is used 2025-12-10 08:43:04 +01:00
DerLinkman dbb9e474b0 pf-tlspol: upgrade to 1.8.22 (#6951)
* postfix-tlspol: upgrade to 1.8.20

* pf-tlspol: update to 1.8.22
2025-12-09 13:25:50 +01:00
Khurram Malik f8eed8c786 fix(api): add missing break in CORS switch block causing save to hang (#6926) 2025-12-09 11:54:20 +01:00
DerLinkman ef010aa39c Update CONTRIBUTING.md
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-08 15:08:25 +01:00
milkmaker 79171ea6f5 [Web] Updated lang.fr-fr.json (#6943)
Co-authored-by: Neuronnexion <support@nnx.com>
2025-12-05 14:40:45 +01:00
milkmaker 4e3294b273 [Web] Updated lang.fr-fr.json (#6941)
[Web] Updated lang.fr-fr.json

[Web] Updated lang.fr-fr.json

Co-authored-by: Neuronnexion <support@nnx.com>
2025-12-03 23:31:37 +01:00
renovate[bot] 32a6ecddb6 chore(deps): update alpine docker tag to v3.23 (#6940)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 23:30:46 +01:00
DerLinkman f3d9833ecf Merge branch 'master' into staging 2025-12-03 16:55:12 +01:00
DerLinkman 930ca76ea7 update: moved _modules initialization and update at the beginning of update script 2025-12-03 16:54:26 +01:00
DerLinkman 9a2887cf46 core: improved docker compose version check 2025-12-03 16:27:04 +01:00
DerLinkman 9950914086 core: improved docker compose version check 2025-12-03 16:26:22 +01:00
renovate[bot] 470cfb0026 chore(deps): update actions/stale action to v10.1.1 (#6937)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 13:53:05 +01:00
milkmaker 6c106b4e4d [Web] Updated lang.fr-fr.json (#6936)
Co-authored-by: Neuronnexion <support@nnx.com>
2025-12-02 16:40:36 +01:00
milkmaker 3d6253a2b2 update postscreen_access.cidr (#6933) 2025-12-01 08:48:22 +01:00
milkmaker b873812588 [Web] Updated lang.gr-gr.json (#6930)
Co-authored-by: ChD Computers <chdcomputers@gmail.com>
2025-11-29 13:20:02 +01:00
milkmaker 514fefd2ed Translations update from Weblate (#6924)
* [Web] Updated lang.ca-es.json

Co-authored-by: Pere Montpeó <peremontpeo@gmail.com>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

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

Co-authored-by: Chris <chrismfz@gmail.com>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.cs-cz.json

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

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

[Web] Updated lang.pl-pl.json

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

---------

Co-authored-by: Pere Montpeó <peremontpeo@gmail.com>
Co-authored-by: Chris <chrismfz@gmail.com>
Co-authored-by: Filip Hajny <filip@hajny.net>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-11-24 16:50:03 +01:00
renovate[bot] 6f9ee2d151 chore(deps): update actions/checkout action to v6 (#6920)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-21 10:55:12 +01:00
milkmaker 9832006141 Translations update from Weblate (#6916)
* [Web] Updated lang.si-si.json

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

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

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

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

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

---------

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: Habetdin <15926758+Habetdin@users.noreply.github.com>
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
Co-authored-by: Peter <magic@kthx.at>
2025-11-17 23:23:07 +01:00
Carlos 890295bbfc Add DNS-01 challenge support with configuration files and scripts 2025-11-14 07:10:17 +00:00
Josh 0413d26855 Allow making spam aliases permanent (#6888)
* Allow making spam aliases permanent

* added german translation

* updated Spamalias Twig + Rename in Spam Alias

* compose: update image tags to align to vendor version

---------

Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-11-13 16:05:01 +01:00
Patrik Kernstock 7b29c1f304 Disable nginx server_tokens in http context (#6873) 2025-11-13 15:19:11 +01:00
Patrik Kernstock ae3ef391ee Remove deprecated 'X-XSS-Protection' header (#6871) 2025-11-13 15:16:44 +01:00
Peter 7313f996d3 Update to trixie (#6907) 2025-11-13 15:16:00 +01:00
DerLinkman 62d16c9e56 compose: changes cronjobs to regular cron syntax + fixed sogo creds for cronjobs (#6866)
* cron: restructure cron timer to time on second (instead of random)

* dovecot: fix clearance for cron.creds file
2025-11-13 14:59:49 +01:00
Carlos a52e977b89 Add DNS-01 challenge support for ACME certificates and related configurations 2025-11-13 07:19:38 +00:00
DerLinkman 674b41ce08 updated the Contributing Guidelines 2025-11-12 10:16:54 +01:00
Claas Flint 1b833be760 Replace pigz with zstd for backup compression (#6897)
* Replace pigz with zstd for backup compression

This change replaces pigz (parallel gzip) with zstd (Zstandard) as the
compression algorithm for mailcow backups while maintaining full backward
compatibility with existing .tar.gz backups.

Benefits:
- Better compression ratios (12-37% improvement in tests)
- Improved compression speed with modern algorithm
- Maintains rsyncable functionality for incremental backups
- Full backward compatibility for restoring old .tar.gz backups
- Wide industry adoption and active development

Changes:
- Backup compression: pigz --rsyncable -p → zstd --rsyncable -T
- Backup decompression: pigz -d -p → zstd -d -T
- File extensions: .tar.gz → .tar.zst
- Added get_archive_info() function for intelligent format detection
- Updated backup Dockerfile to install zstd alongside pigz
- Restore function now auto-detects and handles both formats
- Updated FILE_SELECTION regex to recognize both .tar.zst and .tar.gz
- Updated comments to reflect new file extension

Backward Compatibility:
- Restore automatically detects .tar.zst (preferred) or .tar.gz (legacy)
- Existing .tar.gz backups can still be restored without issues
- pigz remains installed in backup image for legacy support
- Graceful fallback if backup file format not found

Testing:
- Added comprehensive test suite (test_backup_and_restore.sh)
- 12 automated tests covering all scenarios:
  * Backup creation (both formats)
  * Restore (both formats)
  * Format detection and priority
  * Error handling (missing files, empty dirs)
  * Content integrity verification
  * Multi-threading configuration
  * Large file compression (8.59 MB realistic data)

Test Results:
✓ zstd compression working
✓ pigz compression working (legacy)
✓ zstd decompression working
✓ pigz decompression working (backward compatible)
✓ Archive detection working
✓ Content integrity verified
✓ Format priority correct (.tar.zst preferred)
✓ Error handling for missing files
✓ Error handling for empty directories
✓ Multi-threading configuration verified
✓ Large file compression: 37.05% improvement
✓ Small file compression: 12.18% improvement

* move testing script into development folder

---------

Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-11-12 10:06:36 +01:00
DerLinkman 88adb1adf5 remove dev docker volume from upstream 2025-11-12 09:54:35 +01:00
DerLinkman ec472f13cf sogo: removed URLDecrpytion by default, make it configurable in sogo.conf 2025-11-12 09:50:41 +01:00
milkmaker 2e1d98cc7c [Web] Updated lang.pl-pl.json (#6908)
[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-11-10 21:06:13 +01:00
milkmaker 07d7e3dc30 [Web] Updated lang.pl-pl.json (#6906)
[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

[Web] Updated lang.pl-pl.json

Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-11-09 23:03:00 +01:00
milkmaker b0f5aee628 [Web] Updated lang.pl-pl.json (#6898)
Co-authored-by: Monika Bark <rychert.monika@wp.pl>
2025-11-05 17:37:26 +01:00
milkmaker d3065612fd update postscreen_access.cidr (#6886) 2025-11-03 21:07:40 +01:00
Josh 9912e41f78 [Web] Correct order of Dansk/Danish in UI (#6887) 2025-11-03 21:07:20 +01:00
milkmaker 04200c99a4 Translations update from Weblate (#6880)
* [Web] Updated lang.vi-vn.json

Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Updated lang.nb-no.json

Co-authored-by: Runar Ingebrigtsen <runar@rin.no>

---------

Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
Co-authored-by: Runar Ingebrigtsen <runar@rin.no>
2025-10-27 20:00:29 +01:00
FreddleSpl0it 45666d2c4e Merge pull request #6874 from mailcow/staging
Update 2025-10a Hotfix
2025-10-24 08:27:23 +02:00
FreddleSpl0it 9a806e64ce [PHP] remove opcache.revalidate_freq 2025-10-24 08:18:49 +02:00
Markku Post 95e0608749 [Web] Disable login on autodiscover/autoconfig domains
Autodiscover and autoconfig domains (autodiscover.*, autoconfig.*) are intended solely for client autoconfiguration endpoints and should not display the mailcow login page. This change check the hostname and disables unauthenticated users from seeing the login page on those domains; HTTP 404 response is returned when necessary.
2025-10-24 06:03:40 +03:00
FreddleSpl0it 22a09b9795 [PHP] re-add opcache.revalidate_freq setting 2025-10-23 15:16:24 +02:00
FreddleSpl0it 04d5c43550 Merge pull request #6847 from patschi/disable-opcache-jit
Disable PHP opcache.jit
2025-10-23 09:32:02 +02:00
milkmaker fbcb8cbeb9 [Web] Updated lang.vi-vn.json (#6861)
Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
2025-10-21 18:03:22 +02:00
renovate[bot] 0338a36ecf chore(deps): update alpine docker tag to v3.22 (#6417)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 18:03:02 +02:00
milkmaker 23fb5e2fca Add Vietnamese language (#6854)
* [Web] Updated lang.vi-vn.json

[Web] Added lang.vi-vn.json

Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: milkmaker <milkmaker@mailcow.de>

* Add Vietnamese language

---------

Co-authored-by: Nguyễn Thái Dũng <nguyenthaidung.work+mailcow.email@gmail.com>
Co-authored-by: Peter <magic@kthx.at>
2025-10-20 18:35:00 +02:00
renovate[bot] 3507ff2773 chore(deps): update devops-infra/action-pull-request action to v1.0.2 (#6850) 2025-10-19 23:18:09 +02:00
Patrik Kernstock a4970397f1 Disable PHP opcache.jit 2025-10-17 13:17:56 +02:00
renovate[bot] 4132f6bd48 chore(deps): update devops-infra/action-pull-request action to v1 (#6840) 2025-10-16 07:28:57 +02:00
FreddleSpl0it 586b3a2ed1 Merge pull request #6838 from mailcow/staging
Update 2025-10
2025-10-15 08:11:08 +02:00
FreddleSpl0it 6af2addf3c [PHPFPM] Update Image to Version 1.94 2025-10-14 10:25:06 +02:00
FreddleSpl0it f6eed6c441 Merge pull request #6836 from mailcow/fix/6802
[Web] Add password verification when setting recovery email
2025-10-13 12:10:57 +02:00
FreddleSpl0it b85837c803 [Web] Add password verification when setting recovery email 2025-10-13 12:05:17 +02:00
FreddleSpl0it 653fc40d4c Merge pull request #6783 from patschi/phpfpm-moar-speeeed
Optimize phpfpm opcache: more aggressive caching, enable JIT
2025-10-13 11:43:07 +02:00
FreddleSpl0it c17d80a6fd Merge pull request #6821 from tjmills-dev/feat/show-app-passwd-logins
Show app passwords for successful logins on user page
2025-10-13 11:41:39 +02:00
FreddleSpl0it 980bfa3aa0 Merge pull request #6696 from mailcow/renovate/krakjoe-apcu-5.x
chore(deps): update dependency krakjoe/apcu to v5.1.27
2025-10-10 14:07:24 +02:00
FreddleSpl0it 664a954393 Merge pull request #6798 from mailcow/renovate/php-pecl-mail-mailparse-3.x
chore(deps): update dependency php/pecl-mail-mailparse to v3.1.9
2025-10-10 14:07:05 +02:00
FreddleSpl0it d5a27c4ccb Merge pull request #6830 from mailcow/feat/rspamd-3.13.2
[Rspamd] Update to 3.13.2
2025-10-10 13:10:54 +02:00
FreddleSpl0it 6a8a2e2136 Merge pull request #6829 from mailcow/feat/redis-7.4.6
[Redis] Update to Redis 7.4.6
2025-10-10 13:09:47 +02:00
FreddleSpl0it b859a52b8e Merge pull request #6828 from mailcow/fix/6818
[Web] Fix SOGo redirection after login
2025-10-10 13:08:22 +02:00
FreddleSpl0it 10e0c42eff Merge pull request #6797 from Hobby-Student/fix/autodiscover-with-ldap-attribute-mapping
fix autodiscover when using ldap with attribute mapping templates
2025-10-10 13:07:58 +02:00
FreddleSpl0it f47df263d7 [Rspamd] Update to 3.13.2 2025-10-10 13:04:01 +02:00
FreddleSpl0it 2642d9109e [Redis] Update to Redis 7.4.6 2025-10-10 12:48:57 +02:00
FreddleSpl0it 6708b94ebb [Web] Fix SOGo redirection after login 2025-10-10 10:05:56 +02:00
milkmaker 79cf0abc6e [Web] Updated lang.zh-cn.json (#6826)
Co-authored-by: Easton Man <me@eastonman.com>
2025-10-09 19:54:12 +02:00
Olavo Rocha Neto 7de70322d6 Update pt-br lang (#6803)
* [Web] Updated lang.si-si.json

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

* Update pt-br lang

* Complimentary adjustments

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

This reverts commit b23848e0f2.

---------

Co-authored-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-10-09 19:36:36 +02:00
DerLinkman 417835dea8 netfilter: improve logging and mark iptables-legacy as deprecated 2025-10-09 16:37:05 +02:00
Thomas Mills 3dcacc4187 Change icon to filled key 2025-10-09 11:39:24 +01:00
Thomas Mills 69f0552d4f Decrease margin size 2025-10-08 21:48:03 +01:00
Thomas Mills c443a9400a Move flag in front of IP 2025-10-08 21:48:03 +01:00
Thomas Mills 5c9f387d94 Add margin 2025-10-08 21:48:02 +01:00
Thomas Mills e9414d17e4 Show app password for last logins 2025-10-08 21:47:50 +01:00
FreddleSpl0it 6bfa58611e Merge pull request #6813 from mailcow/staging
Update 2025-09c
2025-10-07 11:43:15 +02:00
FreddleSpl0it df4d3bb6e0 [Web] Fix dashboard host stats 2025-10-07 11:41:57 +02:00
FreddleSpl0it e31b6d9a07 Merge pull request #6812 from mailcow/staging
Update 2025-09c
2025-10-07 10:47:19 +02:00
FreddleSpl0it 455ef084b4 [Web] clear old app_passwd log entries 2025-10-07 10:37:44 +02:00
FreddleSpl0it c2948735f2 [Web] clear old app_passwd log entries 2025-10-07 10:18:07 +02:00
FreddleSpl0it 24c62b2f09 Merge pull request #6810 from mailcow/staging
Update 2025-09c
2025-10-07 10:03:47 +02:00
FreddleSpl0it 1ef0149076 [Web] make SameSite policy and cookie name configurable via vars.local.inc 2025-10-06 11:00:03 +02:00
FreddleSpl0it 922d173540 [Web] include hostname in default website title 2025-10-06 10:58:35 +02:00
renovate[bot] fd088cb504 chore(deps): update actions/stale action to v10.1.0 (#6806) 2025-10-04 14:13:48 +02:00
Valentin Brandl 721ee2394e Update variable name for prometheus-exporter security token (#6776)
* update variable name for prometheus-exporter security token

* update `MAILCOW_EXPORTER_TOKEN_DISABLE` variable name
2025-10-03 18:03:03 +01:00
Colin Kubon c217be06c6 scripts: make sure /etc/docker exists (#6791) 2025-10-02 09:24:06 +02:00
Jonas 871c422ec1 Fix typos in config (#6792)
Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-10-02 09:22:35 +02:00
sdsys-ch 3cc28af607 [Helper] Fix cold-standby script to support digits and override files (#6800)
This commit fixes two bugs in the cold-standby script:

1. Support digits in COMPOSE_PROJECT_NAME
   The script was stripping digits from COMPOSE_PROJECT_NAME, while
   backup_and_restore.sh (fixed in a71d991c) correctly supports them.
   Added '0-9' to the tr character set to align behavior.

2. Support docker-compose.override.yml on remote
   Lines 172 and 287 explicitly used '-f docker-compose.yml' which
   causes Docker Compose to ignore docker-compose.override.yml even
   when present. Changed to 'cd && compose' pattern (matching line 296)
   to auto-discover override files.

   Impact: Users with custom volumes/services in override file would
   experience silent failures - volumes not created, images not pulled,
   data syncing to wrong locations.

Both fixes ensure cold-standby works correctly with standard Docker
Compose conventions and user customizations.

Co-authored-by: Christophe Neuerburg <c.neuerburg@sdsys.ch>
2025-10-02 09:21:26 +02:00
milkmaker 796e131c3a update postscreen_access.cidr (#6801) 2025-10-01 11:14:57 +02:00
renovate[bot] dd160cd508 Update dependency php/pecl-mail-mailparse to v3.1.9
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-09-30 13:42:36 +00:00
Hobby-Student 732b321962 fix autodiscover when using ldap with attribute mapping templates 2025-09-30 14:37:19 +02:00
milkmaker c51a769aec [Web] Updated lang.si-si.json (#6794)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-29 18:10:39 +02:00
FreddleSpl0it 45a61755a5 Merge pull request #6777 from patschi/enable-https-redirect-default
Enable HTTPS redirect by default on new setups
2025-09-29 11:56:46 +02:00
FreddleSpl0it 769c57c355 Merge pull request #6779 from patschi/remove-debug-consolelog
Remove debug console.log calls
2025-09-29 11:54:23 +02:00
FreddleSpl0it 2e7eb7c0fd Merge pull request #6780 from patschi/fix-pwcomplexity-apppasswds
Fixed password complexity check for AppPasswords creation/edit
2025-09-29 11:53:26 +02:00
FreddleSpl0it 4c83147d01 Merge pull request #6781 from patschi/pw-field-name-consistency
Rename password fields for AppPasswords same way for consistency
2025-09-29 11:52:08 +02:00
FreddleSpl0it ca0bec4fc2 Merge pull request #6782 from patschi/fix-footer-escape
Fixed wrong footer escaping for certain characters
2025-09-29 11:45:42 +02:00
FreddleSpl0it 6f50dd17da Merge pull request #6786 from patschi/fix-sql-typo
Fix several SQL statements
2025-09-29 11:39:30 +02:00
FreddleSpl0it 4a331929d0 Merge pull request #6787 from patschi/hide-relayhosts-if-no-acl
Hide relayhosts when ACL does not allow
2025-09-29 11:38:52 +02:00
FreddleSpl0it 748bc893b6 Merge pull request #6788 from patschi/lastmodified-default-value
Show "Never" by default if no last-modified date saved
2025-09-29 11:37:52 +02:00
FreddleSpl0it e462602ddc Merge pull request #6789 from patschi/domain-descr-readonly-when-no-acl
Make domain description field readonly when no ACL
2025-09-29 11:36:42 +02:00
milkmaker 4e0f435d12 [Web] Updated lang.si-si.json (#6793) 2025-09-28 15:12:14 +02:00
milkmaker 46f0581936 [Web] Updated lang.si-si.json (#6790)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-26 19:14:07 +02:00
Patrik Kernstock 20f04ecf6b Make domain description field readonly when no ACL 2025-09-26 17:13:24 +02:00
Patrik Kernstock ff43799763 Show "Never" by default if no last-modified date 2025-09-26 17:02:22 +02:00
Patrik Kernstock 85ca197615 Hide relayhosts when ACL does not allow 2025-09-26 16:50:58 +02:00
Patrik Kernstock d06d23bbaf Fix several SQL statements 2025-09-26 14:58:04 +02:00
Patrik Kernstock 702ed85dfd Fixed footer escaping 2025-09-26 14:41:19 +02:00
milkmaker 8abe74a562 [Web] Updated lang.si-si.json (#6785)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-26 10:57:32 +02:00
Patrik Kernstock 2f8a181281 Fix comments, added some comments 2025-09-26 04:16:57 +02:00
Patrik Kernstock 5c5287ca21 Fixed wrong footer escaping 2025-09-26 04:04:45 +02:00
Patrik Kernstock 83ba8d5840 Optimize opcache settings, enable JIT 2025-09-26 04:01:17 +02:00
Patrik Kernstock ce219668cf Rename AppPasswds fields uniquely like 'add' 2025-09-26 03:37:49 +02:00
Patrik Kernstock 5b1b49a418 Fixed password complexity check for AppPasswords 2025-09-26 02:37:02 +02:00
Patrik Kernstock 8978a9ad79 Remove debug console.log() lines 2025-09-26 02:13:22 +02:00
Patrik Kernstock 5f4a4fd759 Removed new lines for consistency 2025-09-26 01:14:33 +02:00
Patrik Kernstock 171c591da4 Enable REDIRECT_HTTP=y by default 2025-09-26 01:14:23 +02:00
FreddleSpl0it 9133b9899c Merge pull request #6764 from patschi/tools-install-clear-msg
Clearer message to install required tool, e.g. jq
2025-09-25 09:00:41 +02:00
FreddleSpl0it 701c9fb1b4 Merge pull request #6772 from patschi/update-issue-template
Update GitHub's issue template
2025-09-25 08:53:18 +02:00
Patrik Kernstock eabd22188b Re-intend checkboxes 2025-09-24 21:20:48 +02:00
Patrik Kernstock 7028619742 Update GitHub's issue template 2025-09-24 21:17:29 +02:00
Patrik Kernstock c915bf2ee2 Add docs link to get_installed_tools() message 2025-09-24 19:06:47 +02:00
milkmaker 011edd5ac9 [Web] Updated lang.si-si.json (#6771)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-24 17:36:09 +02:00
FreddleSpl0it 7ba3de4ced Merge pull request #6767 from mailcow/fix/rename-phpsessid
[Web] Rename PHP Cookie to MCSESSID
2025-09-23 12:41:01 +02:00
FreddleSpl0it 8ead77083f [Web] Rename PHP Cookie to MCSESSID 2025-09-23 12:39:48 +02:00
FreddleSpl0it b2774fb50b Merge pull request #6766 from mailcow/fix/samesite-cookie
[Web] set cookie SameSite attribute to Lax
2025-09-23 12:36:11 +02:00
FreddleSpl0it 4440bd46ad [Web] set cookie SameSite attribute to Lax 2025-09-23 12:24:25 +02:00
FreddleSpl0it 28985973eb [Web] Revert - allow "*" as wildcard domain 2025-09-23 10:07:33 +02:00
Christian 🦄 f2c4697ca3 Fixed typo in lang de-de (#6765) 2025-09-22 22:45:54 +01:00
Patrik Kernstock 383b5affb5 More clearer message to install required tool 2025-09-22 19:49:31 +02:00
FreddleSpl0it ed4dcff63b [Web] allow "*" as wildcard domain 2025-09-22 14:42:14 +02:00
FreddleSpl0it caca32bbba Merge pull request #6759 from mailcow/fix/6720
[Web] Allow wildcard subdomains for MTA-STS
2025-09-22 14:20:36 +02:00
FreddleSpl0it d31e74c778 Merge pull request #6760 from mailcow/fix/6739
[Web] Remove Port from HTTP_HOST
2025-09-22 14:20:15 +02:00
FreddleSpl0it 6c00e29276 Merge pull request #6762 from mailcow/fix/6740
[Nginx] do not invert ENABLE_IPV6
2025-09-22 14:19:57 +02:00
FreddleSpl0it 9940c503a2 [Nginx] do not invert ENABLE_IPV6 2025-09-22 14:16:42 +02:00
FreddleSpl0it 4b2862cb3c [Web] Remove Port from HTTP_HOST 2025-09-22 14:07:17 +02:00
FreddleSpl0it a36485f0f1 [Web] Allow wildcard subdomains for MTA-STS 2025-09-22 13:55:18 +02:00
FreddleSpl0it 78168ee80a Merge pull request #6758 from mailcow/feat/sogo-url-encryption
[SOGo][Web] SOGo URL Encryption support
2025-09-22 13:32:58 +02:00
FreddleSpl0it 610609378f [SOGo][Web] Set URL encryption key in mailcow.conf 2025-09-22 12:58:05 +02:00
FreddleSpl0it 260906e350 [SOGo][Web] Enable SOGo URL Encryption 2025-09-22 12:28:09 +02:00
milkmaker 2891bbf82a Translations update from Weblate (#6749)
* [Web] Updated lang.cs-cz.json

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

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

Co-authored-by: Edgars Andersons <Edgars+Mailcow+Weblate@gaitenis.id.lv>

---------

Co-authored-by: Filip Hajny <filip@hajny.net>
Co-authored-by: Edgars Andersons <Edgars+Mailcow+Weblate@gaitenis.id.lv>
2025-09-16 18:24:12 +02:00
milkmaker eb26bcbc94 Translations update from Weblate (#6743)
* [Web] Updated lang.zh-cn.json

Co-authored-by: Easton Man <me@eastonman.com>

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

[Web] Updated lang.si-si.json

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

---------

Co-authored-by: Easton Man <me@eastonman.com>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-09-13 21:41:59 +02:00
FreddleSpl0it ef0f366d1c Merge pull request #6738 from mailcow/staging
Update 2025-09b
2025-09-12 11:29:26 +02:00
patr_ 84e230de8f [Nginx] fix: Disable IPv6 support in Nginx configuration (#6736)
Co-authored-by: patr_ <patbernh@gmail.com>
2025-09-12 11:17:18 +02:00
FreddleSpl0it f67a12d157 Merge pull request #6726 from mailcow/fix/6135
[Web] remove unused bcc dest column from alias table
2025-09-11 13:50:25 +02:00
FreddleSpl0it 34b48eedfc Merge pull request #6727 from mailcow/fix/6423
[SOGo] Drop deprecated `sogo_update_password` sql trigger if it still exists
2025-09-11 13:50:05 +02:00
FreddleSpl0it 0d900d4fc8 [SOGo] Drop deprecated sogo_update_password sql trigger if it still exists 2025-09-11 11:01:50 +02:00
FreddleSpl0it 642ac6d02c [Web] remove unused bcc dest column from alias table 2025-09-11 10:34:35 +02:00
DerLinkman 4db1569c93 Squashed commit of the following:
commit 94c1a6c4e1
Author: DerLinkman <niklas.meyer@servercow.de>
Date:   Wed Sep 10 16:20:58 2025 +0200

    scripts:  ipv6_controller improvement + fix modules handling (#6722)

    * Fix subscript handling for modules

    * ipv6: detect case when link local is present

    * v6-controller: removed fixed-cidr for docker 28+
2025-09-10 16:22:19 +02:00
DerLinkman 94c1a6c4e1 scripts: ipv6_controller improvement + fix modules handling (#6722)
* Fix subscript handling for modules

* ipv6: detect case when link local is present

* v6-controller: removed fixed-cidr for docker 28+
2025-09-10 16:20:58 +02:00
FreddleSpl0it 7ce3b0faed Merge pull request #6719 from mailcow/staging
Update 2025-09
2025-09-10 11:18:17 +02:00
FreddleSpl0it 262fe04286 change MAJOR_VERSIONS 2025-08 to 2025-09 2025-09-10 11:17:34 +02:00
FreddleSpl0it b1c088a57f Merge pull request #6718 from mailcow/staging
Update 2025-09
2025-09-10 11:05:09 +02:00
FreddleSpl0it 1c438330c6 [postfix-tlspol] build with NOOPT=1 for wider CPU compatibility 2025-09-10 10:14:37 +02:00
FreddleSpl0it 8cb25709ae [Clamd] update to 1.71 2025-09-10 08:23:22 +02:00
FreddleSpl0it 221f2989b0 Merge pull request #6698 from mailcow/6644_clamd-tmp-folder-naming-change
Changed clamavs tmp folder structure
2025-09-09 13:12:54 +02:00
FreddleSpl0it 3d05207bc7 Merge pull request #6717 from mailcow/fix/6664
[Rspamd] only recreate external_services.conf file if it was deleted
2025-09-09 12:53:03 +02:00
FreddleSpl0it 8c8497d885 [Rspamd] only recreate external_services.conf file if it was deleted 2025-09-09 12:50:19 +02:00
FreddleSpl0it 56d083ced4 Merge pull request #6682 from psuet/fix/imapsync
fix: imapsync gets correct timeouts from imapsync_runner.pl
2025-09-09 12:34:02 +02:00
FreddleSpl0it a90b3544a7 Merge pull request #6651 from psuet/fix/php-warnings
Fix multiple PHP Warnings present in "stock" installation
2025-09-09 12:27:14 +02:00
FreddleSpl0it 08aea7fb26 Merge pull request #6716 from mailcow/fix/6610
Prevent user login if protocol access has been disabled
2025-09-09 12:16:09 +02:00
FreddleSpl0it 13f7f9830b Prevent user login if protocol access has been disabled 2025-09-09 12:11:19 +02:00
FreddleSpl0it 2f75039194 Merge pull request #6715 from mailcow/fix/version-tag
[Web] Only include mailcow_info in JS when mailcow_cc_username is set
2025-09-09 11:10:25 +02:00
FreddleSpl0it 1e192e14f4 [Web] Only include mailcow_info in JS when mailcow_cc_username is set 2025-09-09 11:09:09 +02:00
FreddleSpl0it 9cd1f931fc Merge pull request #6714 from mailcow/fix/domain-wide-footer
[RSPAMD] Add boundary if present when applying domain-wide footer
2025-09-09 10:57:18 +02:00
FreddleSpl0it 8d7235b535 [RSPAMD] Add boundary if present when applying domain-wide footer 2025-09-09 10:52:19 +02:00
FreddleSpl0it 8446abd484 Merge pull request #6713 from mailcow/feat/internal-alias
[Rspamd][Web] Internal alias support
2025-09-09 10:45:56 +02:00
FreddleSpl0it f67c0530f5 [Rspamd][Web] Internal alias support 2025-09-09 10:37:54 +02:00
Dmitriy Alekseev 06db1d6a72 [Rspamd] Do not increment rate limit for emails from user to himself (#6706)
* [Rspamd] Do not increment rate limit for emails from user to himself

* Lowercase username and recipient address for comparison

Normalize username and recipient address comparison to lowercase.
2025-09-05 03:37:59 +02:00
renovate[bot] 81775ab4d5 chore(deps): update actions/stale action to v10 (#6708)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 18:02:11 +02:00
DerLinkman 34877ecf9c watchdog: added postfix-tlspol check (#6691) 2025-09-03 08:18:04 +02:00
milkmaker dbde144014 update postscreen_access.cidr (#6703) 2025-09-03 08:14:14 +02:00
DerLinkman 5361a4a4ee updated sponsors in Readme.md 2025-09-01 12:32:27 +02:00
milkmaker 0997548d7f Translations update from Weblate (#6699)
* [Web] Updated lang.de-de.json

Co-authored-by: Peter <magic@kthx.at>

* [Web] Updated lang.en-gb.json

Co-authored-by: Peter <magic@kthx.at>

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

[Web] Language file updated by 'Cleanup translation files' addon

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>

---------

Co-authored-by: Peter <magic@kthx.at>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2025-08-29 22:38:06 +02:00
Sándor 921de02a2b Update lang.hu-hu.json (#6697)
Extended Hungarian translation
2025-08-29 18:32:17 +02:00
Peter 48e90a72dc Changed clamavs tmp folder structure 2025-08-29 18:27:34 +02:00
renovate[bot] c0b7a98e6c chore(deps): update actions/checkout action to v5 (#6671)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-29 18:23:56 +02:00
renovate[bot] 6dc90186f9 chore(deps): update dependency krakjoe/apcu to v5.1.27
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2025-08-29 16:22:28 +00:00
DerLinkman 0b0a65a3f3 web: rename login placeholder for mailbox to email address (#6693) 2025-08-28 17:02:16 +02:00
DerLinkman 6c5d82c4df expanded postscreen whitelist with modern freemailers + included checks.mailcow.email 2025-08-28 14:06:17 +02:00
maxi322 5e66ffa366 watchdog: use dig instead of check_dns (#6685)
* watchdog: use dig instead of check_dns

check_dns is slower and uses more system resources,
dig wrapped in a script is a more performant approach and uses
fewer system resources

* added debug mode + compose image bump

---------

Co-authored-by: maxi322 <maxi322@users.noreply.github.com>
Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-08-28 12:56:37 +02:00
Sajjad hassanzadeh 4d88e19106 Feat/prometheus-exporter : Add prometheus exporter and grafana dashboard for mailcow. (#6314)
* add : readme for prometheus exporter configs

* add : grafana dashboard json file

* add: prometheus exporter service on docker-compose.override.yml

* migrate: doc files into docs.mailcow.email project

* add : security configs in prometheus exporter compose file

* add : explain more in my comment part in prometheus override compose file

* remove : mailcow dockerized docs

---------

Co-authored-by: Saji <saji@abrnoc>
2025-08-28 12:36:43 +02:00
DerLinkman 29e28b47ed compose: add depends on for postfix-tlspol 2025-08-28 10:20:21 +02:00
DerLinkman 1cb38bacdb Postfix: Split TLSPol companion app into separate container (#6688)
* postfix: split postfix-tlspol service into new container

* postfix-tls-pol: added debug mode

* pf-tlspol: removed obsoleted standalone conf from Dockerfiles

* pf-tlspol: use git instead of wget
2025-08-28 10:18:18 +02:00
DerLinkman 169aafec50 compose: fix dovecot image tag 2025-08-27 14:09:29 +02:00
DerLinkman 3826c4b5be fix postfix tlspol missing folders for config 2025-08-26 10:10:16 +02:00
DerLinkman e1410baaeb fix gitignore 2025-08-26 09:58:18 +02:00
DerLinkman c39712af67 pf/php: add mta-sts support (outbound) (#6686)
* added mta-sts-resolver into postfix config + daemon

* [Web] Add MTA-STS support

* [Web] Fix mta-sts server_name

* updated .gitignore

* [ACME] fetch cert for mta-sts subdomain

* [Web] change MTA-STS id to human-readable timestamp

* [Web] Remove MTA-STS version STSv2

* [Web] Fix MTA-STS DNS check

* [Web] add max_age limit for MTA-STS policy

* Added tooltips and info texts to mta-sts webui page

* postfix: replace mta-sts-resolver with postfix-tlspol

---------

Co-authored-by: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com>
2025-08-26 09:57:05 +02:00
Paul Sütterlin 53c35493a5 fix: imapsync gets correct timeouts
Previously imapsync only attached the timeout1 / timeout2 arguments if the argument was negative (which is not even possible). Now the argument is added for every positive number.

Fixes #6590
2025-08-21 18:36:01 +00:00
renovate[bot] af871fdacb chore(deps): update devops-infra/action-pull-request action to v0.6.1 (#6676)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 18:47:28 +02:00
DerLinkman 2b93b59cdd db: change qhash varchar to 64 instead of 255 2025-08-06 16:11:23 +02:00
Christoph Lechleitner 2b2da1679e [DB][Web] optimize qhandler by keeping SHA2 in new column qhash (#6556)
* [DB][Web] optimize qhandler by keeping SHA2(id+qid) in new column quarantine.qhash, for feature #6555, might also help with #6361

* rspamd: only add qhash to new entries while passing rspamd not all existing

* compose: bump dovecot image + push to registry

---------

Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-08-06 16:02:25 +02:00
CodeShell 8cdb0b869e fixed favicon.png (#6570) 2025-08-06 09:42:43 +02:00
FreddleSpl0it 1e42b8dd21 [Web] Add delimiter_action to mailbox and mailbox_template add/edit admin forms (#6620) 2025-08-06 09:40:47 +02:00
Dmitriy Alekseev 842cb235b6 [Rspamd] Fill module name for set_pre_result actions (#6630)
* [Rspamd] Fill module name for postmaster handler

* Update rspamd.local.lua
2025-08-06 09:38:22 +02:00
DerLinkman e91d678bd1 fix docker version detection 2025-08-06 09:36:05 +02:00
DerLinkman ef5739c32f add 2025-08 as breaking major release 2025-08-06 08:39:21 +02:00
DerLinkman 88bf9b02e1 core: modules splitting + ipv6 nat rewrite (#6634)
* ipv6: added ipv6 detection + removed ip6 nat container

* nginx: renamed DISABLE_IPv6 to ENABLE_IPV6 to align

* initial commit for script overhauls

* rewrite to scripts after testing (improved error handling)

* fixed missing fi in update.sh

* fixed/added comments for modules

* fix broken EXIT_CODE var handling

* added jq as dependancy

* fixed docker version check for daemon

* improved _modules handling while running

* reintegrated module loading (update.sh)

* added error handling for blank daemon.json

* adapted removal of ACME_CONTACT for nightly

* move detect_major_update func to core submodule

* removed unnecessary message on every call of function

* Update _modules/scripts/new_options.sh

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update _modules/scripts/core.sh

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* improve ENABLE_IPV6 check in nginx bootstrap

* improve detection of ENABLE_IPV6

* ip6_controller: moved docker major detection upwards

* Update _modules/scripts/new_options.sh

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update _modules/scripts/new_options.sh

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* reuse DOCKER_MAJOR Variable in ip6_controller

* fix some smaller typos in update.sh

* smaller bugfixes in submodules

* completely remove ACME_CONTACT Variable

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-06 08:36:40 +02:00
renovate[bot] 3803b5d351 Update dependency php-memcached-dev/php-memcached to v3.3.0 (#6638)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 08:35:14 +02:00
renovate[bot] 14d58c8163 Update dependency phpredis/phpredis to v6.2.0 (#6639)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 08:34:53 +02:00
renovate[bot] 728fcdb375 Update dependency tianon/gosu to v1.17 (#6640)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 08:34:30 +02:00
renovate[bot] 1fc36263dc chore(deps): update dependency krakjoe/apcu to v5.1.26 (#6656)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 08:33:41 +02:00
Markus Machatschek 69420113f7 rspamd: update rspamd to 3.12.1 (#6643)
* rspamd: update rspamd to 3.12.1

* compose: correct rspamd tag + pushed image

---------

Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-08-06 08:33:11 +02:00
DerLinkman 360fe03497 sogo: update to 5.12.3 2025-08-05 16:01:47 +02:00
milkmaker 7557802933 [Web] Updated lang.de-de.json (#6661)
Co-authored-by: whitehotaru <whitehotaru@posteo.net>
2025-08-05 06:37:55 +02:00
milkmaker 2e9ba1e9b3 update postscreen_access.cidr (#6660) 2025-08-05 00:37:47 +02:00
milkmaker 795bcdc5d2 [Web] Updated lang.ru-ru.json (#6654) 2025-07-27 19:30:10 +02:00
Paul Sütterlin ad9b328ed5 fix: Undefined array key "pending_tfa_methods" in
/web/inc/footer.inc.php
on line
29
2025-07-26 01:12:48 +00:00
Paul Sütterlin 3d5b57889a fix: Empty App Links
The return value of the function caused a warning
in header.inc.php:42 if no additional links were set.

header.inc.php is the only caller of this function,
thus it is safe to return an empty array here.
2025-07-26 01:08:28 +00:00
Paul Sütterlin 6b8e981bdc fix: Only use HTTP_ORIGIN if it is sent. 2025-07-26 01:06:24 +00:00
milkmaker 2f1eb4b004 Translations update from Weblate (#6649)
* [Web] Updated lang.es-es.json

Co-authored-by: sariegos <informatica@sariegos.es>

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

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

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

[Web] Updated lang.pt-br.json

Co-authored-by: Bruno Zouein Pereira <zopostyle@gmail.com>
Co-authored-by: Peter <magic@kthx.at>

---------

Co-authored-by: sariegos <informatica@sariegos.es>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: Bruno Zouein Pereira <zopostyle@gmail.com>
Co-authored-by: Peter <magic@kthx.at>
2025-07-24 21:42:00 +02:00
milkmaker 3ee3d7d969 Translations update from Weblate (#6637)
* [Web] Language file updated by 'Cleanup translation files' addon

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

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

Co-authored-by: Anonymous <noreply@weblate.org>

* [Web] Updated lang.ca-es.json

Co-authored-by: Anonymous <noreply@weblate.org>

---------

Co-authored-by: Anonymous <noreply@weblate.org>
2025-07-16 20:31:58 +02:00
Denis Evers 95eb350f15 [netfilter] fix negative timer, no unbanning of IPs (#6575)
* [netfilter] added debug logs and updated autopurge

* updated "Allow/Blacklist" terms

* netfilter: bumped compose version

* netfilter: changed black/whitelist terms in code

---------

Co-authored-by: Denis Evers <git@evers.sh>
Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-07-16 10:08:55 +02:00
Peter 1e5fcfe392 Bulgarian language added (#6623) 2025-07-16 09:29:35 +02:00
552 changed files with 33181 additions and 24243 deletions
+37 -32
View File
@@ -11,22 +11,35 @@ body:
required: true
- type: checkboxes
attributes:
label: I've found a bug and checked that ...
description: Prior to placing the issue, please check following:** *(fill out each checkbox with an `X` once done)*
label: Checklist prior issue creation
description: Prior to creating the issue...
options:
- label: ... I understand that not following the below instructions will result in immediate closure and/or deletion of my issue.
- label: I understand that failure to follow below instructions may cause this issue to be closed.
required: true
- label: ... I have understood that this bug report is dedicated for bugs, and not for support-related inquiries.
- label: I understand that vague, incomplete or inaccurate information may cause this issue to be closed.
required: true
- label: ... I have understood that answers are voluntary and community-driven, and not commercial support.
- label: I understand that this form is intended solely for reporting software bugs and not for support-related inquiries.
required: true
- label: ... I have verified that my issue has not been already answered in the past. I also checked previous [issues](https://github.com/mailcow/mailcow-dockerized/issues).
- label: I understand that all responses are voluntary and community-driven, and do not constitute commercial support.
required: true
- label: I confirm that I have reviewed previous [issues](https://github.com/mailcow/mailcow-dockerized/issues) to ensure this matter has not already been addressed.
required: true
- label: I confirm that my environment meets all [prerequisite requirements](https://docs.mailcow.email/getstarted/prerequisite-system/) as specified in the official documentation.
required: true
- type: textarea
attributes:
label: Description
description: Please provide a brief description of the bug in 1-2 sentences. If applicable, add screenshots to help explain your problem. Very useful for bugs in mailcow UI.
render: plain text
description: Please provide a brief description of the bug. If applicable, add screenshots to help explain your problem. (Very useful for bugs in mailcow UI.)
validations:
required: true
- type: textarea
attributes:
label: "Steps to reproduce:"
description: "Please describe the steps to reproduce the bug. Screenshots can be added, if helpful."
placeholder: |-
1. ...
2. ...
3. ...
validations:
required: true
- type: textarea
@@ -36,45 +49,36 @@ body:
render: plain text
validations:
required: true
- type: textarea
attributes:
label: "Steps to reproduce:"
description: "Please describe the steps to reproduce the bug. Screenshots can be added, if helpful."
render: plain text
placeholder: |-
1. ...
2. ...
3. ...
validations:
required: true
- type: markdown
attributes:
value: |
## System information
### In this stage we would kindly ask you to attach general system information about your setup.
In this stage we would kindly ask you to attach general system information about your setup.
- type: dropdown
attributes:
label: "Which branch are you using?"
description: "#### `git rev-parse --abbrev-ref HEAD`"
description: "#### Run: `git rev-parse --abbrev-ref HEAD`"
multiple: false
options:
- master
- master (stable)
- staging
- nightly
validations:
required: true
- type: dropdown
attributes:
label: "Which architecture are you using?"
description: "#### `uname -m`"
description: "#### Run: `uname -m`"
multiple: false
options:
- x86
- x86_64
- ARM64 (aarch64)
validations:
required: true
- type: input
attributes:
label: "Operating System:"
description: "#### Run: `lsb_release -ds`"
placeholder: "e.g. Ubuntu 22.04 LTS"
validations:
required: true
@@ -93,43 +97,44 @@ body:
- type: input
attributes:
label: "Virtualization technology:"
placeholder: "KVM, VMware, Xen, etc - **LXC and OpenVZ are not supported**"
description: "LXC and OpenVZ are not supported!"
placeholder: "KVM, VMware ESXi, Xen, etc"
validations:
required: true
- type: input
attributes:
label: "Docker version:"
description: "#### `docker version`"
description: "#### Run: `docker version`"
placeholder: "20.10.21"
validations:
required: true
- type: input
attributes:
label: "docker-compose version or docker compose version:"
description: "#### `docker-compose version` or `docker compose version`"
description: "#### Run: `docker-compose version` or `docker compose version`"
placeholder: "v2.12.2"
validations:
required: true
- type: input
attributes:
label: "mailcow version:"
description: "#### ```git describe --tags `git rev-list --tags --max-count=1` ```"
placeholder: "2022-08"
description: "#### Run: ```git describe --tags `git rev-list --tags --max-count=1` ```"
placeholder: "2022-08x"
validations:
required: true
- type: input
attributes:
label: "Reverse proxy:"
placeholder: "e.g. Nginx/Traefik"
placeholder: "e.g. nginx/Traefik, or none"
validations:
required: true
- type: textarea
attributes:
label: "Logs of git diff:"
description: "#### Output of `git diff origin/master`, any other changes to the code? If so, **please post them**:"
description: "#### Output of `git diff origin/master`, any other changes to the code? Sanitize if needed. If so, **please post them**:"
render: plain text
validations:
required: true
required: false
- type: textarea
attributes:
label: "Logs of iptables -L -vn:"
@@ -14,7 +14,7 @@ jobs:
pull-requests: write
steps:
- name: Mark/Close Stale Issues and Pull Requests 🗑️
uses: actions/stale@v9.1.0
uses: actions/stale@v10.2.0
with:
repo-token: ${{ secrets.STALE_ACTION_PAT }}
days-before-stale: 60
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- "watchdog-mailcow"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup Docker
run: |
curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh
+2 -2
View File
@@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Run the Action
uses: devops-infra/action-pull-request@v0.6.0
uses: devops-infra/action-pull-request@v1.0.2
with:
github_token: ${{ secrets.PRTONIGHTLY_ACTION_PAT }}
title: Automatic PR to nightly from ${{ github.event.repository.updated_at}}
+5 -5
View File
@@ -13,24 +13,24 @@ jobs:
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
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
@@ -15,14 +15,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Generate postscreen_access.cidr
run: |
bash helper-scripts/update_postscreen_whitelist.sh
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.mailcow_action_Update_postscreen_access_cidr_pat }}
commit-message: update postscreen_access.cidr
+2
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/*
@@ -75,3 +76,4 @@ refresh_images.sh
update_diffs/
create_cold_standby.sh
!data/conf/nginx/mailcow_auth.conf
data/conf/postfix/postfix-tlspol
+7 -3
View File
@@ -1,11 +1,11 @@
# Contribution Guidelines
**_Last modified on 15th August 2024_**
**_Last modified on 12th November 2025_**
First of all, thank you for wanting to provide a bugfix or a new feature for the mailcow community, it's because of your help that the project can continue to grow!
As we want to keep mailcow's development structured we setup these Guidelines which helps you to create your issue/pull request accordingly.
**PLEASE NOTE, THAT WE MIGHT CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULLFIL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
**PLEASE NOTE, THAT WE WILL CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULFILL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
## Topics
@@ -27,14 +27,18 @@ However, please note the following regarding pull requests:
6. Please **ALWAYS** create the actual pull request against the staging branch and **NEVER** directly against the master branch. *If you forget to do this, our moobot will remind you to switch the branch to staging.*
7. Wait for a merge commit: It may happen that we do not accept your pull request immediately or sometimes not at all for various reasons. Please do not be disappointed if this is the case. We always endeavor to incorporate any meaningful changes from the community into the mailcow project.
8. If you are planning larger and therefore more complex pull requests, it would be advisable to first announce this in a separate issue and then start implementing it after the idea has been accepted in order to avoid unnecessary frustration and effort!
9. If your PR requires a Docker image rebuild (changes to Dockerfiles or files in data/Dockerfiles/), update the image tag in docker-compose.yml. Use the base-image versioning (e.g. ghcr.io/mailcow/sogo:5.12.4 → :5.12.5 for version bumps; append a letter for patch fixes, e.g. :5.12.4a). Follow this scheme.
---
## Issue Reporting
**_Last modified on 15th August 2024_**
**_Last modified on 12th November 2025_**
If you plan to report a issue within mailcow please read and understand the following rules:
### Security disclosures / Security-related fixes
- Security vulnerabilities and security fixes must always be reported confidentially first to the contact address specified in SECURITY.md before they are integrated, published, or publicly disclosed in issues/PRs. Please wait for a response from the specified contact to ensure coordinated and responsible disclosure.
### Issue Reporting Guidelines
1. **ONLY** use the issue tracker for bug reports or improvement requests and NOT for support questions. For support questions you can either contact the [mailcow community on Telegram](https://docs.mailcow.email/#community-support-and-chat) or the mailcow team directly in exchange for a [support fee](https://docs.mailcow.email/#commercial-support).
-3
View File
@@ -23,9 +23,6 @@ A big thank you to everyone supporting us on GitHub Sponsors—your contribution
<a href="https://www.maehdros.com/" target=_blank><img
src="https://avatars.githubusercontent.com/u/173894712" height="58"
/></a>
<a href="https://macarne.com/" target=_blank><img
src="https://avatars.githubusercontent.com/u/149550368?s=200&v=4" height="58"
/></a>
### 50$/Month Sponsors
<a href="https://github.com/vnukhr" target=_blank><img
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env bash
# _modules/scripts/core.sh
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
# ANSI color for red errors
RED='\e[31m'
GREEN='\e[32m'
YELLOW='\e[33m'
BLUE='\e[34m'
MAGENTA='\e[35m'
LIGHT_RED='\e[91m'
LIGHT_GREEN='\e[92m'
NC='\e[0m'
caller="${BASH_SOURCE[1]##*/}"
get_installed_tools(){
for bin in openssl curl docker git awk sha1sum grep cut jq; do
if [[ -z $(command -v ${bin}) ]]; then
echo "Error: Cannot find command '${bin}'. Cannot proceed."
echo "Solution: Please review system requirements and install requirements. Then, re-run the script."
echo "See System Requirements: https://docs.mailcow.email/getstarted/install/"
echo "Exiting..."
exit 1
fi
done
if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox grep detected, please install gnu grep, \"apk add --no-cache --upgrade grep\"${NC}"; exit 1; fi
# This will also cover sort
if cp --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox cp detected, please install coreutils, \"apk add --no-cache --upgrade coreutils\"${NC}"; exit 1; fi
if sed --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox sed detected, please install gnu sed, \"apk add --no-cache --upgrade sed\"${NC}"; exit 1; fi
}
get_docker_version(){
# Check Docker Version (need at least 24.X)
docker_version=$(docker version --format '{{.Server.Version}}' | cut -d '.' -f 1)
}
get_compose_type(){
if docker compose > /dev/null 2>&1; then
if docker compose version --short | grep -e "^[2-9]\." -e "^v[2-9]\." -e "^[1-9][0-9]\." -e "^v[1-9][0-9]\." > /dev/null 2>&1; then
COMPOSE_VERSION=native
COMPOSE_COMMAND="docker compose"
if [[ "$caller" == "update.sh" ]]; then
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=native/' "$SCRIPT_DIR/mailcow.conf"
fi
echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
sleep 2
echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
else
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
elif docker-compose > /dev/null 2>&1; then
if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then
if docker-compose version --short | grep -e "^[2-9]\." -e "^[1-9][0-9]\." > /dev/null 2>&1; then
COMPOSE_VERSION=standalone
COMPOSE_COMMAND="docker-compose"
if [[ "$caller" == "update.sh" ]]; then
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=standalone/' "$SCRIPT_DIR/mailcow.conf"
fi
echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
sleep 2
echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
else
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
fi
else
echo -e "\e[31mCannot find Docker Compose.\e[0m"
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
exit 1
fi
}
detect_bad_asn() {
echo -e "\e[33mDetecting if your IP is listed on Spamhaus Bad ASN List...\e[0m"
response=$(curl --connect-timeout 15 --max-time 30 -s -o /dev/null -w "%{http_code}" "https://asn-check.mailcow.email")
if [ "$response" -eq 503 ]; then
if [ -z "$SPAMHAUS_DQS_KEY" ]; then
echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
echo -e "\e[33mmailcow did not detected a value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf!\e[0m"
sleep 2
echo ""
echo -e "\e[33mTo use the Spamhaus DNS Blocklists again, you will need to create a FREE account for their Data Query Service (DQS) at: https://www.spamhaus.com/free-trial/sign-up-for-a-free-data-query-service-account\e[0m"
echo -e "\e[33mOnce done, enter your DQS API key in mailcow.conf and mailcow will do the rest for you!\e[0m"
echo ""
sleep 2
else
echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
echo -e "\e[32mmailcow detected a Value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf. Postfix will use DQS with the given API key...\e[0m"
fi
elif [ "$response" -eq 200 ]; then
echo -e "\e[33mCheck completed! Your IP is \e[32mclean\e[0m"
elif [ "$response" -eq 429 ]; then
echo -e "\e[33mCheck completed! \e[31mYour IP seems to be rate limited on the ASN Check service... please try again later!\e[0m"
else
echo -e "\e[31mCheck failed! \e[0mMaybe a DNS or Network problem?\e[0m"
fi
}
check_online_status() {
CHECK_ONLINE_DOMAINS=('https://github.com' 'https://hub.docker.com')
for domain in "${CHECK_ONLINE_DOMAINS[@]}"; do
if timeout 6 curl --head --silent --output /dev/null ${domain}; then
return 0
fi
done
return 1
}
prefetch_images() {
[[ -z ${BRANCH} ]] && { echo -e "\e[33m\nUnknown branch...\e[0m"; exit 1; }
git fetch origin #${BRANCH}
while read image; do
RET_C=0
until docker pull "${image}"; do
RET_C=$((RET_C + 1))
echo -e "\e[33m\nError pulling $image, retrying...\e[0m"
[ ${RET_C} -gt 3 ] && { echo -e "\e[31m\nToo many failed retries, exiting\e[0m"; exit 1; }
sleep 1
done
done < <(git show "origin/${BRANCH}:docker-compose.yml" | grep "image:" | awk '{ gsub("image:","", $3); print $2 }')
}
docker_garbage() {
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )"
IMGS_TO_DELETE=()
declare -A IMAGES_INFO
COMPOSE_IMAGES=($(grep -oP "image: \K(ghcr\.io/)?mailcow.+" "${SCRIPT_DIR}/docker-compose.yml"))
for existing_image in $(docker images --format "{{.ID}}:{{.Repository}}:{{.Tag}}" | grep -E '(mailcow/|ghcr\.io/mailcow/)'); do
ID=$(echo "$existing_image" | cut -d ':' -f 1)
REPOSITORY=$(echo "$existing_image" | cut -d ':' -f 2)
TAG=$(echo "$existing_image" | cut -d ':' -f 3)
if [[ "$REPOSITORY" == "mailcow/backup" || "$REPOSITORY" == "ghcr.io/mailcow/backup" ]]; then
if [[ "$TAG" != "<none>" ]]; then
continue
fi
fi
if [[ " ${COMPOSE_IMAGES[@]} " =~ " ${REPOSITORY}:${TAG} " ]]; then
continue
else
IMGS_TO_DELETE+=("$ID")
IMAGES_INFO["$ID"]="$REPOSITORY:$TAG"
fi
done
if [[ ! -z ${IMGS_TO_DELETE[*]} ]]; then
echo "The following unused mailcow images were found:"
for id in "${IMGS_TO_DELETE[@]}"; do
echo " ${IMAGES_INFO[$id]} ($id)"
done
if [ -z "$FORCE" ]; then
read -r -p "Do you want to delete them to free up some space? [y/N] " response
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
docker rmi ${IMGS_TO_DELETE[*]}
else
echo "OK, skipped."
fi
else
echo "Running in forced mode! Force removing old mailcow images..."
docker rmi ${IMGS_TO_DELETE[*]}
fi
echo -e "\e[32mFurther cleanup...\e[0m"
echo "If you want to cleanup further garbage collected by Docker, please make sure all containers are up and running before cleaning your system by executing \"docker system prune\""
fi
}
in_array() {
local e match="$1"
shift
for e; do [[ "$e" == "$match" ]] && return 0; done
return 1
}
detect_major_update() {
if [ ${BRANCH} == "master" ]; then
# Array with major versions
# Add major versions here
MAJOR_VERSIONS=(
"2025-02"
"2025-03"
"2025-09"
)
current_version=""
if [[ -f "${SCRIPT_DIR}/data/web/inc/app_info.inc.php" ]]; then
current_version=$(grep 'MAILCOW_GIT_VERSION' ${SCRIPT_DIR}/data/web/inc/app_info.inc.php | sed -E 's/.*MAILCOW_GIT_VERSION="([^"]+)".*/\1/')
fi
if [[ -z "$current_version" ]]; then
return 1
fi
release_url="https://github.com/mailcow/mailcow-dockerized/releases/tag"
updates_to_apply=()
for version in "${MAJOR_VERSIONS[@]}"; do
if [[ "$current_version" < "$version" ]]; then
updates_to_apply+=("$version")
fi
done
if [[ ${#updates_to_apply[@]} -gt 0 ]]; then
echo -e "\e[33m\nMAJOR UPDATES to be applied:\e[0m"
for update in "${updates_to_apply[@]}"; do
echo "$update - $release_url/$update"
done
echo -e "\nPlease read the release notes before proceeding."
read -p "Do you want to proceed with the update? [y/n] " response
if [[ "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo "Proceeding with the update..."
else
echo "Update canceled. Exiting."
exit 1
fi
fi
fi
}
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env bash
# _modules/scripts/ipv6_controller.sh
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
# 1) Check if the host supports IPv6
get_ipv6_support() {
# ---- helper: probe external IPv6 connectivity without DNS ----
_probe_ipv6_connectivity() {
# Use literal, always-on IPv6 echo responders (no DNS required)
local PROBE_IPS=("2001:4860:4860::8888" "2606:4700:4700::1111")
local ip rc=1
for ip in "${PROBE_IPS[@]}"; do
if command -v ping6 &>/dev/null; then
ping6 -c1 -W2 "$ip" &>/dev/null || ping6 -c1 -w2 "$ip" &>/dev/null
rc=$?
elif command -v ping &>/dev/null; then
ping -6 -c1 -W2 "$ip" &>/dev/null || ping -6 -c1 -w2 "$ip" &>/dev/null
rc=$?
else
rc=1
fi
[[ $rc -eq 0 ]] && return 0
done
return 1
}
if [[ ! -f /proc/net/if_inet6 ]] || grep -qs '^1' /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null; then
DETECTED_IPV6=false
echo -e "${YELLOW}IPv6 not detected on host ${LIGHT_RED}IPv6 is administratively disabled${YELLOW}.${NC}"
return
fi
if ip -6 route show default 2>/dev/null | grep -qE '^default'; then
echo -e "${YELLOW}Default IPv6 route found testing external IPv6 connectivity...${NC}"
if _probe_ipv6_connectivity; then
DETECTED_IPV6=true
echo -e "IPv6 detected on host ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
else
DETECTED_IPV6=false
echo -e "${YELLOW}Default IPv6 route present but external IPv6 connectivity failed ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
fi
return
fi
if ip -6 addr show scope global 2>/dev/null | grep -q 'inet6'; then
DETECTED_IPV6=false
echo -e "${YELLOW}Global IPv6 address present but no default route ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
return
fi
if ip -6 addr show scope link 2>/dev/null | grep -q 'inet6'; then
echo -e "${YELLOW}Only link-local IPv6 addresses found testing external IPv6 connectivity...${NC}"
if _probe_ipv6_connectivity; then
DETECTED_IPV6=true
echo -e "External IPv6 connectivity available ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
else
DETECTED_IPV6=false
echo -e "${YELLOW}Only link-local IPv6 present and no external connectivity ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
fi
return
fi
DETECTED_IPV6=false
echo -e "${YELLOW}IPv6 not detected on host ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
}
# 2) Ensure Docker daemon.json has (or create) the required IPv6 settings
docker_daemon_edit(){
DOCKER_DAEMON_CONFIG="/etc/docker/daemon.json"
DOCKER_MAJOR=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
MISSING=()
_has_kv() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*$2" "$DOCKER_DAEMON_CONFIG" 2>/dev/null; }
if [[ -f "$DOCKER_DAEMON_CONFIG" ]]; then
# reject empty or whitespace-only file immediately
if [[ ! -s "$DOCKER_DAEMON_CONFIG" ]] || ! grep -Eq '[{}]' "$DOCKER_DAEMON_CONFIG"; then
echo -e "${RED}ERROR: $DOCKER_DAEMON_CONFIG exists but is empty or contains no JSON braces please initialize it with valid JSON (e.g. {}).${NC}"
exit 1
fi
# Validate JSON if jq is present
if command -v jq &>/dev/null && ! jq empty "$DOCKER_DAEMON_CONFIG" &>/dev/null; then
echo -e "${RED}ERROR: Invalid JSON in $DOCKER_DAEMON_CONFIG please correct manually.${NC}"
exit 1
fi
# Gather missing keys
! _has_kv ipv6 true && MISSING+=("ipv6: true")
# For Docker < 28, keep requiring fixed-cidr-v6 (default bridge needs it on old engines)
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
! grep -Eq '"fixed-cidr-v6"[[:space:]]*:[[:space:]]*".+"' "$DOCKER_DAEMON_CONFIG" \
&& MISSING+=('fixed-cidr-v6: "fd00:dead:beef:c0::/80"')
fi
# For Docker < 27, ip6tables needed and was tied to experimental in older releases
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
_has_kv ipv6 true && ! _has_kv ip6tables true && MISSING+=("ip6tables: true")
! _has_kv experimental true && MISSING+=("experimental: true")
fi
# Fix if needed
if ((${#MISSING[@]}>0)); then
echo -e "${MAGENTA}Your daemon.json is missing: ${YELLOW}${MISSING[*]}${NC}"
if [[ -n "$FORCE" ]]; then
ans=Y
else
read -p "Would you like to update $DOCKER_DAEMON_CONFIG now? [Y/n] " ans
ans=${ans:-Y}
fi
if [[ $ans =~ ^[Yy]$ ]]; then
cp "$DOCKER_DAEMON_CONFIG" "${DOCKER_DAEMON_CONFIG}.bak"
if command -v jq &>/dev/null; then
TMP=$(mktemp)
# Base filter: ensure ipv6 = true
JQ_FILTER='.ipv6 = true'
# Add fixed-cidr-v6 only for Docker < 28
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
JQ_FILTER+=' | .["fixed-cidr-v6"] = (.["fixed-cidr-v6"] // "fd00:dead:beef:c0::/80")'
fi
# Add ip6tables/experimental only for Docker < 27
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
JQ_FILTER+=' | .ip6tables = true | .experimental = true'
fi
jq "$JQ_FILTER" "$DOCKER_DAEMON_CONFIG" >"$TMP" && mv "$TMP" "$DOCKER_DAEMON_CONFIG"
echo -e "${LIGHT_GREEN}daemon.json updated. Restarting Docker...${NC}"
(command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart
echo -e "${YELLOW}Docker restarted.${NC}"
else
echo -e "${RED}Please install jq or manually update daemon.json and restart Docker.${NC}"
exit 1
fi
else
echo -e "${YELLOW}User declined Docker update please insert these changes manually:${NC}"
echo "${MISSING[*]}"
exit 1
fi
fi
else
# Create new daemon.json if missing
if [[ -n "$FORCE" ]]; then
ans=Y
else
read -p "$DOCKER_DAEMON_CONFIG not found. Create it with IPv6 settings? [Y/n] " ans
ans=${ans:-Y}
fi
if [[ $ans =~ ^[Yy]$ ]]; then
mkdir -p "$(dirname "$DOCKER_DAEMON_CONFIG")"
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef:c0::/80",
"ip6tables": true,
"experimental": true
}
EOF
elif [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
{
"ipv6": true,
"fixed-cidr-v6": "fd00:dead:beef:c0::/80"
}
EOF
else
# Docker 28+: ipv6 works without fixed-cidr-v6
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
{
"ipv6": true
}
EOF
fi
echo -e "${GREEN}Created $DOCKER_DAEMON_CONFIG with IPv6 settings.${NC}"
echo "Restarting Docker..."
(command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart
echo "Docker restarted."
else
echo "User declined to create daemon.json please manually merge the docker daemon with these configs:"
echo "${MISSING[*]}"
exit 1
fi
fi
}
# 3) Main wrapper for generate_config.sh and update.sh
configure_ipv6() {
# detect manual override if mailcow.conf is present
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]] && grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
MANUAL_SETTING=$(grep '^ENABLE_IPV6=' "$MAILCOW_CONF" | cut -d= -f2)
elif [[ -z "$MAILCOW_CONF" ]] && [[ -n "${ENABLE_IPV6:-}" ]]; then
MANUAL_SETTING="$ENABLE_IPV6"
else
MANUAL_SETTING=""
fi
get_ipv6_support
# if user manually set it, check for mismatch
if [[ "$DETECTED_IPV6" != "true" ]]; then
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=false
fi
echo "Skipping Docker IPv6 configuration because host does not support IPv6."
echo "Make sure to check if your docker daemon.json does not include \"enable_ipv6\": true if you do not want IPv6."
echo "IPv6 configuration complete: ENABLE_IPV6=false"
sleep 2
return
fi
docker_daemon_edit
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=true/' "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=true" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=true
fi
echo "IPv6 configuration complete: ENABLE_IPV6=true"
}
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# _modules/scripts/migrate_options.sh
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
migrate_config_options() {
sed -i --follow-symlinks '$a\' mailcow.conf
KEYS=(
SOLR_HEAP
SKIP_SOLR
SOLR_PORT
FLATCURVE_EXPERIMENTAL
DISABLE_IPv6
ACME_CONTACT
)
for key in "${KEYS[@]}"; do
if grep -q "${key}" mailcow.conf; then
case "${key}" in
SOLR_HEAP)
echo "Removing ${key} in mailcow.conf"
sed -i '/# Solr heap size in MB\b/d' mailcow.conf
sed -i '/# Solr is a prone to run\b/d' mailcow.conf
sed -i '/SOLR_HEAP\b/d' mailcow.conf
;;
SKIP_SOLR)
echo "Removing ${key} in mailcow.conf"
sed -i '/\bSkip Solr on low-memory\b/d' mailcow.conf
sed -i '/\bSolr is disabled by default\b/d' mailcow.conf
sed -i '/\bDisable Solr or\b/d' mailcow.conf
sed -i '/\bSKIP_SOLR\b/d' mailcow.conf
;;
SOLR_PORT)
echo "Removing ${key} in mailcow.conf"
sed -i '/\bSOLR_PORT\b/d' mailcow.conf
;;
FLATCURVE_EXPERIMENTAL)
echo "Removing ${key} in mailcow.conf"
sed -i '/\bFLATCURVE_EXPERIMENTAL\b/d' mailcow.conf
;;
DISABLE_IPv6)
echo "Migrating ${key} to ENABLE_IPv6 in mailcow.conf"
local old=$(grep '^DISABLE_IPv6=' "mailcow.conf" | cut -d'=' -f2)
local new
if [[ "$old" == "y" ]]; then
new="false"
else
new="true"
fi
sed -i '/^DISABLE_IPv6=/d' "mailcow.conf"
echo "ENABLE_IPV6=$new" >> "mailcow.conf"
;;
ACME_CONTACT)
echo "Deleting obsoleted ${key} in mailcow.conf"
sed -i '/^# Lets Encrypt registration contact information/d' mailcow.conf
sed -i '/^# Optional: Leave empty for none/d' mailcow.conf
sed -i '/^# This value is only used on first order!/d' mailcow.conf
sed -i '/^# Setting it at a later point will require the following steps:/d' mailcow.conf
sed -i '/^# https:\/\/docs.mailcow.email\/troubleshooting\/debug-reset_tls\//d' mailcow.conf
sed -i '/^ACME_CONTACT=.*/d' mailcow.conf
sed -i '/^#ACME_CONTACT=.*/d' mailcow.conf
;;
esac
fi
done
solr_volume=$(docker volume ls -qf name=^${COMPOSE_PROJECT_NAME}_solr-vol-1)
if [[ -n $solr_volume ]]; then
echo -e "\e[34mSolr has been replaced within mailcow since 2025-01.\nThe volume $solr_volume is unused.\e[0m"
sleep 1
if [ ! "$FORCE" ]; then
read -r -p "Remove $solr_volume? [y/N] " response
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
echo -e "\e[33mRemoving $solr_volume...\e[0m"
docker volume rm $solr_volume || echo -e "\e[31mFailed to remove. Remove it manually!\e[0m"
echo -e "\e[32mSuccessfully removed $solr_volume!\e[0m"
else
echo -e "Not removing $solr_volume. Run \`docker volume rm $solr_volume\` manually if needed."
fi
else
echo -e "\e[33mForce removing $solr_volume...\e[0m"
docker volume rm $solr_volume || echo -e "\e[31mFailed to remove. Remove it manually!\e[0m"
echo -e "\e[32mSuccessfully removed $solr_volume!\e[0m"
fi
fi
# Delete old fts.conf before forced switch to flatcurve to ensure update is working properly
FTS_CONF_PATH="${SCRIPT_DIR}/data/conf/dovecot/conf.d/fts.conf"
if [[ -f "$FTS_CONF_PATH" ]]; then
if grep -q "Autogenerated by mailcow" "$FTS_CONF_PATH"; then
rm -rf $FTS_CONF_PATH
fi
fi
}
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env bash
# _modules/scripts/new_options.sh
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
adapt_new_options() {
CONFIG_ARRAY=(
"AUTODISCOVER_SAN"
"SKIP_LETS_ENCRYPT"
"SKIP_SOGO"
"USE_WATCHDOG"
"WATCHDOG_NOTIFY_EMAIL"
"WATCHDOG_NOTIFY_WEBHOOK"
"WATCHDOG_NOTIFY_WEBHOOK_BODY"
"WATCHDOG_NOTIFY_BAN"
"WATCHDOG_NOTIFY_START"
"WATCHDOG_EXTERNAL_CHECKS"
"WATCHDOG_SUBJECT"
"SKIP_CLAMD"
"SKIP_OLEFY"
"SKIP_IP_CHECK"
"ADDITIONAL_SAN"
"DOVEADM_PORT"
"IPV4_NETWORK"
"IPV6_NETWORK"
"LOG_LINES"
"SNAT_TO_SOURCE"
"SNAT6_TO_SOURCE"
"COMPOSE_PROJECT_NAME"
"DOCKER_COMPOSE_VERSION"
"SQL_PORT"
"API_KEY"
"API_KEY_READ_ONLY"
"API_ALLOW_FROM"
"MAILDIR_GC_TIME"
"MAILDIR_SUB"
"ACL_ANYONE"
"FTS_HEAP"
"FTS_PROCS"
"SKIP_FTS"
"ENABLE_SSL_SNI"
"ALLOW_ADMIN_EMAIL_LOGIN"
"SKIP_HTTP_VERIFICATION"
"SOGO_EXPIRE_SESSION"
"SOGO_URL_ENCRYPTION_KEY"
"REDIS_PORT"
"REDISPASS"
"DOVECOT_MASTER_USER"
"DOVECOT_MASTER_PASS"
"MAILCOW_PASS_SCHEME"
"ADDITIONAL_SERVER_NAMES"
"WATCHDOG_VERBOSE"
"WEBAUTHN_ONLY_TRUSTED_VENDORS"
"SPAMHAUS_DQS_KEY"
"SKIP_UNBOUND_HEALTHCHECK"
"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
continue
fi
echo "Adding new option \"${option}\" to mailcow.conf"
case "${option}" in
AUTODISCOVER_SAN)
echo '# Obtain certificates for autodiscover.* and autoconfig.* domains.' >> mailcow.conf
echo '# This can be useful to switch off in case you are in a scenario where a reverse proxy already handles those.' >> mailcow.conf
echo '# There are mixed scenarios where ports 80,443 are occupied and you do not want to share certs' >> mailcow.conf
echo '# between services. So acme-mailcow obtains for maildomains and all web-things get handled' >> mailcow.conf
echo '# in the reverse proxy.' >> mailcow.conf
echo 'AUTODISCOVER_SAN=y' >> mailcow.conf
;;
DOCKER_COMPOSE_VERSION)
echo "# Used Docker Compose version" >> mailcow.conf
echo "# Switch here between native (compose plugin) and standalone" >> mailcow.conf
echo "# For more informations take a look at the mailcow docs regarding the configuration options." >> mailcow.conf
echo "# Normally this should be untouched but if you decided to use either of those you can switch it manually here." >> mailcow.conf
echo "# Please be aware that at least one of those variants should be installed on your machine or mailcow will fail." >> mailcow.conf
echo "" >> mailcow.conf
echo "DOCKER_COMPOSE_VERSION=${DOCKER_COMPOSE_VERSION}" >> mailcow.conf
;;
DOVEADM_PORT)
echo "DOVEADM_PORT=127.0.0.1:19991" >> mailcow.conf
;;
LOG_LINES)
echo '# Max log lines per service to keep in Redis logs' >> mailcow.conf
echo "LOG_LINES=9999" >> mailcow.conf
;;
IPV4_NETWORK)
echo '# Internal IPv4 /24 subnet, format n.n.n. (expands to n.n.n.0/24)' >> mailcow.conf
echo "IPV4_NETWORK=172.22.1" >> mailcow.conf
;;
IPV6_NETWORK)
echo '# Internal IPv6 subnet in fc00::/7' >> mailcow.conf
echo "IPV6_NETWORK=fd4d:6169:6c63:6f77::/64" >> mailcow.conf
;;
SQL_PORT)
echo '# Bind SQL to 127.0.0.1 on port 13306' >> mailcow.conf
echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf
;;
API_KEY)
echo '# Create or override API key for web UI' >> mailcow.conf
echo "#API_KEY=" >> mailcow.conf
;;
API_KEY_READ_ONLY)
echo '# Create or override read-only API key for web UI' >> mailcow.conf
echo "#API_KEY_READ_ONLY=" >> mailcow.conf
;;
API_ALLOW_FROM)
echo '# Must be set for API_KEY to be active' >> mailcow.conf
echo '# IPs only, no networks (networks can be set via UI)' >> mailcow.conf
echo "#API_ALLOW_FROM=" >> mailcow.conf
;;
SNAT_TO_SOURCE)
echo '# Use this IPv4 for outgoing connections (SNAT)' >> mailcow.conf
echo "#SNAT_TO_SOURCE=" >> mailcow.conf
;;
SNAT6_TO_SOURCE)
echo '# Use this IPv6 for outgoing connections (SNAT)' >> mailcow.conf
echo "#SNAT6_TO_SOURCE=" >> mailcow.conf
;;
MAILDIR_GC_TIME)
echo '# Garbage collector cleanup' >> mailcow.conf
echo '# Deleted domains and mailboxes are moved to /var/vmail/_garbage/timestamp_sanitizedstring' >> mailcow.conf
echo '# How long should objects remain in the garbage until they are being deleted? (value in minutes)' >> mailcow.conf
echo '# Check interval is hourly' >> mailcow.conf
echo 'MAILDIR_GC_TIME=1440' >> mailcow.conf
;;
ACL_ANYONE)
echo '# Set this to "allow" to enable the anyone pseudo user. Disabled by default.' >> mailcow.conf
echo '# When enabled, ACL can be created, that apply to "All authenticated users"' >> mailcow.conf
echo '# This should probably only be activated on mail hosts, that are used exclusively by one organisation.' >> mailcow.conf
echo '# Otherwise a user might share data with too many other users.' >> mailcow.conf
echo 'ACL_ANYONE=disallow' >> mailcow.conf
;;
FTS_HEAP)
echo '# Dovecot Indexing (FTS) Process maximum heap size in MB, there is no recommendation, please see Dovecot docs.' >> mailcow.conf
echo '# Flatcurve is used as FTS Engine. It is supposed to be pretty efficient in CPU and RAM consumption.' >> mailcow.conf
echo '# Please always monitor your Resource consumption!' >> mailcow.conf
echo "FTS_HEAP=128" >> mailcow.conf
;;
SKIP_FTS)
echo '# Skip FTS (Fulltext Search) for Dovecot on low-memory, low-threaded systems or if you simply want to disable it.' >> mailcow.conf
echo "# Dovecot inside mailcow use Flatcurve as FTS Backend." >> mailcow.conf
echo "SKIP_FTS=y" >> mailcow.conf
;;
FTS_PROCS)
echo '# Controls how many processes the Dovecot indexing process can spawn at max.' >> mailcow.conf
echo '# Too many indexing processes can use a lot of CPU and Disk I/O' >> mailcow.conf
echo '# Please visit: https://doc.dovecot.org/configuration_manual/service_configuration/#indexer-worker for more informations' >> mailcow.conf
echo "FTS_PROCS=1" >> mailcow.conf
;;
ENABLE_SSL_SNI)
echo '# Create seperate certificates for all domains - y/n' >> mailcow.conf
echo '# this will allow adding more than 100 domains, but some email clients will not be able to connect with alternative hostnames' >> mailcow.conf
echo '# see https://wiki.dovecot.org/SSL/SNIClientSupport' >> mailcow.conf
echo "ENABLE_SSL_SNI=n" >> mailcow.conf
;;
SKIP_SOGO)
echo '# Skip SOGo: Will disable SOGo integration and therefore webmail, DAV protocols and ActiveSync support (experimental, unsupported, not fully implemented) - y/n' >> mailcow.conf
echo "SKIP_SOGO=n" >> mailcow.conf
;;
MAILDIR_SUB)
echo '# MAILDIR_SUB defines a path in a users virtual home to keep the maildir in. Leave empty for updated setups.' >> mailcow.conf
echo "#MAILDIR_SUB=Maildir" >> mailcow.conf
echo "MAILDIR_SUB=" >> mailcow.conf
;;
WATCHDOG_NOTIFY_WEBHOOK)
echo '# Send notifications to a webhook URL that receives a POST request with the content type "application/json".' >> mailcow.conf
echo '# You can use this to send notifications to services like Discord, Slack and others.' >> mailcow.conf
echo '#WATCHDOG_NOTIFY_WEBHOOK=https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' >> mailcow.conf
;;
WATCHDOG_NOTIFY_WEBHOOK_BODY)
echo '# JSON body included in the webhook POST request. Needs to be in single quotes.' >> mailcow.conf
echo '# Following variables are available: SUBJECT, BODY' >> mailcow.conf
WEBHOOK_BODY='{"username": "mailcow Watchdog", "content": "**${SUBJECT}**\n${BODY}"}'
echo "#WATCHDOG_NOTIFY_WEBHOOK_BODY='${WEBHOOK_BODY}'" >> mailcow.conf
;;
WATCHDOG_NOTIFY_BAN)
echo '# Notify about banned IP. Includes whois lookup.' >> mailcow.conf
echo "WATCHDOG_NOTIFY_BAN=y" >> mailcow.conf
;;
WATCHDOG_NOTIFY_START)
echo '# Send a notification when the watchdog is started.' >> mailcow.conf
echo "WATCHDOG_NOTIFY_START=y" >> mailcow.conf
;;
WATCHDOG_SUBJECT)
echo '# Subject for watchdog mails. Defaults to "Watchdog ALERT" followed by the error message.' >> mailcow.conf
echo "#WATCHDOG_SUBJECT=" >> mailcow.conf
;;
WATCHDOG_EXTERNAL_CHECKS)
echo '# Checks if mailcow is an open relay. Requires a SAL. More checks will follow.' >> mailcow.conf
echo '# No data is collected. Opt-in and anonymous.' >> mailcow.conf
echo '# Will only work with unmodified mailcow setups.' >> mailcow.conf
echo "WATCHDOG_EXTERNAL_CHECKS=n" >> mailcow.conf
;;
SOGO_EXPIRE_SESSION)
echo '# SOGo session timeout in minutes' >> mailcow.conf
echo "SOGO_EXPIRE_SESSION=480" >> mailcow.conf
;;
REDIS_PORT)
echo "REDIS_PORT=127.0.0.1:7654" >> mailcow.conf
;;
DOVECOT_MASTER_USER)
echo '# DOVECOT_MASTER_USER and _PASS must _both_ be provided. No special chars.' >> mailcow.conf
echo '# Empty by default to auto-generate master user and password on start.' >> mailcow.conf
echo '# User expands to DOVECOT_MASTER_USER@mailcow.local' >> mailcow.conf
echo '# LEAVE EMPTY IF UNSURE' >> mailcow.conf
echo "DOVECOT_MASTER_USER=" >> mailcow.conf
;;
DOVECOT_MASTER_PASS)
echo '# LEAVE EMPTY IF UNSURE' >> mailcow.conf
echo "DOVECOT_MASTER_PASS=" >> mailcow.conf
;;
MAILCOW_PASS_SCHEME)
echo '# Password hash algorithm' >> mailcow.conf
echo '# Only certain password hash algorithm are supported. For a fully list of supported schemes,' >> mailcow.conf
echo '# see https://docs.mailcow.email/models/model-passwd/' >> mailcow.conf
echo "MAILCOW_PASS_SCHEME=BLF-CRYPT" >> mailcow.conf
;;
ADDITIONAL_SERVER_NAMES)
echo '# Additional server names for mailcow UI' >> mailcow.conf
echo '#' >> mailcow.conf
echo '# Specify alternative addresses for the mailcow UI to respond to' >> mailcow.conf
echo '# This is useful when you set mail.* as ADDITIONAL_SAN and want to make sure mail.maildomain.com will always point to the mailcow UI.' >> mailcow.conf
echo '# If the server name does not match a known site, Nginx decides by best-guess and may redirect users to the wrong web root.' >> mailcow.conf
echo '# You can understand this as server_name directive in Nginx.' >> mailcow.conf
echo '# Comma separated list without spaces! Example: ADDITIONAL_SERVER_NAMES=a.b.c,d.e.f' >> mailcow.conf
echo 'ADDITIONAL_SERVER_NAMES=' >> mailcow.conf
;;
WEBAUTHN_ONLY_TRUSTED_VENDORS)
echo "# WebAuthn device manufacturer verification" >> mailcow.conf
echo '# After setting WEBAUTHN_ONLY_TRUSTED_VENDORS=y only devices from trusted manufacturers are allowed' >> mailcow.conf
echo '# root certificates can be placed for validation under mailcow-dockerized/data/web/inc/lib/WebAuthn/rootCertificates' >> mailcow.conf
echo 'WEBAUTHN_ONLY_TRUSTED_VENDORS=n' >> mailcow.conf
;;
SPAMHAUS_DQS_KEY)
echo "# Spamhaus Data Query Service Key" >> mailcow.conf
echo '# Optional: Leave empty for none' >> mailcow.conf
echo '# Enter your key here if you are using a blocked ASN (OVH, AWS, Cloudflare e.g) for the unregistered Spamhaus Blocklist.' >> mailcow.conf
echo '# If empty, it will completely disable Spamhaus blocklists if it detects that you are running on a server using a blocked AS.' >> mailcow.conf
echo '# Otherwise it will work as usual.' >> mailcow.conf
echo 'SPAMHAUS_DQS_KEY=' >> mailcow.conf
;;
WATCHDOG_VERBOSE)
echo '# Enable watchdog verbose logging' >> mailcow.conf
echo 'WATCHDOG_VERBOSE=n' >> mailcow.conf
;;
SKIP_UNBOUND_HEALTHCHECK)
echo '# Skip Unbound (DNS Resolver) Healthchecks (NOT Recommended!) - y/n' >> mailcow.conf
echo 'SKIP_UNBOUND_HEALTHCHECK=n' >> mailcow.conf
;;
DISABLE_NETFILTER_ISOLATION_RULE)
echo '# Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n' >> mailcow.conf
echo '# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost' >> mailcow.conf
echo 'DISABLE_NETFILTER_ISOLATION_RULE=n' >> mailcow.conf
;;
HTTP_REDIRECT)
echo '# Redirect HTTP connections to HTTPS - y/n' >> mailcow.conf
echo 'HTTP_REDIRECT=n' >> mailcow.conf
;;
ENABLE_IPV6)
echo '# IPv6 Controller Section' >> mailcow.conf
echo '# This variable controls the usage of IPv6 within mailcow.' >> mailcow.conf
echo '# Can either be true or false | Defaults to true' >> mailcow.conf
echo '# WARNING: MAKE SURE TO PROPERLY CONFIGURE IPv6 ON YOUR HOST FIRST BEFORE ENABLING THIS AS FAULTY CONFIGURATIONS CAN LEAD TO OPEN RELAYS!' >> mailcow.conf
echo '# A COMPLETE DOCKER STACK REBUILD (compose down && compose up -d) IS NEEDED TO APPLY THIS.' >> mailcow.conf
echo ENABLE_IPV6=${IPV6_BOOL} >> mailcow.conf
;;
SKIP_CLAMD)
echo '# Skip ClamAV (clamd-mailcow) anti-virus (Rspamd will auto-detect a missing ClamAV container) - y/n' >> mailcow.conf
echo 'SKIP_CLAMD=n' >> mailcow.conf
;;
SKIP_OLEFY)
echo '# Skip Olefy (olefy-mailcow) anti-virus for Office documents (Rspamd will auto-detect a missing Olefy container) - y/n' >> mailcow.conf
echo 'SKIP_OLEFY=n' >> mailcow.conf
;;
REDISPASS)
echo "REDISPASS=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 28)" >> mailcow.conf
;;
SOGO_URL_ENCRYPTION_KEY)
echo '# SOGo URL encryption key (exactly 16 characters, limited to AZ, az, 09)' >> mailcow.conf
echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
;;
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
;;
esac
done
}
+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 []
+75 -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 Docker API..."
until ping dockerapi -c1 > /dev/null; do
log_f "Waiting for Redis control bus..."
until redis-cli -h "${REDIS_SLAVEOF_IP:-redis-mailcow}" -p "${REDIS_SLAVEOF_PORT:-6379}" -a "${REDISPASS}" --no-auth-warning ping > /dev/null 2>&1; do
sleep 1
done
log_f "Docker API OK"
log_f "Redis control bus OK"
log_f "Waiting for Postfix..."
until ping postfix -c1 > /dev/null; do
@@ -206,7 +221,7 @@ while true; do
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
# Fetch certs for autoconfig and autodiscover subdomains
ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig')
ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig' 'mta-sts')
fi
if [[ ${SKIP_IP_CHECK} != "y" ]]; then
@@ -238,14 +253,46 @@ while true; do
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}")
fi
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
# Skip if subdomain matches MAILCOW_HOSTNAME
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
continue
fi
# Skip if subdomain is covered by a wildcard in ADDITIONAL_SAN
if is_covered_by_wildcard "${FULL_SUBDOMAIN}"; then
log_f "Subdomain '${FULL_SUBDOMAIN}' is covered by wildcard - skipping explicit subdomain"
continue
fi
# Validate and add subdomain
if check_domain "${FULL_SUBDOMAIN}"; then
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${FULL_SUBDOMAIN}")
fi
done
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
done
# Fetch alias domains where target domain has MTA-STS enabled
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
SQL_ALIAS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT ad.alias_domain FROM alias_domain ad INNER JOIN mta_sts m ON ad.target_domain = m.domain WHERE ad.active = 1 AND m.active = 1" -Bs)
if [[ $? -eq 0 ]]; then
while read alias_domain; do
if [[ -z "${alias_domain}" ]]; then
# ignore empty lines
continue
fi
# Only add mta-sts subdomain for alias domains
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
# Skip if mta-sts subdomain is covered by a wildcard
if is_covered_by_wildcard "mta-sts.${alias_domain}"; then
log_f "Alias domain mta-sts subdomain 'mta-sts.${alias_domain}' is covered by wildcard - skipping"
elif check_domain "mta-sts.${alias_domain}"; then
VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}")
fi
fi
done <<< "${SQL_ALIAS_DOMAINS}"
fi
fi
fi
if check_domain ${MAILCOW_HOSTNAME}; then
@@ -274,13 +321,31 @@ while true; do
done
fi
# Check if MAILCOW_HOSTNAME is covered by a wildcard in ADDITIONAL_SAN
MAILCOW_HOSTNAME_COVERED=0
if [[ ! -z ${VALIDATED_MAILCOW_HOSTNAME} ]]; then
if is_covered_by_wildcard "${VALIDATED_MAILCOW_HOSTNAME}"; then
MAILCOW_PARENT_DOMAIN=$(echo ${VALIDATED_MAILCOW_HOSTNAME} | cut -d. -f2-)
log_f "MAILCOW_HOSTNAME '${VALIDATED_MAILCOW_HOSTNAME}' is covered by wildcard '*.${MAILCOW_PARENT_DOMAIN}' - skipping explicit hostname"
MAILCOW_HOSTNAME_COVERED=1
fi
fi
# Unique domains for server certificate
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
# create certificate for server name and fqdn SANs only
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
SERVER_SAN_VALIDATED=($(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
else
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
fi
else
# create certificate for all domains, including all subdomains from other domains [*]
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
SERVER_SAN_VALIDATED=($(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
else
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
fi
fi
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
CERT_NAME=${SERVER_SAN_VALIDATED[0]}
+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://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"nginx-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
DOVECOT=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"dovecot-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
POSTFIX=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
reload_nginx(){
echo "Reloading Nginx..."
NGINX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${NGINX}/exec -d '{"cmd":"reload", "task":"nginx"}' --silent -H 'Content-type: application/json' | jq -r .type)
[[ ${NGINX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Nginx, restarting container..."; restart_container ${NGINX} ; }
reload_service() {
local svc="$1"
echo "Reloading ${svc} via mailcow-agent..."
if ! mailcow-agent-cli send "${svc}" reload >/dev/null; then
echo "Could not publish reload to ${svc}, attempting restart..."
mailcow-agent-cli send "${svc}" restart >/dev/null || true
fi
}
reload_dovecot(){
echo "Reloading Dovecot..."
DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type)
[[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; }
}
reload_postfix(){
echo "Reloading Postfix..."
POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/exec -d '{"cmd":"reload", "task":"postfix"}' --silent -H 'Content-type: application/json' | jq -r .type)
[[ ${POSTFIX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Postfix, restarting container..."; restart_container ${POSTFIX} ; }
}
restart_container(){
for container in $*; do
echo "Restarting ${container}..."
C_REST_OUT=$(curl -X POST --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${container}/restart --silent | jq -r '.msg')
echo "${C_REST_OUT}"
done
restart_service() {
local svc="$1"
echo "Restarting ${svc} via mailcow-agent..."
mailcow-agent-cli send "${svc}" restart >/dev/null || true
}
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
restart_container ${NGINX}
restart_container ${DOVECOT}
restart_container ${POSTFIX}
restart_service nginx
restart_service dovecot
restart_service postfix
else
reload_nginx
#reload_dovecot
restart_container ${DOVECOT}
#reload_postfix
restart_container ${POSTFIX}
reload_service nginx
restart_service dovecot
restart_service postfix
fi
+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
+2 -2
View File
@@ -1,3 +1,3 @@
FROM debian:bookworm-slim
FROM debian:trixie-slim
RUN apt update && apt install pigz -y --no-install-recommends
RUN apt update && apt install pigz zstd -y --no-install-recommends
+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 []
+1 -1
View File
@@ -8,7 +8,7 @@ fi
# Cleaning up garbage
echo "Cleaning up tmp files..."
rm -rf /var/lib/clamav/clamav-*.tmp
rm -rf /var/lib/clamav/tmp.*
# Prepare whitelist
-27
View File
@@ -1,27 +0,0 @@
FROM alpine:3.21
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
ARG PIP_BREAK_SYSTEM_PACKAGES=1
WORKDIR /app
RUN apk add --update --no-cache python3 \
py3-pip \
openssl \
tzdata \
py3-psutil \
py3-redis \
py3-async-timeout \
&& pip3 install --upgrade pip \
fastapi \
uvicorn \
aiodocker \
docker
RUN mkdir /app/modules
COPY docker-entrypoint.sh /app/
COPY main.py /app/main.py
COPY modules/ /app/modules/
ENTRYPOINT ["/bin/sh", "/app/docker-entrypoint.sh"]
CMD ["python", "main.py"]
@@ -1,9 +0,0 @@
#!/bin/bash
`openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout /app/dockerapi_key.pem \
-out /app/dockerapi_cert.pem \
-subj /CN=dockerapi/O=mailcow \
-addext subjectAltName=DNS:dockerapi`
exec "$@"
-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/dockerapi_cert.pem",
ssl_keyfile="/app/dockerapi_key.pem",
log_level="info",
loop="none"
)
@@ -1,626 +0,0 @@
import psutil
import sys
import os
import re
import time
import json
import asyncio
import platform
from datetime import datetime
from fastapi import FastAPI, Response, Request
class DockerApi:
def __init__(self, redis_client, sync_docker_client, async_docker_client, logger):
self.redis_client = redis_client
self.sync_docker_client = sync_docker_client
self.async_docker_client = async_docker_client
self.logger = logger
self.host_stats_isUpdating = False
self.containerIds_to_update = []
# api call: container_post - post_action: stop
def container_post__stop(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
container.stop()
res = { 'type': 'success', 'msg': 'command completed successfully'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: start
def container_post__start(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
container.start()
res = { 'type': 'success', 'msg': 'command completed successfully'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: restart
def container_post__restart(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
container.restart()
res = { 'type': 'success', 'msg': 'command completed successfully'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: top
def container_post__top(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
res = { 'type': 'success', 'msg': container.top()}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: stats
def container_post__stats(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
for stat in container.stats(decode=True, stream=True):
res = { 'type': 'success', 'msg': stat}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: mailq - task: delete
def container_post__exec__mailq__delete(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
flagged_qids = ['-d %s' % i for i in filtered_qids]
sanitized_string = str(' '.join(flagged_qids))
for container in self.sync_docker_client.containers.list(filters=filters):
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
return self.exec_run_handler('generic', postsuper_r)
# api call: container_post - post_action: exec - cmd: mailq - task: hold
def container_post__exec__mailq__hold(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
flagged_qids = ['-h %s' % i for i in filtered_qids]
sanitized_string = str(' '.join(flagged_qids))
for container in self.sync_docker_client.containers.list(filters=filters):
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
return self.exec_run_handler('generic', postsuper_r)
# api call: container_post - post_action: exec - cmd: mailq - task: cat
def container_post__exec__mailq__cat(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
sanitized_string = str(' '.join(filtered_qids))
for container in self.sync_docker_client.containers.list(filters=filters):
postcat_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postcat -q " + sanitized_string], user='postfix')
if not postcat_return:
postcat_return = 'err: invalid'
return self.exec_run_handler('utf8_text_only', postcat_return)
# api call: container_post - post_action: exec - cmd: mailq - task: unhold
def container_post__exec__mailq__unhold(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
flagged_qids = ['-H %s' % i for i in filtered_qids]
sanitized_string = str(' '.join(flagged_qids))
for container in self.sync_docker_client.containers.list(filters=filters):
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
return self.exec_run_handler('generic', postsuper_r)
# api call: container_post - post_action: exec - cmd: mailq - task: deliver
def container_post__exec__mailq__deliver(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'items' in request_json:
r = re.compile("^[0-9a-fA-F]+$")
filtered_qids = filter(r.match, request_json['items'])
if filtered_qids:
flagged_qids = ['-i %s' % i for i in filtered_qids]
for container in self.sync_docker_client.containers.list(filters=filters):
for i in flagged_qids:
postqueue_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postqueue " + i], user='postfix')
# todo: check each exit code
res = { 'type': 'success', 'msg': 'Scheduled immediate delivery'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: mailq - task: list
def container_post__exec__mailq__list(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
mailq_return = container.exec_run(["/usr/sbin/postqueue", "-j"], user='postfix')
return self.exec_run_handler('utf8_text_only', mailq_return)
# api call: container_post - post_action: exec - cmd: mailq - task: flush
def container_post__exec__mailq__flush(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
postqueue_r = container.exec_run(["/usr/sbin/postqueue", "-f"], user='postfix')
return self.exec_run_handler('generic', postqueue_r)
# api call: container_post - post_action: exec - cmd: mailq - task: super_delete
def container_post__exec__mailq__super_delete(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
postsuper_r = container.exec_run(["/usr/sbin/postsuper", "-d", "ALL"])
return self.exec_run_handler('generic', postsuper_r)
# api call: container_post - post_action: exec - cmd: system - task: fts_rescan
def container_post__exec__system__fts_rescan(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'username' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -u '" + request_json['username'].replace("'", "'\\''") + "'"], user='vmail')
if rescan_return.exit_code == 0:
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
if 'all' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -A"], user='vmail')
if rescan_return.exit_code == 0:
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: system - task: df
def container_post__exec__system__df(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'dir' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
df_return = container.exec_run(["/bin/bash", "-c", "/bin/df -H '" + request_json['dir'].replace("'", "'\\''") + "' | /usr/bin/tail -n1 | /usr/bin/tr -s [:blank:] | /usr/bin/tr ' ' ','"], user='nobody')
if df_return.exit_code == 0:
return df_return.output.decode('utf-8').rstrip()
else:
return "0,0,0,0,0,0"
# api call: container_post - post_action: exec - cmd: system - task: mysql_upgrade
def container_post__exec__system__mysql_upgrade(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_upgrade -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "'\n"], user='mysql')
if sql_return.exit_code == 0:
matched = False
for line in sql_return.output.decode('utf-8').split("\n"):
if 'is already upgraded to' in line:
matched = True
if matched:
res = { 'type': 'success', 'msg':'mysql_upgrade: already upgraded', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
container.restart()
res = { 'type': 'warning', 'msg':'mysql_upgrade: upgrade was applied', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'error', 'msg': 'mysql_upgrade: error running command', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
def container_post__exec__system__mysql_tzinfo_to_sql(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_tzinfo_to_sql /usr/share/zoneinfo | /bin/sed 's/Local time zone must be set--see zic manual page/FCTY/' | /usr/bin/mysql -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "' mysql \n"], user='mysql')
if sql_return.exit_code == 0:
res = { 'type': 'info', 'msg': 'mysql_tzinfo_to_sql: command completed successfully', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'error', 'msg': 'mysql_tzinfo_to_sql: error running command', 'text': sql_return.output.decode('utf-8')}
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: reload - task: dovecot
def container_post__exec__reload__dovecot(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
return self.exec_run_handler('generic', reload_return)
# api call: container_post - post_action: exec - cmd: reload - task: postfix
def container_post__exec__reload__postfix(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
return self.exec_run_handler('generic', reload_return)
# api call: container_post - post_action: exec - cmd: reload - task: nginx
def container_post__exec__reload__nginx(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
return self.exec_run_handler('generic', reload_return)
# api call: container_post - post_action: exec - cmd: sieve - task: list
def container_post__exec__sieve__list(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'username' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request_json['username'].replace("'", "'\\''") + "'"])
return self.exec_run_handler('utf8_text_only', sieve_return)
# api call: container_post - post_action: exec - cmd: sieve - task: print
def container_post__exec__sieve__print(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'username' in request_json and 'script_name' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]
sieve_return = container.exec_run(cmd)
return self.exec_run_handler('utf8_text_only', sieve_return)
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
def container_post__exec__maildir__cleanup(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'maildir' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
sane_name = re.sub(r'\W+', '', request_json['maildir'])
vmail_name = request_json['maildir'].replace("'", "'\\''")
cmd_vmail = "if [[ -d '/var/vmail/" + vmail_name + "' ]]; then /bin/mv '/var/vmail/" + vmail_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "'; fi"
index_name = request_json['maildir'].split("/")
if len(index_name) > 1:
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
cmd_vmail_index = "if [[ -d '/var/vmail_index/" + index_name + "' ]]; then /bin/mv '/var/vmail_index/" + index_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "_index'; fi"
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
else:
cmd = ["/bin/bash", "-c", cmd_vmail]
maildir_cleanup = container.exec_run(cmd, user='vmail')
return self.exec_run_handler('generic', maildir_cleanup)
# api call: container_post - post_action: exec - cmd: maildir - task: move
def container_post__exec__maildir__move(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'old_maildir' in request_json and 'new_maildir' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
vmail_name = request_json['old_maildir'].replace("'", "'\\''")
new_vmail_name = request_json['new_maildir'].replace("'", "'\\''")
cmd_vmail = f"if [[ -d '/var/vmail/{vmail_name}' ]]; then /bin/mv '/var/vmail/{vmail_name}' '/var/vmail/{new_vmail_name}'; fi"
index_name = request_json['old_maildir'].split("/")
new_index_name = request_json['new_maildir'].split("/")
if len(index_name) > 1 and len(new_index_name) > 1:
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
new_index_name = new_index_name[1].replace("'", "'\\''") + "@" + new_index_name[0].replace("'", "'\\''")
cmd_vmail_index = f"if [[ -d '/var/vmail_index/{index_name}' ]]; then /bin/mv '/var/vmail_index/{index_name}' '/var/vmail_index/{new_index_name}_index'; fi"
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
else:
cmd = ["/bin/bash", "-c", cmd_vmail]
maildir_move = container.exec_run(cmd, user='vmail')
return self.exec_run_handler('generic', maildir_move)
# api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
def container_post__exec__rspamd__worker_password(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'raw' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
cmd = "/usr/bin/rspamadm pw -e -p '" + request_json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
matched = False
for line in cmd_response.split("\n"):
if '$2$' in line:
hash = line.strip()
hash_out = re.search(r'\$2\$.+$', hash).group(0)
rspamd_passphrase_hash = re.sub(r'[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
rspamd_password_filename = "/etc/rspamd/override.d/worker-controller-password.inc"
cmd = '''/bin/echo 'enable_password = "%s";' > %s && cat %s''' % (rspamd_passphrase_hash, rspamd_password_filename, rspamd_password_filename)
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
container.restart()
matched = True
if matched:
res = { 'type': 'success', 'msg': 'command completed successfully' }
self.logger.info('success changing Rspamd password')
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
self.logger.error('failed changing Rspamd password')
res = { 'type': 'danger', 'msg': 'command did not complete' }
return Response(content=json.dumps(res, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: sogo - task: rename
def container_post__exec__sogo__rename_user(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
if 'old_username' in request_json and 'new_username' in request_json:
for container in self.sync_docker_client.containers.list(filters=filters):
old_username = request_json['old_username'].replace("'", "'\\''")
new_username = request_json['new_username'].replace("'", "'\\''")
sogo_return = container.exec_run(["/bin/bash", "-c", f"sogo-tool rename-user '{old_username}' '{new_username}'"], user='sogo')
return self.exec_run_handler('generic', sogo_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: get_acl
def container_post__exec__doveadm__get_acl(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
id = request_json['id'].replace("'", "'\\''")
shared_folders = container.exec_run(["/bin/bash", "-c", f"doveadm mailbox list -u '{id}'"])
shared_folders = shared_folders.output.decode('utf-8')
shared_folders = shared_folders.splitlines()
formatted_acls = []
mailbox_seen = []
for shared_folder in shared_folders:
if "Shared" not in shared_folder:
mailbox = shared_folder.replace("'", "'\\''")
if mailbox in mailbox_seen:
continue
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"])
acls = acls.output.decode('utf-8').strip().splitlines()
if len(acls) >= 2:
for acl in acls[1:]:
user_id, rights = acl.split(maxsplit=1)
user_id = user_id.split('=')[1]
mailbox_seen.append(mailbox)
formatted_acls.append({ 'user': id, 'id': user_id, 'mailbox': mailbox, 'rights': rights.split() })
elif "Shared" in shared_folder and "/" in shared_folder:
shared_folder = shared_folder.split("/")
if len(shared_folder) < 3:
continue
user = shared_folder[1].replace("'", "'\\''")
mailbox = '/'.join(shared_folder[2:]).replace("'", "'\\''")
if mailbox in mailbox_seen:
continue
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"])
acls = acls.output.decode('utf-8').strip().splitlines()
if len(acls) >= 2:
for acl in acls[1:]:
user_id, rights = acl.split(maxsplit=1)
user_id = user_id.split('=')[1].replace("'", "'\\''")
if user_id == id and mailbox not in mailbox_seen:
mailbox_seen.append(mailbox)
formatted_acls.append({ 'user': user, 'id': id, 'mailbox': mailbox, 'rights': rights.split() })
return Response(content=json.dumps(formatted_acls, indent=4), media_type="application/json")
# api call: container_post - post_action: exec - cmd: doveadm - task: delete_acl
def container_post__exec__doveadm__delete_acl(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
user = request_json['user'].replace("'", "'\\''")
mailbox = request_json['mailbox'].replace("'", "'\\''")
id = request_json['id'].replace("'", "'\\''")
if user and mailbox and id:
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' 'user={id}'"])
return self.exec_run_handler('generic', acl_delete_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
if 'container_id' in kwargs:
filters = {"id": kwargs['container_id']}
elif 'container_name' in kwargs:
filters = {"name": kwargs['container_name']}
for container in self.sync_docker_client.containers.list(filters=filters):
user = request_json['user'].replace("'", "'\\''")
mailbox = request_json['mailbox'].replace("'", "'\\''")
id = request_json['id'].replace("'", "'\\''")
rights = ""
available_rights = [
"admin",
"create",
"delete",
"expunge",
"insert",
"lookup",
"post",
"read",
"write",
"write-deleted",
"write-seen"
]
for right in request_json['rights']:
right = right.replace("'", "'\\''").lower()
if right in available_rights:
rights += right + " "
if user and mailbox and id and rights:
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' 'user={id}' {rights}"])
return self.exec_run_handler('generic', acl_set_return)
# Collect host stats
async def get_host_stats(self, wait=5):
try:
system_time = datetime.now()
host_stats = {
"cpu": {
"cores": psutil.cpu_count(),
"usage": psutil.cpu_percent()
},
"memory": {
"total": psutil.virtual_memory().total,
"usage": psutil.virtual_memory().percent,
"swap": psutil.swap_memory()
},
"uptime": time.time() - psutil.boot_time(),
"system_time": system_time.strftime("%d.%m.%Y %H:%M:%S"),
"architecture": platform.machine()
}
await self.redis_client.set('host_stats', json.dumps(host_stats), ex=10)
except Exception as e:
res = {
"type": "danger",
"msg": str(e)
}
await asyncio.sleep(wait)
self.host_stats_isUpdating = False
# Collect container stats
async def get_container_stats(self, container_id, wait=5, stop=False):
if container_id and container_id.isalnum():
try:
for container in (await self.async_docker_client.containers.list()):
if container._id == container_id:
res = await container.stats(stream=False)
if await self.redis_client.exists(container_id + '_stats'):
stats = json.loads(await self.redis_client.get(container_id + '_stats'))
else:
stats = []
stats.append(res[0])
if len(stats) > 3:
del stats[0]
await self.redis_client.set(container_id + '_stats', json.dumps(stats), ex=60)
except Exception as e:
res = {
"type": "danger",
"msg": str(e)
}
else:
res = {
"type": "danger",
"msg": "no or invalid id defined"
}
await asyncio.sleep(wait)
if stop == True:
# update task was called second time, stop
self.containerIds_to_update.remove(container_id)
else:
# call update task a second time
await self.get_container_stats(container_id, wait=0, stop=True)
def exec_cmd_container(self, container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
def recv_socket_data(c_socket, timeout):
c_socket.setblocking(0)
total_data=[]
data=''
begin=time.time()
while True:
if total_data and time.time()-begin > timeout:
break
elif time.time()-begin > timeout*2:
break
try:
data = c_socket.recv(8192)
if data:
total_data.append(data.decode('utf-8'))
#change the beginning time for measurement
begin=time.time()
else:
#sleep for sometime to indicate a gap
time.sleep(0.1)
break
except:
pass
return ''.join(total_data)
try :
socket = container.exec_run([shell_cmd], stdin=True, socket=True, user=user).output._sock
if not cmd.endswith("\n"):
cmd = cmd + "\n"
socket.send(cmd.encode('utf-8'))
data = recv_socket_data(socket, timeout)
socket.close()
return data
except Exception as e:
self.logger.error("error - exec_cmd_container: %s" % str(e))
traceback.print_exc(file=sys.stdout)
def exec_run_handler(self, type, output):
if type == 'generic':
if output.exit_code == 0:
res = { 'type': 'success', 'msg': 'command completed successfully' }
return Response(content=json.dumps(res, indent=4), media_type="application/json")
else:
res = { 'type': 'danger', 'msg': 'command failed: ' + output.output.decode('utf-8') }
return Response(content=json.dumps(res, indent=4), media_type="application/json")
if type == 'utf8_text_only':
return Response(content=output.output.decode('utf-8'), media_type="text/plain")
+16 -3
View File
@@ -1,9 +1,13 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM alpine:3.21
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
ARG GOSU_VERSION=1.16
ARG GOSU_VERSION=1.19
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
@@ -135,5 +139,14 @@ COPY quota_notify.py /usr/local/bin/quota_notify.py
COPY repl_health.sh /usr/local/bin/repl_health.sh
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
ENV MAILCOW_AGENT_SERVICE=dovecot \
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
@@ -204,16 +204,17 @@ EOF
# Create random master Password for SOGo SSO
RAND_PASS=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 32 | head -n 1)
echo -n ${RAND_PASS} > /etc/phpfpm/sogo-sso.pass
# Creating additional creds file for SOGo notify crons (calendars, etc)
echo -n ${RAND_USER}@mailcow.local:${RAND_PASS} > /etc/sogo/cron.creds
cat <<EOF > /etc/dovecot/sogo-sso.conf
# Autogenerated by mailcow
passdb {
driver = static
args = allow_real_nets=${IPV4_NETWORK}.248/32 password={plain}${RAND_PASS}
args = allow_nets=${IPV4_NETWORK}.248/32 password={plain}${RAND_PASS}
}
EOF
# Creating additional creds file for SOGo notify crons (calendars, etc) (dummy user, sso password)
echo -n ${RAND_USER}@mailcow.local:${RAND_PASS} > /etc/sogo/cron.creds
if [[ "${MASTER}" =~ ^([nN][oO]|[nN])+$ ]]; then
# Toggling MASTER will result in a rebuild of containers, so the quota script will be recreated
cat <<'EOF' > /usr/local/bin/quota_notify.py
+2 -2
View File
@@ -132,8 +132,8 @@ while ($row = $sth->fetchrow_arrayref()) {
"--tmpdir", "/tmp",
"--nofoldersizes",
"--addheader",
($timeout1 gt "0" ? () : ('--timeout1', $timeout1)),
($timeout2 gt "0" ? () : ('--timeout2', $timeout2)),
($timeout1 le "0" ? () : ('--timeout1', $timeout1)),
($timeout2 le "0" ? () : ('--timeout2', $timeout2)),
($exclude eq "" ? () : ("--exclude", $exclude)),
($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)),
($maxage eq "0" ? () : ('--maxage', $maxage)),
@@ -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 SHA2(CONCAT(id, qid), 256) AS 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://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | \
jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | \
jq -rc "select( .name | tostring | contains(\"${CONTAINER_NAME}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
if [[ ! -z ${CONTAINER_ID} ]]; then
curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
fi
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
REQ_ID="$(date +%s%N)"
PAYLOAD="{\"cmd\":\"restart\",\"request_id\":\"${REQ_ID}\",\"issued_by\":\"dovecot-sa-rules\"}"
redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning \
PUBLISH mailcow.control.rspamd "${PAYLOAD}" >/dev/null 2>&1 || true
fi
# Cleanup
@@ -21,7 +21,7 @@ destination d_redis_ui_log {
persist-name("redis1")
port(`REDIS_SLAVEOF_PORT`)
auth("`REDISPASS`")
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
);
};
destination d_redis_f2b_channel {
+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 []
@@ -1,6 +1,6 @@
#!/bin/sh
backend=iptables
backend=nftables
nft list table ip filter &>/dev/null
nftables_found=$?
+107 -61
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
DEBUG = False
import re
import os
import sys
@@ -20,10 +22,13 @@ from modules.Logger import Logger
from modules.IPTables import IPTables
from modules.NFTables import NFTables
def logdebug(msg):
if DEBUG:
logger.logInfo("DEBUG: %s" % msg)
# globals
# Globals
WHITELIST = []
BLACKLIST= []
BLACKLIST = []
bans = {}
quit_now = False
exit_code = 0
@@ -33,12 +38,10 @@ r = None
pubsub = None
clear_before_quit = False
def refreshF2boptions():
global f2boptions
global quit_now
global exit_code
f2boptions = {}
if not r.get('F2B_OPTIONS'):
@@ -52,8 +55,9 @@ def refreshF2boptions():
else:
try:
f2boptions = json.loads(r.get('F2B_OPTIONS'))
except ValueError:
logger.logCrit('Error loading F2B options: F2B_OPTIONS is not json')
except ValueError as e:
logger.logCrit(
'Error loading F2B options: F2B_OPTIONS is not json. Exception: %s' % e)
quit_now = True
exit_code = 2
@@ -61,15 +65,15 @@ def refreshF2boptions():
r.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False))
def verifyF2boptions(f2boptions):
verifyF2boption(f2boptions,'ban_time', 1800)
verifyF2boption(f2boptions,'max_ban_time', 10000)
verifyF2boption(f2boptions,'ban_time_increment', True)
verifyF2boption(f2boptions,'max_attempts', 10)
verifyF2boption(f2boptions,'retry_window', 600)
verifyF2boption(f2boptions,'netban_ipv4', 32)
verifyF2boption(f2boptions,'netban_ipv6', 128)
verifyF2boption(f2boptions,'banlist_id', str(uuid.uuid4()))
verifyF2boption(f2boptions,'manage_external', 0)
verifyF2boption(f2boptions, 'ban_time', 1800)
verifyF2boption(f2boptions, 'max_ban_time', 10000)
verifyF2boption(f2boptions, 'ban_time_increment', True)
verifyF2boption(f2boptions, 'max_attempts', 10)
verifyF2boption(f2boptions, 'retry_window', 600)
verifyF2boption(f2boptions, 'netban_ipv4', 32)
verifyF2boption(f2boptions, 'netban_ipv6', 128)
verifyF2boption(f2boptions, 'banlist_id', str(uuid.uuid4()))
verifyF2boption(f2boptions, 'manage_external', 0)
def verifyF2boption(f2boptions, f2boption, f2bdefault):
f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
@@ -111,7 +115,7 @@ def get_ip(address):
def ban(address):
global f2boptions
global lock
logdebug("ban() called with address=%s" % address)
refreshF2boptions()
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
RETRY_WINDOW = int(f2boptions['retry_window'])
@@ -119,31 +123,43 @@ def ban(address):
NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
ip = get_ip(address)
if not ip: return
if not ip:
logdebug("No valid IP -- skipping ban()")
return
address = str(ip)
self_network = ipaddress.ip_network(address)
with lock:
temp_whitelist = set(WHITELIST)
if temp_whitelist:
for wl_key in temp_whitelist:
wl_net = ipaddress.ip_network(wl_key, False)
if wl_net.overlaps(self_network):
logger.logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
return
logdebug("Checking if %s overlaps with any WHITELIST entries" % self_network)
if temp_whitelist:
for wl_key in temp_whitelist:
wl_net = ipaddress.ip_network(wl_key, False)
logdebug("Checking overlap between %s and %s" % (self_network, wl_net))
if wl_net.overlaps(self_network):
logger.logInfo(
'Address %s is allowlisted by rule %s' % (self_network, wl_net))
return
net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
net = ipaddress.ip_network(
(address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
net = str(net)
logdebug("Ban net: %s" % net)
if not net in bans:
bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0}
logdebug("Initing new ban counter for %s" % net)
current_attempt = time.time()
logdebug("Current attempt ts=%s, previous: %s, retry_window: %s" %
(current_attempt, bans[net]['last_attempt'], RETRY_WINDOW))
if current_attempt - bans[net]['last_attempt'] > RETRY_WINDOW:
bans[net]['attempts'] = 0
logdebug("Ban counter for %s reset as window expired" % net)
bans[net]['attempts'] += 1
bans[net]['last_attempt'] = current_attempt
logdebug("%s attempts now %d" % (net, bans[net]['attempts']))
if bans[net]['attempts'] >= MAX_ATTEMPTS:
cur_time = int(round(time.time()))
@@ -151,34 +167,41 @@ def ban(address):
logger.logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
if type(ip) is ipaddress.IPv4Address and int(f2boptions['manage_external']) != 1:
with lock:
logdebug("Calling tables.banIPv4(%s)" % net)
tables.banIPv4(net)
elif int(f2boptions['manage_external']) != 1:
with lock:
logdebug("Calling tables.banIPv6(%s)" % net)
tables.banIPv6(net)
logdebug("Updating F2B_ACTIVE_BANS[%s]=%d" %
(net, cur_time + NET_BAN_TIME))
r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME)
else:
logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (
MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
def unban(net):
global lock
logdebug("Calling unban() with net=%s" % net)
if not net in bans:
logger.logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
return
logger.logInfo(
'%s is not banned, skipping unban and deleting from queue (if any)' % net)
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
return
logger.logInfo('Unbanning %s' % net)
if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
with lock:
logdebug("Calling tables.unbanIPv4(%s)" % net)
tables.unbanIPv4(net)
else:
with lock:
logdebug("Calling tables.unbanIPv6(%s)" % net)
tables.unbanIPv6(net)
r.hdel('F2B_ACTIVE_BANS', '%s' % net)
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
if net in bans:
logdebug("Unban for %s, setting attempts=0, ban_counter+=1" % net)
bans[net]['attempts'] = 0
bans[net]['ban_counter'] += 1
@@ -204,17 +227,19 @@ def permBan(net, unban=False):
if is_unbanned:
r.hdel('F2B_PERM_BANS', '%s' % net)
logger.logCrit('Removed host/network %s from blacklist' % net)
logger.logCrit('Removed host/network %s from denylist' % net)
elif is_banned:
r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
logger.logCrit('Added host/network %s to blacklist' % net)
logger.logCrit('Added host/network %s to denylist' % net)
def clear():
global lock
logger.logInfo('Clearing all bans')
for net in bans.copy():
logdebug("Unbanning net: %s" % net)
unban(net)
with lock:
logdebug("Clearing IPv4/IPv6 table")
tables.clearIPv4Table()
tables.clearIPv6Table()
try:
@@ -275,21 +300,35 @@ def snat6(snat_target):
def autopurge():
global f2boptions
logdebug("autopurge thread started")
while not quit_now:
logdebug("autopurge tick")
time.sleep(10)
refreshF2boptions()
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
logdebug("QUEUE_UNBAN: %s" % QUEUE_UNBAN)
if QUEUE_UNBAN:
for net in QUEUE_UNBAN:
logdebug("Autopurge: unbanning queued net: %s" % net)
unban(str(net))
for net in bans.copy():
if bans[net]['attempts'] >= MAX_ATTEMPTS:
NET_BAN_TIME = calcNetBanTime(bans[net]['ban_counter'])
TIME_SINCE_LAST_ATTEMPT = time.time() - bans[net]['last_attempt']
if TIME_SINCE_LAST_ATTEMPT > NET_BAN_TIME:
unban(net)
# Only check expiry for actively banned IPs:
active_bans = r.hgetall('F2B_ACTIVE_BANS')
now = time.time()
for net_str, expire_str in active_bans.items():
logdebug("Checking ban expiry for (actively banned): %s" % net_str)
# Defensive: always process if timer missing or expired
try:
expire = float(expire_str)
except Exception:
logdebug("Invalid expire time for %s; unbanning" % net_str)
unban(net_str)
continue
time_left = expire - now
logdebug("Time left for %s: %.1f seconds" % (net_str, time_left))
if time_left <= 0:
logdebug("Ban expired for %s" % net_str)
unban(net_str)
def mailcowChainOrder():
global lock
@@ -359,7 +398,7 @@ def whitelistUpdate():
with lock:
if Counter(new_whitelist) != Counter(WHITELIST):
WHITELIST = new_whitelist
logger.logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
logger.logInfo('Allowlist was changed, it has %s entries' % len(WHITELIST))
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
def blacklistUpdate():
@@ -375,7 +414,7 @@ def blacklistUpdate():
addban = set(new_blacklist).difference(BLACKLIST)
delban = set(BLACKLIST).difference(new_blacklist)
BLACKLIST = new_blacklist
logger.logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
logger.logInfo('Denylist was changed, it has %s entries' % len(BLACKLIST))
if addban:
for net in addban:
permBan(net=net)
@@ -386,42 +425,43 @@ def blacklistUpdate():
def sigterm_quit(signum, frame):
global clear_before_quit
logdebug("SIGTERM received, setting clear_before_quit to True and exiting")
clear_before_quit = True
sys.exit(exit_code)
def berfore_quit():
def before_quit():
logdebug("before_quit called, clear_before_quit=%s" % clear_before_quit)
if clear_before_quit:
clear()
if pubsub is not None:
pubsub.unsubscribe()
if __name__ == '__main__':
atexit.register(berfore_quit)
logger = Logger()
logdebug("Sys.argv: %s" % sys.argv)
atexit.register(before_quit)
signal.signal(signal.SIGTERM, sigterm_quit)
# init Logger
logger = Logger()
# init backend
backend = sys.argv[1]
logdebug("Backend: %s" % backend)
if backend == "nftables":
logger.logInfo('Using NFTables backend')
tables = NFTables(chain_name, logger)
else:
logger.logInfo('Using IPTables backend')
logger.logWarn(
"DEPRECATION: iptables-legacy is deprecated and will be removed in future releases. "
"Please switch to nftables on your host to ensure complete compatibility."
)
time.sleep(5)
tables = IPTables(chain_name, logger)
# In case a previous session was killed without cleanup
clear()
# Reinit MAILCOW chain
# Is called before threads start, no locking
logger.logInfo("Initializing mailcow netfilter chain")
tables.initChainIPv4()
tables.initChainIPv6()
if os.getenv("DISABLE_NETFILTER_ISOLATION_RULE").lower() in ("y", "yes"):
if os.getenv("DISABLE_NETFILTER_ISOLATION_RULE", "").lower() in ("y", "yes"):
logger.logInfo(f"Skipping {chain_name} isolation")
else:
logger.logInfo(f"Setting {chain_name} isolation")
@@ -432,23 +472,28 @@ if __name__ == '__main__':
try:
redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '')
logdebug(
"Connecting redis (SLAVEOF_IP:%s, PORT:%s)" % (redis_slaveof_ip, redis_slaveof_port))
if "".__eq__(redis_slaveof_ip):
r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0, password=os.environ['REDISPASS'])
r = redis.StrictRedis(
host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0, password=os.environ['REDISPASS'])
else:
r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0, password=os.environ['REDISPASS'])
r = redis.StrictRedis(
host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0, password=os.environ['REDISPASS'])
r.ping()
pubsub = r.pubsub()
except Exception as ex:
print('%s - trying again in 3 seconds' % (ex))
logdebug(
'Redis connection failed: %s - trying again in 3 seconds' % (ex))
time.sleep(3)
else:
break
logger.set_redis(r)
logdebug("Redis connection established, setting up F2B keys")
# rename fail2ban to netfilter
if r.exists('F2B_LOG'):
logdebug("Renaming F2B_LOG to NETFILTER_LOG")
r.rename('F2B_LOG', 'NETFILTER_LOG')
# clear bans in redis
r.delete('F2B_ACTIVE_BANS')
r.delete('F2B_PERM_BANS')
@@ -463,7 +508,7 @@ if __name__ == '__main__':
snat_ip = os.getenv('SNAT_TO_SOURCE')
snat_ipo = ipaddress.ip_address(snat_ip)
if type(snat_ipo) is ipaddress.IPv4Address:
snat4_thread = Thread(target=snat4,args=(snat_ip,))
snat4_thread = Thread(target=snat4, args=(snat_ip,))
snat4_thread.daemon = True
snat4_thread.start()
except ValueError:
@@ -499,4 +544,5 @@ if __name__ == '__main__':
while not quit_now:
time.sleep(0.5)
sys.exit(exit_code)
logdebug("Exiting with code %s" % exit_code)
sys.exit(exit_code)
+19 -7
View File
@@ -1,5 +1,6 @@
import time
import json
import datetime
class Logger:
def __init__(self):
@@ -8,17 +9,28 @@ class Logger:
def set_redis(self, redis):
self.r = redis
def _format_timestamp(self):
# Local time with milliseconds
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def log(self, priority, message):
tolog = {}
tolog['time'] = int(round(time.time()))
tolog['priority'] = priority
tolog['message'] = message
print(message)
# build redis-friendly dict
tolog = {
'time': int(round(time.time())), # keep raw timestamp for Redis
'priority': priority,
'message': message
}
# print human-readable message with timestamp
ts = self._format_timestamp()
print(f"{ts} {priority.upper()}: {message}", flush=True)
# also push JSON to Redis if connected
if self.r is not None:
try:
self.r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
except Exception as ex:
print('Failed logging to redis: %s' % (ex))
print(f'{ts} WARN: Failed logging to redis: {ex}', flush=True)
def logWarn(self, message):
self.log('warn', message)
@@ -27,4 +39,4 @@ class Logger:
self.log('crit', message)
def logInfo(self, message):
self.log('info', message)
self.log('info', message)
+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 []
+2 -2
View File
@@ -10,7 +10,7 @@ def includes_conf(env, template_vars):
server_name_config = f"server_name {template_vars['MAILCOW_HOSTNAME']} autodiscover.* autoconfig.* {' '.join(template_vars['ADDITIONAL_SERVER_NAMES'])};"
listen_plain_config = f"listen {template_vars['HTTP_PORT']};"
listen_ssl_config = f"listen {template_vars['HTTPS_PORT']};"
if not template_vars['DISABLE_IPv6']:
if template_vars['ENABLE_IPV6']:
listen_plain_config += f"\nlisten [::]:{template_vars['HTTP_PORT']};"
listen_ssl_config += f"\nlisten [::]:{template_vars['HTTPS_PORT']} ssl;"
listen_ssl_config += "\nhttp2 on;"
@@ -58,7 +58,7 @@ def prepare_template_vars():
'SOGOHOST': os.getenv("SOGOHOST", ipv4_network + ".248"),
'RSPAMDHOST': os.getenv("RSPAMDHOST", "rspamd-mailcow"),
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
'DISABLE_IPv6': os.getenv("DISABLE_IPv6", "n").lower() in ("y", "yes"),
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
}
+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 []
+21 -9
View File
@@ -1,19 +1,23 @@
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM php:8.2-fpm-alpine3.21
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
# renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG APCU_PECL_VERSION=5.1.24
ARG APCU_PECL_VERSION=5.1.28
# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
ARG IMAGICK_PECL_VERSION=3.8.0
ARG IMAGICK_PECL_VERSION=3.8.1
# renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG MAILPARSE_PECL_VERSION=3.1.8
ARG MAILPARSE_PECL_VERSION=3.1.9
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG MEMCACHED_PECL_VERSION=3.2.0
ARG MEMCACHED_PECL_VERSION=3.4.0
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
ARG REDIS_PECL_VERSION=6.1.0
ARG REDIS_PECL_VERSION=6.3.0
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
ARG COMPOSER_VERSION=2.8.6
ARG COMPOSER_VERSION=2.9.5
RUN apk add -U --no-cache autoconf \
aspell-dev \
@@ -72,7 +76,7 @@ RUN apk add -U --no-cache autoconf \
&& pecl clear-cache \
&& docker-php-ext-configure intl \
&& docker-php-ext-configure exif \
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
--with-jpeg=/usr/include/ \
--with-webp \
--with-xpm \
@@ -109,6 +113,14 @@ RUN apk add -U --no-cache autoconf \
COPY ./docker-entrypoint.sh /
ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
CMD ["php-fpm"]
ENV MAILCOW_AGENT_SERVICE=php-fpm \
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh php-fpm"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+24 -52
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://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"mysql-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
echo "Could not get mysql-mailcow container id... trying again"
sleep 2
done
echo "MySQL @ ${CONTAINER_ID}"
SQL_LOOP_C=0
SQL_CHANGED=0
until [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; do
if [ ${SQL_LOOP_C} -gt 4 ]; then
echo "Tried to upgrade MySQL and failed, giving up after ${SQL_LOOP_C} retries and starting container (oops, not good)"
# Wait for MariaDB. The upstream mariadb image already runs mariadb-upgrade
# itself on startup when needed
echo "Waiting for MariaDB socket at /var/run/mysqld/mysqld.sock..."
WAIT_C=0
until mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} -e "SELECT 1" >/dev/null 2>&1; do
WAIT_C=$((WAIT_C+1))
if [ ${WAIT_C} -gt 60 ]; then
echo "MariaDB did not respond after 60s — continuing anyway."
break
fi
SQL_FULL_UPGRADE_RETURN=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_upgrade"}' --silent -H 'Content-type: application/json')
SQL_UPGRADE_STATUS=$(echo ${SQL_FULL_UPGRADE_RETURN} | jq -r .type)
SQL_LOOP_C=$((SQL_LOOP_C+1))
echo "SQL upgrade iteration #${SQL_LOOP_C}"
if [[ ${SQL_UPGRADE_STATUS} == 'warning' ]]; then
SQL_CHANGED=1
echo "MySQL applied an upgrade, debug output:"
echo ${SQL_FULL_UPGRADE_RETURN}
sleep 3
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
echo "Waiting for SQL to return, please wait"
sleep 2
done
continue
elif [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; then
echo "MySQL is up-to-date - debug output:"
echo ${SQL_FULL_UPGRADE_RETURN}
else
echo "No valid reponse for mysql_upgrade was received, debug output:"
echo ${SQL_FULL_UPGRADE_RETURN}
fi
sleep 1
done
echo "MariaDB is ready."
# doing post-installation stuff, if SQL was upgraded (master and slave)
if [ ${SQL_CHANGED} -eq 1 ]; then
POSTFIX=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
if [[ -z "${POSTFIX}" ]] || ! [[ "${POSTFIX}" =~ ^[[:alnum:]]*$ ]]; then
echo "Could not determine Postfix container ID, skipping Postfix restart."
else
echo "Restarting Postfix"
curl -X POST --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${POSTFIX}/restart | jq -r '.msg'
echo "Sleeping 5 seconds..."
sleep 5
fi
fi
# Check mysql tz import (master and slave)
# Timezone tables — check if CONVERT_TZ works, import if it returns NULL.
# Some Alpine builds drop mariadb-tzinfo-to-sql; fall back to a Python
# emitter that produces the same INSERT statements from /usr/share/zoneinfo.
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
SQL_FULL_TZINFO_IMPORT_RETURN=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_tzinfo_to_sql"}' --silent -H 'Content-type: application/json')
echo "MySQL mysql_tzinfo_to_sql - debug output:"
echo ${SQL_FULL_TZINFO_IMPORT_RETURN}
echo "Importing timezone data into mysql.time_zone_* …"
if command -v mariadb-tzinfo-to-sql >/dev/null 2>&1; then
mariadb-tzinfo-to-sql /usr/share/zoneinfo 2>/dev/null \
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
elif command -v mysql_tzinfo_to_sql >/dev/null 2>&1; then
mysql_tzinfo_to_sql /usr/share/zoneinfo 2>/dev/null \
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
else
echo "No tzinfo-to-sql tool available — skipping timezone import."
fi
fi
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
@@ -167,7 +139,7 @@ DELIMITER //
CREATE EVENT clean_spamalias
ON SCHEDULE EVERY 1 DAY DO
BEGIN
DELETE FROM spamalias WHERE validity < UNIX_TIMESTAMP();
DELETE FROM spamalias WHERE validity < UNIX_TIMESTAMP() AND permanent = 0;
END;
//
DELIMITER ;
@@ -0,0 +1,62 @@
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
ENV CGO_ENABLED=0 \
GO111MODULE=on \
NOOPT=1 \
VERSION=1.8.22
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
cd /src/postfix-tlspol && \
scripts/build.sh build-only
FROM debian:bookworm-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ENV LC_ALL=C
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
dirmngr \
dnsutils \
iputils-ping \
sudo \
supervisor \
redis-tools \
syslog-ng \
syslog-ng-core \
syslog-ng-mod-redis \
tzdata \
&& rm -rf /var/lib/apt/lists/* \
&& touch /etc/default/locale
COPY supervisord.conf /etc/supervisor/supervisord.conf
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
COPY postfix-tlspol.sh /opt/postfix-tlspol.sh
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
COPY docker-entrypoint.sh /docker-entrypoint.sh
COPY --from=builder /src/postfix-tlspol/build/postfix-tlspol /usr/local/bin/postfix-tlspol
RUN chmod +x /opt/postfix-tlspol.sh \
/usr/local/sbin/stop-supervisor.sh \
/docker-entrypoint.sh
RUN rm -rf /tmp/* /var/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
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 []
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
cp /etc/syslog-ng/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng.conf
fi
exec "$@"
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
LOGLVL=info
if [ ${DEV_MODE} != "n" ]; then
echo -e "\e[31mEnabling debug mode\e[0m"
set -x
LOGLVL=debug
fi
[[ ! -d /etc/postfix-tlspol ]] && mkdir -p /etc/postfix-tlspol
[[ ! -d /var/lib/postfix-tlspol ]] && mkdir -p /var/lib/postfix-tlspol
until dig +short mailcow.email > /dev/null; do
echo "Waiting for DNS..."
sleep 1
done
# Do not attempt to write to slave
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
export REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
else
export REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning"
fi
until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
echo "Waiting for Redis..."
sleep 2
done
echo "Waiting for Postfix..."
until ping postfix -c1 > /dev/null; do
sleep 1
done
echo "Postfix OK"
cat <<EOF > /etc/postfix-tlspol/config.yaml
server:
address: 0.0.0.0:8642
log-level: ${LOGLVL}
prefetch: true
cache-file: /var/lib/postfix-tlspol/cache.db
dns:
# must support DNSSEC
address: 127.0.0.11:53
EOF
/usr/local/bin/postfix-tlspol -config /etc/postfix-tlspol/config.yaml
+8
View File
@@ -0,0 +1,8 @@
#!/bin/bash
printf "READY\n";
while read line; do
echo "Processing Event: $line" >&2;
kill -3 $(cat "/var/run/supervisord.pid")
done < /dev/stdin
@@ -0,0 +1,25 @@
[supervisord]
pidfile=/var/run/supervisord.pid
nodaemon=true
user=root
[program:syslog-ng]
command=/usr/sbin/syslog-ng --foreground --no-caps
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autostart=true
[program:postfix-tlspol]
startsecs=10
autorestart=true
command=/opt/postfix-tlspol.sh
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[eventlistener:processes]
command=/usr/local/sbin/stop-supervisor.sh
events=PROCESS_STATE_STOPPED, PROCESS_STATE_EXITED, PROCESS_STATE_FATAL
@@ -0,0 +1,45 @@
@version: 3.38
@include "scl.conf"
options {
chain_hostnames(off);
flush_lines(0);
use_dns(no);
dns_cache(no);
use_fqdn(no);
owner("root"); group("adm"); perm(0640);
stats_freq(0);
bad_hostname("^gconfd$");
};
source s_src {
unix-stream("/dev/log");
internal();
};
destination d_stdout { pipe("/dev/stdout"); };
destination d_redis_ui_log {
redis(
host("`REDIS_SLAVEOF_IP`")
persist-name("redis1")
port(`REDIS_SLAVEOF_PORT`)
auth("`REDISPASS`")
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
);
};
filter f_mail { facility(mail); };
# start
# overriding warnings are still displayed when the entrypoint runs its initial check
# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs
# Some other warnings are ignored
filter f_ignore {
not match("overriding earlier entry" value("MESSAGE"));
not match("TLS SNI from checks.mailcow.email" value("MESSAGE"));
not match("no SASL support" value("MESSAGE"));
not facility (local0, local1, local2, local3, local4, local5, local6, local7);
};
# end
log {
source(s_src);
filter(f_ignore);
destination(d_stdout);
filter(f_mail);
destination(d_redis_ui_log);
};
@@ -0,0 +1,45 @@
@version: 3.38
@include "scl.conf"
options {
chain_hostnames(off);
flush_lines(0);
use_dns(no);
dns_cache(no);
use_fqdn(no);
owner("root"); group("adm"); perm(0640);
stats_freq(0);
bad_hostname("^gconfd$");
};
source s_src {
unix-stream("/dev/log");
internal();
};
destination d_stdout { pipe("/dev/stdout"); };
destination d_redis_ui_log {
redis(
host("redis-mailcow")
persist-name("redis1")
port(6379)
auth("`REDISPASS`")
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
);
};
filter f_mail { facility(mail); };
# start
# overriding warnings are still displayed when the entrypoint runs its initial check
# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs
# Some other warnings are ignored
filter f_ignore {
not match("overriding earlier entry" value("MESSAGE"));
not match("TLS SNI from checks.mailcow.email" value("MESSAGE"));
not match("no SASL support" value("MESSAGE"));
not facility (local0, local1, local2, local3, local4, local5, local6, local7);
};
# end
log {
source(s_src);
filter(f_ignore);
destination(d_stdout);
filter(f_mail);
destination(d_redis_ui_log);
};
+16 -4
View File
@@ -1,9 +1,13 @@
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>"
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ENV LC_ALL C
ENV LC_ALL=C
RUN dpkg-divert --local --rename --add /sbin/initctl \
&& ln -sf /bin/true /sbin/initctl \
@@ -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 []
+4 -1
View File
@@ -329,14 +329,17 @@ query = SELECT goto FROM alias
SELECT id FROM alias
WHERE address='%s'
AND (active='1' OR active='2')
AND sender_allowed='1'
), (
SELECT id FROM alias
WHERE address='@%d'
AND (active='1' OR active='2')
AND sender_allowed='1'
)
)
)
AND active='1'
AND sender_allowed='1'
AND (domain IN
(SELECT domain FROM domain
WHERE domain='%d'
@@ -390,7 +393,7 @@ hosts = unix:/var/run/mysqld/mysqld.sock
dbname = ${DBNAME}
query = SELECT goto FROM spamalias
WHERE address='%s'
AND validity >= UNIX_TIMESTAMP()
AND (validity >= UNIX_TIMESTAMP() OR permanent != 0)
EOF
if [ ! -f /opt/postfix/conf/dns_blocklists.cf ]; then
@@ -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 {
+20 -8
View File
@@ -1,9 +1,13 @@
FROM debian:bookworm-slim
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
FROM ${AGENT_IMAGE} AS mailcow-agent-src
FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ARG RSPAMD_VER=rspamd_3.11.1-1~ab0b44951
ARG CODENAME=bookworm
ARG RSPAMD_VER=rspamd_3.14.3-1~236eb65
ARG CODENAME=trixie
ENV LC_ALL=C
RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -14,8 +18,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
dnsutils \
netcat-traditional \
wget \
redis-tools \
procps \
redis-tools \
procps \
nano \
lua-cjson \
&& arch=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) \
@@ -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 []
+3 -1
View File
@@ -86,7 +86,8 @@ if [[ "${SKIP_OLEFY}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
rm /etc/rspamd/local.d/external_services.conf
fi
else
cat <<EOF > /etc/rspamd/local.d/external_services.conf
if [[ ! -f /etc/rspamd/local.d/external_services.conf ]]; then
cat <<EOF > /etc/rspamd/local.d/external_services.conf
oletools {
# default olefy settings
servers = "olefy:10055";
@@ -100,6 +101,7 @@ oletools {
retransmits = 1;
}
EOF
fi
fi
# Provide additional lua modules
+164 -32
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.17
ARG GOSU_VERSION=1.19
ENV LC_ALL=C
# Prerequisites
RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \
&& apt-get update && apt-get install -y --no-install-recommends \
apt-transport-https \
ca-certificates \
gettext \
gnupg \
mariadb-client \
rsync \
supervisor \
syslog-ng \
syslog-ng-core \
syslog-ng-mod-redis \
dirmngr \
netcat-traditional \
psmisc \
wget \
patch \
# Install dependencies, build SOPE and SOGo, then clean up (all in one layer to minimize image size)
RUN apt-get update && apt-get install -y --no-install-recommends \
# Build dependencies
git \
build-essential \
gobjc \
pkg-config \
gnustep-make \
gnustep-base-runtime \
libgnustep-base-dev \
libxml2-dev \
libldap2-dev \
libssl-dev \
zlib1g-dev \
libpq-dev \
libmariadb-dev-compat \
libmemcached-dev \
libsodium-dev \
libcurl4-openssl-dev \
libzip-dev \
libytnef0-dev \
libwbxml2-dev \
curl \
ca-certificates \
# Runtime dependencies
apt-transport-https \
gettext \
gnupg \
mariadb-client \
rsync \
supervisor \
syslog-ng \
syslog-ng-core \
syslog-ng-mod-redis \
dirmngr \
netcat-traditional \
psmisc \
wget \
patch \
libobjc4 \
libxml2 \
libldap-2.5-0 \
libssl3 \
zlib1g \
libmariadb3 \
libmemcached11 \
libsodium23 \
libcurl4 \
libzip4 \
libytnef0 \
libwbxml2-1 \
# Download gosu
&& dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
&& chmod +x /usr/local/bin/gosu \
&& gosu nobody true \
&& mkdir /usr/share/doc/sogo \
&& touch /usr/share/doc/sogo/empty.sh \
&& wget -O- https://keys.openpgp.org/vks/v1/by-fingerprint/74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9 | gpg --dearmor | apt-key add - \
&& echo "deb [trusted=yes] ${SOGO_DEBIAN_REPOSITORY} ${DEBIAN_VERSION} main" > /etc/apt/sources.list.d/sogo.list \
&& apt-get update && apt-get install -y --no-install-recommends \
sogo \
sogo-activesync \
&& apt-get autoclean \
# Build SOPE
&& git clone --depth 1 --branch ${SOPE_VERSION} https://github.com/Alinto/sope.git /tmp/sope \
&& cd /tmp/sope \
&& rm -rf .git \
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
&& ./configure --prefix=/usr --disable-debug --disable-strip \
&& make -j$(nproc) \
&& make install \
&& cd / \
&& rm -rf /tmp/sope \
# Build SOGo with security patches
&& git clone --depth 1 --branch ${SOGO_VERSION} https://github.com/Alinto/sogo.git /tmp/sogo \
&& cd /tmp/sogo \
&& git config user.email "builder@mailcow.local" \
&& git config user.name "SOGo Builder" \
&& for patch in ${SOGO_SECURITY_PATCHES}; do \
echo "Applying security patch: ${patch}"; \
git fetch origin ${patch} && git cherry-pick ${patch}; \
done \
&& rm -rf .git \
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
&& ./configure --disable-debug --disable-strip \
&& make -j$(nproc) \
&& make install \
&& cd /tmp/sogo/ActiveSync \
&& . /usr/share/GNUstep/Makefiles/GNUstep.sh \
&& make -j$(nproc) install \
&& cd / \
&& rm -rf /tmp/sogo \
# Strip binaries
&& strip --strip-unneeded /usr/local/sbin/sogod 2>/dev/null || true \
&& strip --strip-unneeded /usr/local/sbin/sogo-tool 2>/dev/null || true \
&& strip --strip-unneeded /usr/local/sbin/sogo-ealarms-notify 2>/dev/null || true \
&& strip --strip-unneeded /usr/local/sbin/sogo-slapd-sockd 2>/dev/null || true \
# Remove build dependencies and clean up
&& apt-get purge -y --auto-remove \
git \
build-essential \
gobjc \
gnustep-make \
libgnustep-base-dev \
libxml2-dev \
libldap2-dev \
libssl-dev \
zlib1g-dev \
libpq-dev \
libmariadb-dev-compat \
libmemcached-dev \
libsodium-dev \
libcurl4-openssl-dev \
libzip-dev \
libytnef0-dev \
curl \
&& apt-get autoremove -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/share/doc/* \
&& rm -rf /usr/share/man/* \
&& rm -rf /var/cache/debconf/* \
&& rm -rf /tmp/* \
&& rm -rf /root/.cache \
&& find /usr/local/lib -name '*.a' -delete \
&& find /usr/lib -name '*.a' -delete \
&& mkdir -p /usr/share/doc/sogo \
&& touch /usr/share/doc/sogo/empty.sh \
&& touch /etc/default/locale
# Configure library paths
RUN echo "/usr/lib64" > /etc/ld.so.conf.d/sogo.conf \
&& echo "/usr/local/lib/sogo" >> /etc/ld.so.conf.d/sogo.conf \
&& echo "/usr/local/lib/GNUstep/Frameworks/SOGo.framework/Versions/5/sogo" >> /etc/ld.so.conf.d/sogo.conf \
&& ldconfig
# Create sogo user and group
RUN groupadd -r -g 999 sogo \
&& useradd -r -u 999 -g sogo -d /var/lib/sogo -s /bin/bash -c "SOGo Daemon" sogo \
&& mkdir -p /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo \
&& chown -R sogo:sogo /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo
# Create symlinks for SOGo binaries
RUN ln -s /usr/local/sbin/sogod /usr/sbin/sogod \
&& ln -s /usr/local/sbin/sogo-tool /usr/sbin/sogo-tool \
&& ln -s /usr/local/sbin/sogo-ealarms-notify /usr/sbin/sogo-ealarms-notify \
&& ln -s /usr/local/sbin/sogo-slapd-sockd /usr/sbin/sogo-slapd-sockd
# Copy configuration files and scripts
COPY ./bootstrap-sogo.sh /bootstrap-sogo.sh
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
@@ -54,6 +178,14 @@ COPY docker-entrypoint.sh /
RUN chmod +x /bootstrap-sogo.sh \
/usr/local/sbin/stop-supervisor.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
ENV MAILCOW_AGENT_SERVICE=sogo \
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
CMD []
+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>
+15 -7
View File
@@ -24,6 +24,10 @@ while [[ "${DBV_NOW}" != "${DBV_NEW}" ]]; do
done
echo "DB schema is ${DBV_NOW}"
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DROP TRIGGER IF EXISTS sogo_update_password"
fi
# cat /dev/urandom seems to hang here occasionally and is not recommended anyway, better use openssl
RAND_PASS=$(openssl rand -base64 16 | tr -dc _A-Z-a-z-0-9)
@@ -126,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
@@ -145,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 []
+20 -4
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>"
@@ -16,7 +20,6 @@ RUN apk add --update \
fcgi \
openssl \
nagios-plugins-mysql \
nagios-plugins-dns \
nagios-plugins-disk \
bind-tools \
redis \
@@ -32,9 +35,22 @@ RUN apk add --update \
tzdata \
whois \
&& curl https://raw.githubusercontent.com/mludvig/smtp-cli/v3.10/smtp-cli -o /smtp-cli \
&& chmod +x smtp-cli
&& chmod +x smtp-cli \
&& mkdir /usr/lib/mailcow
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 []
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
while getopts "H:s:" opt; do
case "$opt" in
H) HOST="$OPTARG" ;;
s) SERVER="$OPTARG" ;;
*) echo "Usage: $0 -H host -s server"; exit 3 ;;
esac
done
if [ -z "$SERVER" ]; then
echo "No DNS Server provided"
exit 3
fi
if [ -z "$HOST" ]; then
echo "No host to test provided"
exit 3
fi
# run dig and measure the time it takes to run
START_TIME=$(perl -MTime::HiRes -e 'print Time::HiRes::time')
dig_output=$(dig +short +timeout=2 +tries=1 "$HOST" @"$SERVER" 2>/dev/null)
dig_rc=$?
END_TIME=$(perl -MTime::HiRes -e 'print Time::HiRes::time')
dig_output_ips=$(echo "$dig_output" | grep -E '^[0-9.]+$' | sort | paste -sd ',' -)
ELAPSED_TIME=$(perl -e "printf('%.3f', $END_TIME - $START_TIME)")
# validate and perform nagios like output and exit codes
if [ $dig_rc -ne 0 ] || [ -z "$dig_output" ]; then
echo "Domain $HOST was not found by the server"
exit 2
elif [ $dig_rc -eq 0 ]; then
echo "DNS OK: $ELAPSED_TIME seconds response time. $HOST returns $dig_output_ips"
exit 0
else
echo "Unknown error"
exit 3
fi
+3
View File
@@ -0,0 +1,3 @@
[client]
ssl = false
ssl-verify-server-cert = false
+85 -577
View File
@@ -1,5 +1,10 @@
#!/bin/bash
if [ "${DEV_MODE}" != "n" ]; then
echo -e "\e[31mEnabled Debug Mode\e[0m"
set -x
fi
trap "exit" INT TERM
trap "kill 0" EXIT
@@ -33,7 +38,7 @@ if [[ ! -p /tmp/com_pipe ]]; then
fi
# Wait for containers
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
echo "Waiting for SQL..."
sleep 2
done
@@ -183,44 +188,6 @@ function notify_error() {
fi
}
get_container_ip() {
# ${1} is container
CONTAINER_ID=()
CONTAINER_IPS=()
CONTAINER_IP=
LOOP_C=1
until [[ ${CONTAINER_IP} =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]] || [[ ${LOOP_C} -gt 5 ]]; do
if [ ${IP_BY_DOCKER_API} -eq 0 ]; then
CONTAINER_IP=$(dig a "${1}" +short)
else
sleep 0.5
# get long container id for exact match
CONTAINER_ID=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring == \"${1}\") | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id"))
# returned id can have multiple elements (if scaled), shuffle for random test
CONTAINER_ID=($(printf "%s\n" "${CONTAINER_ID[@]}" | shuf))
if [[ ! -z ${CONTAINER_ID} ]]; then
for matched_container in "${CONTAINER_ID[@]}"; do
CONTAINER_IPS=($(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${matched_container}/json | jq -r '.NetworkSettings.Networks[].IPAddress'))
for ip_match in "${CONTAINER_IPS[@]}"; do
# grep will do nothing if one of these vars is empty
[[ -z ${ip_match} ]] && continue
[[ -z ${IPV4_NETWORK} ]] && continue
# only return ips that are part of our network
if ! grep -q ${IPV4_NETWORK} <(echo ${ip_match}); then
continue
else
CONTAINER_IP=${ip_match}
break
fi
done
[[ ! -z ${CONTAINER_IP} ]] && break
done
fi
fi
LOOP_C=$((LOOP_C + 1))
done
[[ ${LOOP_C} -gt 5 ]] && echo 240.0.0.0 || echo ${CONTAINER_IP}
}
# One-time check
if grep -qi "$(echo ${IPV6_NETWORK} | cut -d: -f1-3)" <<< "$(ip a s)"; then
@@ -262,270 +229,6 @@ external_checks() {
return 1
}
nginx_checks() {
err_count=0
diff_c=0
THRESHOLD=${NGINX_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/nginx-mailcow; echo "$(tail -50 /tmp/nginx-mailcow)" > /tmp/nginx-mailcow
host_ip=$(get_container_ip nginx-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_http -4 -H ${host_ip} -u / -p 8081 2>> /tmp/nginx-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Nginx" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
unbound_checks() {
err_count=0
diff_c=0
THRESHOLD=${UNBOUND_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/unbound-mailcow; echo "$(tail -50 /tmp/unbound-mailcow)" > /tmp/unbound-mailcow
host_ip=$(get_container_ip unbound-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_dns -s ${host_ip} -H stackoverflow.com 2>> /tmp/unbound-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
DNSSEC=$(dig com +dnssec | egrep 'flags:.+ad')
if [[ -z ${DNSSEC} ]]; then
echo "DNSSEC failure" 2>> /tmp/unbound-mailcow 1>&2
err_count=$(( ${err_count} + 1))
else
echo "DNSSEC check succeeded" 2>> /tmp/unbound-mailcow 1>&2
fi
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Unbound" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
redis_checks() {
# A check for the local redis container
err_count=0
diff_c=0
THRESHOLD=${REDIS_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/redis-mailcow; echo "$(tail -50 /tmp/redis-mailcow)" > /tmp/redis-mailcow
host_ip=$(get_container_ip redis-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_tcp -4 -H redis-mailcow -p 6379 -E -s "AUTH ${REDISPASS}\nPING\n" -q "QUIT" -e "PONG" 2>> /tmp/redis-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Redis" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
mysql_checks() {
err_count=0
diff_c=0
THRESHOLD=${MYSQL_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/mysql-mailcow; echo "$(tail -50 /tmp/mysql-mailcow)" > /tmp/mysql-mailcow
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_mysql -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_mysql_query -s /var/run/mysqld/mysqld.sock -u ${DBUSER} -p ${DBPASS} -d ${DBNAME} -q "SELECT COUNT(*) FROM information_schema.tables" 2>> /tmp/mysql-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "MySQL/MariaDB" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
mysql_repl_checks() {
err_count=0
diff_c=0
THRESHOLD=${MYSQL_REPLICATION_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/mysql_repl_checks; echo "$(tail -50 /tmp/mysql_repl_checks)" > /tmp/mysql_repl_checks
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_mysql_slavestatus.sh -S /var/run/mysqld/mysqld.sock -u root -p ${DBROOT} 2>> /tmp/mysql_repl_checks 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "MySQL/MariaDB replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 60
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
sogo_checks() {
err_count=0
diff_c=0
THRESHOLD=${SOGO_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/sogo-mailcow; echo "$(tail -50 /tmp/sogo-mailcow)" > /tmp/sogo-mailcow
host_ip=$(get_container_ip sogo-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_http -4 -H ${host_ip} -u /SOGo.index/ -p 20000 2>> /tmp/sogo-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "SOGo" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
postfix_checks() {
err_count=0
diff_c=0
THRESHOLD=${POSTFIX_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/postfix-mailcow; echo "$(tail -50 /tmp/postfix-mailcow)" > /tmp/postfix-mailcow
host_ip=$(get_container_ip postfix-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 589 -f "watchdog@invalid" -C "RCPT TO:watchdog@localhost" -C DATA -C . -R 250 2>> /tmp/postfix-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 589 -S 2>> /tmp/postfix-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Postfix" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
clamd_checks() {
err_count=0
diff_c=0
THRESHOLD=${CLAMD_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/clamd-mailcow; echo "$(tail -50 /tmp/clamd-mailcow)" > /tmp/clamd-mailcow
host_ip=$(get_container_ip clamd-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_clamd -4 -H ${host_ip} 2>> /tmp/clamd-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Clamd" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 120 ) + 20 ))
fi
done
return 1
}
dovecot_checks() {
err_count=0
diff_c=0
THRESHOLD=${DOVECOT_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/dovecot-mailcow; echo "$(tail -50 /tmp/dovecot-mailcow)" > /tmp/dovecot-mailcow
host_ip=$(get_container_ip dovecot-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_smtp -4 -H ${host_ip} -p 24 -f "watchdog@invalid" -C "RCPT TO:<watchdog@invalid>" -L -R "User doesn't exist" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_imap -4 -H ${host_ip} -p 993 -S -e "OK " 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_imap -4 -H ${host_ip} -p 143 -e "OK " 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 10001 -e "VERSION" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 4190 -e "Dovecot ready" 2>> /tmp/dovecot-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Dovecot" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
dovecot_repl_checks() {
err_count=0
diff_c=0
THRESHOLD=${DOVECOT_REPL_THRESHOLD}
D_REPL_STATUS=$(redis-cli -h redis -a ${REDISPASS} --no-auth-warning -r GET DOVECOT_REPL_HEALTH)
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
err_c_cur=${err_count}
D_REPL_STATUS=$(redis-cli --raw -h redis -a ${REDISPASS} --no-auth-warning GET DOVECOT_REPL_HEALTH)
if [[ "${D_REPL_STATUS}" != "1" ]]; then
err_count=$(( ${err_count} + 1 ))
fi
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Dovecot replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 60
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
cert_checks() {
err_count=0
diff_c=0
@@ -534,11 +237,9 @@ cert_checks() {
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/certcheck; echo "$(tail -50 /tmp/certcheck)" > /tmp/certcheck
host_ip_postfix=$(get_container_ip postfix)
host_ip_dovecot=$(get_container_ip dovecot)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_smtp -H ${host_ip_postfix} -p 589 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_imap -H ${host_ip_dovecot} -p 993 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_smtp -H postfix -p 589 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_imap -H dovecot -p 993 -4 -S -D 7 2>> /tmp/certcheck 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Primary certificate expiry check" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
@@ -548,31 +249,6 @@ cert_checks() {
return 1
}
phpfpm_checks() {
err_count=0
diff_c=0
THRESHOLD=${PHPFPM_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/php-fpm-mailcow; echo "$(tail -50 /tmp/php-fpm-mailcow)" > /tmp/php-fpm-mailcow
host_ip=$(get_container_ip php-fpm-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_tcp -H ${host_ip} -p 9001 2>> /tmp/php-fpm-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
/usr/lib/nagios/plugins/check_tcp -H ${host_ip} -p 9002 2>> /tmp/php-fpm-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "PHP-FPM" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
ratelimit_checks() {
err_count=0
@@ -706,90 +382,63 @@ acme_checks() {
return 1
}
rspamd_checks() {
err_count=0
diff_c=0
THRESHOLD=${RSPAMD_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/rspamd-mailcow; echo "$(tail -50 /tmp/rspamd-mailcow)" > /tmp/rspamd-mailcow
host_ip=$(get_container_ip rspamd-mailcow)
err_c_cur=${err_count}
SCORE=$(echo 'To: null@localhost
From: watchdog@localhost
Empty
' | usr/bin/curl --max-time 10 -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/scan | jq -rc .default.required_score | sed 's/\..*//' )
if [[ ${SCORE} -ne 9999 ]]; then
echo "Rspamd settings check failed, score returned: ${SCORE}" 2>> /tmp/rspamd-mailcow 1>&2
err_count=$(( ${err_count} + 1))
else
echo "Rspamd settings check succeeded, score returned: ${SCORE}" 2>> /tmp/rspamd-mailcow 1>&2
fi
# A dirty hack until a PING PONG event is implemented to worker proxy
# We expect an empty response, not a timeout
if [ "$(curl -s --max-time 10 ${host_ip}:9900 2> /dev/null ; echo $?)" == "28" ]; then
echo "Milter check failed" 2>> /tmp/rspamd-mailcow 1>&2; err_count=$(( ${err_count} + 1 ));
else
echo "Milter check succeeded" 2>> /tmp/rspamd-mailcow 1>&2
fi
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Rspamd" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
olefy_checks() {
err_count=0
diff_c=0
THRESHOLD=${OLEFY_THRESHOLD}
# Reduce error count by 2 after restarting an unhealthy container
trap "[ ${err_count} -gt 1 ] && err_count=$(( ${err_count} - 2 ))" USR1
while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/olefy-mailcow; echo "$(tail -50 /tmp/olefy-mailcow)" > /tmp/olefy-mailcow
host_ip=$(get_container_ip olefy-mailcow)
err_c_cur=${err_count}
/usr/lib/nagios/plugins/check_tcp -4 -H ${host_ip} -p 10055 -s "PING\n" 2>> /tmp/olefy-mailcow 1>&2; err_count=$(( ${err_count} + $? ))
[ ${err_c_cur} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "Olefy" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
if [[ $? == 10 ]]; then
diff_c=0
sleep 1
else
diff_c=0
sleep $(( ( RANDOM % 60 ) + 20 ))
fi
done
return 1
}
# Notify about start
if [[ ${WATCHDOG_NOTIFY_START} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
notify_error "watchdog-mailcow" "Watchdog started monitoring mailcow."
fi
# Create watchdog agents
# Health checks run inside each container (mailcow-agent healthcheck + heartbeat).
# We just read the per-node health field from Redis and restart on N consecutive fails.
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
REDIS_CMDLINE_FULL="redis-cli -h ${REDIS_HOST} -p ${REDIS_PORT} -a ${REDISPASS} --no-auth-warning"
HEALTH_WATCHED_SERVICES=(
postfix dovecot sogo rspamd nginx
clamd unbound olefy phpfpm postfix-tlspol
)
declare -A HEALTH_FAIL_COUNT
HEALTH_FAIL_THRESHOLD=3
[[ "${SKIP_SOGO}" =~ ^([yY][eE][sS]|[yY])+$ ]] && HEALTH_WATCHED_SERVICES=("${HEALTH_WATCHED_SERVICES[@]/sogo}")
[[ "${SKIP_CLAMD}" =~ ^([yY][eE][sS]|[yY])+$ ]] && HEALTH_WATCHED_SERVICES=("${HEALTH_WATCHED_SERVICES[@]/clamd}")
[[ "${SKIP_OLEFY}" =~ ^([yY][eE][sS]|[yY])+$ ]] && HEALTH_WATCHED_SERVICES=("${HEALTH_WATCHED_SERVICES[@]/olefy}")
(
# Counters are per-node in an associative array reset on restart, so absorb USR1
# instead of dying (other tasks trap it to decrement their own err_count).
trap '' USR1
declare -A HEALTH_FAIL_COUNT
while true; do
if ! nginx_checks; then
log_msg "Nginx hit error limit"
echo nginx-mailcow > /tmp/com_pipe
fi
for svc in "${HEALTH_WATCHED_SERVICES[@]}"; do
[[ -z "$svc" ]] && continue
nodes=$(${REDIS_CMDLINE_FULL} ZRANGEBYSCORE "mailcow.nodes.${svc}" "$(( $(date +%s) - 30 ))" "+inf" 2>/dev/null)
[[ -z "${nodes}" ]] && continue
while IFS= read -r node; do
[[ -z "${node}" ]] && continue
health=$(${REDIS_CMDLINE_FULL} HGET "mailcow.node.${svc}.${node}" health 2>/dev/null)
key="${svc}|${node}"
if [[ "${health}" == "fail" ]]; then
HEALTH_FAIL_COUNT[$key]=$(( ${HEALTH_FAIL_COUNT[$key]:-0} + 1 ))
if [[ ${HEALTH_FAIL_COUNT[$key]} -ge ${HEALTH_FAIL_THRESHOLD} ]]; then
detail=$(${REDIS_CMDLINE_FULL} HGET "mailcow.node.${svc}.${node}" health_detail 2>/dev/null)
log_msg "Service ${svc} node ${node} unhealthy (${detail:-no detail}) — sending restart"
echo "${svc}-mailcow|${node}" > /tmp/com_pipe
HEALTH_FAIL_COUNT[$key]=0
fi
else
HEALTH_FAIL_COUNT[$key]=0
fi
done <<< "${nodes}"
done
sleep 15
done
) &
PID=$!
echo "Spawned nginx_checks with PID ${PID}"
echo "Spawned registry-based health monitor with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
if [[ ${WATCHDOG_EXTERNAL_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
@@ -806,110 +455,6 @@ echo "Spawned external_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
if [[ ${WATCHDOG_MYSQL_REPLICATION_CHECKS} =~ ^([yY][eE][sS]|[yY])+$ ]]; then
(
while true; do
if ! mysql_repl_checks; then
log_msg "MySQL replication check hit error limit"
echo mysql_repl_checks > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned mysql_repl_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
(
while true; do
if ! mysql_checks; then
log_msg "MySQL hit error limit"
echo mysql-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned mysql_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! redis_checks; then
log_msg "Local Redis hit error limit"
echo redis-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned redis_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! phpfpm_checks; then
log_msg "PHP-FPM hit error limit"
echo php-fpm-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned phpfpm_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
if [[ "${SKIP_SOGO}" =~ ^([nN][oO]|[nN])+$ ]]; then
(
while true; do
if ! sogo_checks; then
log_msg "SOGo hit error limit"
echo sogo-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned sogo_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
if [ ${CHECK_UNBOUND} -eq 1 ]; then
(
while true; do
if ! unbound_checks; then
log_msg "Unbound hit error limit"
echo unbound-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned unbound_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
if [[ "${SKIP_CLAMD}" =~ ^([nN][oO]|[nN])+$ ]]; then
(
while true; do
if ! clamd_checks; then
log_msg "Clamd hit error limit"
echo clamd-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned clamd_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
(
while true; do
if ! postfix_checks; then
log_msg "Postfix hit error limit"
echo postfix-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned postfix_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! mailq_checks; then
@@ -922,42 +467,6 @@ PID=$!
echo "Spawned mailq_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! dovecot_checks; then
log_msg "Dovecot hit error limit"
echo dovecot-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned dovecot_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! dovecot_repl_checks; then
log_msg "Dovecot hit error limit"
echo dovecot_repl_checks > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned dovecot_repl_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! rspamd_checks; then
log_msg "Rspamd hit error limit"
echo rspamd-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned rspamd_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
(
while true; do
if ! ratelimit_checks; then
@@ -994,20 +503,6 @@ PID=$!
echo "Spawned cert_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
if [[ "${SKIP_OLEFY}" =~ ^([nN][oO]|[nN])+$ ]]; then
(
while true; do
if ! olefy_checks; then
log_msg "Olefy hit error limit"
echo olefy-mailcow > /tmp/com_pipe
fi
done
) &
PID=$!
echo "Spawned olefy_checks with PID ${PID}"
BACKGROUND_TASKS+=(${PID})
fi
(
while true; do
if ! acme_checks; then
@@ -1033,15 +528,19 @@ while true; do
done
) &
# Monitor dockerapi
# Pause background checks while Redis (the control bus) is unreachable, otherwise
# we'd flag every service as unhealthy at once.
(
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
ping_bus() { redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning ping > /dev/null 2>&1; }
while true; do
while nc -z dockerapi 443; do
while ping_bus; do
sleep 3
done
log_msg "Cannot find dockerapi-mailcow, waiting to recover..."
log_msg "Cannot reach redis-mailcow (control bus), waiting to recover..."
kill -STOP ${BACKGROUND_TASKS[*]}
until nc -z dockerapi 443; do
until ping_bus; do
sleep 3
done
kill -CONT ${BACKGROUND_TASKS[*]}
@@ -1101,24 +600,33 @@ while true; do
elif [[ ${com_pipe_answer} =~ .+-mailcow ]]; then
kill -STOP ${BACKGROUND_TASKS[*]}
sleep 10
CONTAINER_ID=$(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"${com_pipe_answer}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
if [[ ! -z ${CONTAINER_ID} ]]; then
if [[ "${com_pipe_answer}" == "php-fpm-mailcow" ]]; then
HAS_INITDB=$(curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/top | jq '.msg.Processes[] | contains(["php -c /usr/local/etc/php -f /web/inc/init_db.inc.php"])' | grep true)
fi
S_RUNNING=$(($(date +%s) - $(curl --silent --insecure https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/json | jq .State.StartedAt | xargs -n1 date +%s -d)))
if [ ${S_RUNNING} -lt 360 ]; then
log_msg "Container is running for less than 360 seconds, skipping action..."
elif [[ ! -z ${HAS_INITDB} ]]; then
log_msg "Database is being initialized by php-fpm-mailcow, not restarting but delaying checks for a minute..."
sleep 60
# "<service>-mailcow|<node>" restarts a single replica; bare "<service>-mailcow"
# broadcasts the restart to every replica of the service.
AGENT_NODE=""
AGENT_SVC="${com_pipe_answer%-mailcow}"
if [[ "${com_pipe_answer}" == *"|"* ]]; then
AGENT_NODE="${com_pipe_answer#*|}"
AGENT_SVC="${com_pipe_answer%|*}"
AGENT_SVC="${AGENT_SVC%-mailcow}"
fi
STARTED_AT_RAW=$(redis-cli -h "${REDIS_SLAVEOF_IP:-redis-mailcow}" -p "${REDIS_SLAVEOF_PORT:-6379}" -a "${REDISPASS}" --no-auth-warning HGET "mailcow.node.${AGENT_SVC}.${AGENT_NODE:-$(hostname)}" started_at 2>/dev/null)
S_RUNNING=999
if [[ -n "${STARTED_AT_RAW}" ]]; then
S_RUNNING=$(( $(date +%s) - $(date -d "${STARTED_AT_RAW}" +%s 2>/dev/null || echo 0) ))
fi
if [ ${S_RUNNING} -lt 360 ]; then
log_msg "Container is running for less than 360 seconds, skipping action..."
else
if [[ -n "${AGENT_NODE}" ]]; then
log_msg "Sending restart to ${AGENT_SVC} node ${AGENT_NODE} via control bus..."
mailcow-agent-cli send "${AGENT_SVC}" restart "{\"target_node\":\"${AGENT_NODE}\"}" >/dev/null || true
else
log_msg "Sending restart command to ${CONTAINER_ID}..."
curl --silent --insecure -XPOST https://dockerapi.${COMPOSE_PROJECT_NAME}_mailcow-network/containers/${CONTAINER_ID}/restart
notify_error "${com_pipe_answer}"
log_msg "Wait for restarted container to settle and continue watching..."
sleep 35
log_msg "Sending restart broadcast to ${AGENT_SVC} via control bus..."
mailcow-agent-cli send "${AGENT_SVC}" restart >/dev/null || true
fi
notify_error "${com_pipe_answer}"
log_msg "Wait for restarted container to settle and continue watching..."
sleep 35
fi
kill -CONT ${BACKGROUND_TASKS[*]}
sleep 1
+14 -7
View File
@@ -80,23 +80,30 @@ if ($isSOGoRequest) {
}
if ($result === false){
// If it's a SOGo Request, don't check for protocol access
$service = ($isSOGoRequest) ? false : array($post['service'] => true);
$result = apppass_login($post['username'], $post['password'], $service, array(
if ($isSOGoRequest) {
$service = 'SOGO';
$post['service'] = 'NONE';
} else {
$service = $post['service'];
}
$result = apppass_login($post['username'], $post['password'], array(
'service' => $post['service'],
'is_internal' => true,
'remote_addr' => $post['real_rip']
));
if ($result) {
error_log('MAILCOWAUTH: App auth for user ' . $post['username']);
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $service);
}
}
if ($result === false){
// Init Identity Provider
$iam_provider = identity_provider('init');
$iam_settings = identity_provider('get');
$result = user_login($post['username'], $post['password'], array('is_internal' => true));
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
if ($result) {
error_log('MAILCOWAUTH: User auth for user ' . $post['username']);
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
}
}
@@ -105,7 +112,7 @@ if ($result) {
http_response_code(200); // OK
$return['success'] = true;
} else {
error_log("MAILCOWAUTH: Login failed for user " . $post['username']);
error_log("MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
http_response_code(401); // Unauthorized
}
+15 -6
View File
@@ -13,6 +13,7 @@ events {
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
@@ -48,13 +49,21 @@ http {
listen {{ HTTP_PORT }} default_server;
listen [::]:{{ HTTP_PORT }} default_server;
server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
if ( $request_uri ~* "%0A|%0D" ) { return 403; }
location ^~ /.well-known/acme-challenge/ {
allow all;
default_type "text/plain";
}
location ^~ /.well-known/mta-sts.txt {
allow all;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass {{ PHPFPMHOST }}:9002;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/mta-sts.php;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
location / {
return 301 https://$host$uri$is_args$args;
}
@@ -70,7 +79,7 @@ http {
{%endif%}
listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
{% if not DISABLE_IPv6 %}
{% if ENABLE_IPV6 %}
{% if not HTTP_REDIRECT %}
listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
{%endif%}
@@ -82,7 +91,7 @@ http {
ssl_certificate /etc/ssl/mail/cert.pem;
ssl_certificate_key /etc/ssl/mail/key.pem;
server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.*;
server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.*;
include /etc/nginx/includes/sites-default.conf;
}
@@ -97,7 +106,7 @@ http {
{%endif%}
listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
{% if not DISABLE_IPv6 %}
{% if ENABLE_IPV6 %}
{% if not HTTP_REDIRECT %}
listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
{%endif%}
@@ -118,7 +127,7 @@ http {
# rspamd dynmaps:
server {
listen 8081;
{% if not DISABLE_IPv6 %}
{% if ENABLE_IPV6 %}
listen [::]:8081;
{%endif%}
index index.php index.html;
@@ -191,7 +200,7 @@ http {
{%endif%}
listen {{ HTTPS_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%} ssl;
{% if not DISABLE_IPv6 %}
{% if ENABLE_IPV6 %}
{% if not HTTP_REDIRECT %}
listen [::]:{{ HTTP_PORT }}{% if NGINX_USE_PROXY_PROTOCOL %} proxy_protocol{%endif%};
{%endif%}
@@ -14,7 +14,6 @@ ssl_session_tickets off;
add_header Strict-Transport-Security "max-age=15768000;";
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header X-Robots-Tag none;
add_header X-Download-Options noopen;
add_header X-Frame-Options "SAMEORIGIN" always;
@@ -76,6 +75,14 @@ location ^~ /.well-known/acme-challenge/ {
allow all;
default_type "text/plain";
}
location ^~ /.well-known/mta-sts.txt {
allow all;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass {{ PHPFPMHOST }}:9002;
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/mta-sts.php;
fastcgi_param PATH_INFO $fastcgi_path_info;
}
rewrite ^/.well-known/caldav$ /SOGo/dav/ permanent;
rewrite ^/.well-known/carddav$ /SOGo/dav/ permanent;
@@ -254,19 +261,19 @@ location ~* /sogo$ {
}
location /SOGo.woa/WebServerResources/ {
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
}
location /.woa/WebServerResources/ {
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
}
location /SOGo/WebServerResources/ {
alias /usr/lib/GNUstep/SOGo/WebServerResources/;
alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
}
location (^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\.(jpg|png|gif|css|js)$) {
alias /usr/lib/GNUstep/SOGo/$1.SOGo/Resources/$2;
alias /usr/local/lib/GNUstep/SOGo/$1.SOGo/Resources/$2;
}
{% endif %}
@@ -1,7 +1,16 @@
; NOTE: Restart phpfpm on ANY manual changes to PHP files!
; opcache
opcache.enable=1
opcache.enable_cli=1
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.memory_consumption=128
opcache.save_comments=1
opcache.revalidate_freq=1
opcache.validate_timestamps=0
; JIT
; Disabled for now due to some PHP segmentation faults observed
; in certain environments. Possibly some PHP or PHP extension bug.
opcache.jit=disable
opcache.jit_buffer_size=0
+1 -1
View File
@@ -152,7 +152,7 @@ smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_sender_dependent.cf
smtp_sasl_security_options =
smtp_sasl_mechanism_filter = plain, login
smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf
smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERY
smtp_header_checks = pcre:/opt/postfix/conf/anonymize_headers.pcre
mail_name = Postcow
# local_transport map catches local destinations and prevents routing local dests when the next map would route "*"

Some files were not shown because too many files have changed in this diff Show More