Compare commits

...
Author SHA1 Message Date
FreddleSpl0it 92ad6c5e6d [Rspamd] Update to 4.2.3 2026-10-28 12:19:53 +01:00
FreddleSpl0it aa7388810c Merge pull request #7568 from mailcow/feat/compose-update
[Web] Update composer libraries
2026-10-28 12:01:04 +01:00
FreddleSpl0it 4dd7aead73 [Web] Store PKCE data of OAuth2 authorization codes 2026-10-28 11:57:46 +01:00
FreddleSpl0it bd8c784118 [Web] Update composer libraries 2026-10-28 11:56:29 +01:00
FreddleSpl0it 9cc8ac2908 Merge pull request #7567 from mailcow/feat/netfilter-rework
[Netfilter] Rework: robust bans, no restart loops, identity provider outages are no failed logins
2026-10-28 11:24:29 +01:00
FreddleSpl0it 002400315d Merge pull request #7566 from mailcow/feat/groups
[Web][Dovecot][SOGo][DockerApi] Add mailbox groups, shared mailboxes and identity provider sync
2026-10-28 11:21:58 +01:00
FreddleSpl0it 8934a84d18 Merge pull request #7547 from mailcow/feat/apikey-rework
[Web] Rework API keys: multiple keys + locked service keys from mailcow.conf
2026-10-28 11:18:47 +01:00
FreddleSpl0it 5ce88b5795 Merge pull request #7546 from mailcow/feat/imapsync-rework
[Web][Dovecot] Rework imapsync: sync sources, OAuth2, Python runner
2026-10-28 11:17:09 +01:00
FreddleSpl0it b2b21b1a85 Merge pull request #7551 from mailcow/renovate/tianon-gosu-1.x
Update dependency tianon/gosu to v1.20
2026-10-28 09:05:19 +01:00
FreddleSpl0it 4b1b9362f3 Merge pull request #7555 from mailcow/renovate/redis-7.x
Update redis Docker tag to v7.4.12
2026-10-28 09:04:55 +01:00
FreddleSpl0it fb32d8d345 Merge pull request #7563 from mailcow/feat/alpine3.24
Feat/alpine3.24
2026-10-28 09:04:34 +01:00
FreddleSpl0it af5d0e3d29 [Dovecot] Update base to Alpine 3.24, build Dovecot 2.3 from Alpine 3.21 packaging 2026-10-28 09:00:45 +01:00
FreddleSpl0it 4884ed0cbf [Olefy] Update base to Alpine 3.24, fix event loop for Python 3.14 2026-10-28 08:51:33 +01:00
FreddleSpl0it 8aaf68ffb5 [Unbound] Update to 1.26.2, base Alpine 3.24 2026-10-28 08:50:42 +01:00
FreddleSpl0it 503e64fe85 [ACME][Dockerapi][Netfilter][Watchdog] Update base to Alpine 3.24 2026-10-28 08:49:57 +01:00
FreddleSpl0it ba034568e0 [Web] Add identity provider outage translation, remove unused needs_restart 2026-10-27 12:55:24 +01:00
FreddleSpl0it b7d24881b1 [Netfilter][Tests] Add netfilter unit tests and identity provider outage tests 2026-10-27 12:54:45 +01:00
FreddleSpl0it 1c7c328a2a [Web][Netfilter] Fix netban limits, no negative ban times, save lists atomically, check banlist id 2026-10-27 12:53:01 +01:00
FreddleSpl0it 7457fd59aa [Web][Dovecot] Do not count logins as failed while the identity provider is unavailable 2026-10-27 12:46:24 +01:00
FreddleSpl0it 60177720ed [Netfilter] Rework: keep bans across restarts, repair chain order instead of restarting, one isolation rule per port 2026-10-27 12:35:07 +01:00
FreddleSpl0it 15042e5921 [Web] Add mailbox group and identity provider translations 2026-10-27 10:21:11 +01:00
FreddleSpl0it 3959e311a0 [Web] Add API Documentation for mailbox groups and identity provider sync 2026-10-27 10:20:24 +01:00
FreddleSpl0it ad86403b87 [Web][Tests] Add mailbox group, identity sync, auth, Dovecot and SOGo module tests 2026-10-27 10:15:00 +01:00
FreddleSpl0it 2c5718c9c9 [Web][Dovecot][SOGo][DockerApi] Add mailbox groups, shared mailboxes and identity provider sync 2026-10-27 10:01:33 +01:00
renovate[bot] 0445f85d7d Update redis Docker tag to v7.4.12
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-10-27 00:27:59 +00:00
renovate[bot] 714ca5a3d8 Update dependency tianon/gosu to v1.20
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-10-25 13:00:37 +00:00
FreddleSpl0it 7d27460fee Merge branch 'staging' into feat/imapsync-rework 2026-10-23 11:49:45 +02:00
FreddleSpl0it 8bc8fbdd2c [Web] Add API Documentation for API keys 2026-10-23 11:23:38 +02:00
FreddleSpl0it f63042bbaf [Web] Bind API sessions to their key 2026-10-23 11:22:32 +02:00
FreddleSpl0it 4828f0cf64 [Web] Restrict read-only API sessions 2026-10-23 11:21:45 +02:00
FreddleSpl0it 40488b2120 [Web] Add API key translations 2026-10-23 11:21:05 +02:00
FreddleSpl0it 02a9f59530 [Web][Tests] Add API key tests 2026-10-23 11:20:46 +02:00
FreddleSpl0it 0dadf49934 [Web] Rework API keys: multiple read-only/read-write keys, service keys from mailcow.conf 2026-10-23 11:20:17 +02:00
FreddleSpl0it c145f858e2 [Web][Dovecot] imapsync: update to 2.314, refresh OAuth tokens of running jobs, add source presets 2026-10-22 18:27:49 +02:00
milkmakerandciri-cuervo 82b1cf7198 [Web] Updated lang.es-es.json (#7542)
Co-authored-by: ciri-cuervo <nicolascirigliano@gmail.com>
2026-10-22 17:54:52 +02:00
FreddleSpl0it 474f1c688b [Web] Add imapsync translations 2026-10-22 10:09:42 +02:00
FreddleSpl0it 7c62a57910 [Web][Dovecot] Rework imapsync: sync sources, OAuth2 (XOAUTH2), worker pool, Python runner 2026-10-22 10:07:52 +02:00
FreddleSpl0it e756b7857e [Web][Tests] Add PHPUnit setup and imapsync module tests 2026-10-22 10:07:52 +02:00
FreddleSpl0it b2a152fe45 [Web] Add Mailcow\ class structure and imapsync module classes 2026-10-22 10:07:43 +02:00
FreddleSpl0it 036607b4bf Merge pull request #7534 from mailcow/feat/php-patch
[PHP] Update base to Alpine 3.24, build PECL extensions from GitHub
2026-10-21 19:29:43 +02:00
FreddleSpl0it 2bad774bcc [PHP] Update to 8.2.35 2026-10-21 16:28:17 +02:00
FreddleSpl0it d833b9696f Merge pull request #7535 from mailcow/feat/nginx-patch
[Nginx] Update to 1.30.6
2026-10-21 14:14:41 +02:00
FreddleSpl0it 7d41484d2a Merge pull request #7536 from mailcow/feat/sogo-patch
[SOGo] Update to 5.12.12
2026-10-21 14:14:13 +02:00
FreddleSpl0it 9e4c7ef038 [SOGo] Update to 5.12.12 2026-10-21 14:08:15 +02:00
FreddleSpl0it 1f04122eca [Nginx] Update to 1.30.6 2026-10-21 13:41:43 +02:00
FreddleSpl0it 015fd04a37 [PHP] Update base to Alpine 3.24, build PECL extensions from GitHub 2026-10-21 11:06:14 +02:00
milkmakerandSchwindelhub ca8546fa4a [Web] Updated lang.de-de.json (#7527)
Co-authored-by: Schwindelhub <github@netbla.de>
2026-10-20 20:34:32 +02:00
milkmakerandMatjaž Tekavec 6f6edfb85e [Web] Updated lang.si-si.json (#7525)
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2026-10-19 18:40:37 +02:00
FreddleSpl0it 6c877fc0ac Merge pull request #7520 from mailcow/feat/update-ui
Feat/update UI
2026-10-15 15:31:18 +02:00
FreddleSpl0it 4a29767a31 [Web] Add role color marker and icons to login pages 2026-10-15 15:23:29 +02:00
FreddleSpl0it cd29426933 [Web] Fix minor customize and theme ui issues 2026-10-15 15:15:42 +02:00
FreddleSpl0it 37be306f1c [Web] change default theme label 2026-10-15 15:10:49 +02:00
FreddleSpl0it a351017455 [Web] Update bootstrap-select css to 1.14.0-beta3 2026-10-15 14:47:19 +02:00
FreddleSpl0it 887b8a3594 [Web] Add servercow theme 2026-10-15 14:46:57 +02:00
FreddleSpl0it 571c1b5b29 [Web] Update Bootstrap to 5.3.8, add theme support 2026-10-15 14:43:06 +02:00
FreddleSpl0it 8cd231c9b9 Merge pull request #7505 from mailcow/feat/rspamd-4.2.1
[Rspamd] Update to 4.2.1
2026-10-06 10:02:49 +02:00
FreddleSpl0it 74facc4b5b [Rspamd] update to 4.2.1 2026-10-06 09:41:51 +02:00
FreddleSpl0it ff6329d01b [Rspamd] Fix CTE and boundary prefix in domain wide footer 2026-10-06 09:41:34 +02:00
FreddleSpl0it e8209d6504 Merge pull request #7504 from ralfbergs/fix/typo-resovler
Fixed typo (changing RESOVLER to RESOLVER).
2026-10-06 09:21:33 +02:00
FreddleSpl0it 626d006a3d Merge pull request #7484 from oidipos/feat/tlspol
[postfix-tlspol] bump to v1.14.0
2026-10-06 09:20:47 +02:00
Ralf Bergs e88c734d0a Fixed typo (changing RESOVLER to RESOLVER). 2026-10-03 12:30:32 +02:00
milkmaker 86bba266f6 update postscreen_access.cidr (#7494) 2026-10-01 08:03:05 +02:00
oidipos e3ed8d2fc2 bump postfix-tlspol to v1.14.0 2026-09-25 21:23:58 +02:00
FreddleSpl0it 88d92261e4 Merge pull request #7432 from mailcow/renovate/redis-7.x
Update redis Docker tag to v7.4.11
2026-09-21 09:32:35 +02:00
FreddleSpl0it 017d0c7ff0 Merge pull request #7475 from mailcow/feat/sogo-5.12.11
[SOGo] Update to 5.12.11
2026-09-21 08:46:32 +02:00
FreddleSpl0it a28fa19044 [SOGo] Update to 5.12.11 2026-09-21 08:43:16 +02:00
FreddleSpl0it fa2ad14fc4 Merge pull request #7473 from mailcow/feat/unbound-1.26.1
[Unbound] Update to 1.26.1
2026-09-21 08:29:13 +02:00
FreddleSpl0it a480a96e10 [Unbound] Update to 1.26.1 2026-09-21 08:23:03 +02:00
renovate[bot] 42d387ca42 Update redis Docker tag to v7.4.11
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-09-15 16:52:39 +00:00
milkmakerandStefano fe4e675754 [Web] Updated lang.it-it.json (#7460)
Co-authored-by: Stefano <stefano.vassena@gmail.com>
2026-09-09 22:28:28 +02:00
b05f59b705 Translations update from Weblate (#7457)
* [Web] Updated lang.si-si.json

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

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

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

---------

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: 雨 <luotianyi@luotianyi.me>
2026-09-07 21:38:12 +02:00
milkmaker c4d0ca56d2 update postscreen_access.cidr (#7450) 2026-09-01 13:07:54 +02:00
7ec61fde8a Translations update from Weblate (#7431)
* [Web] Updated lang.it-it.json

Co-authored-by: Claudio <cb@bastard.it>

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

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

---------

Co-authored-by: Claudio <cb@bastard.it>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2026-08-19 20:23:04 +02:00
FreddleSpl0it d80b15b06c Merge pull request #7376 from smpaz7467/fix/extended-sender-acl-authsource
[Web] show external sender addresses regardless of authsource
2026-08-18 14:41:20 +02:00
FreddleSpl0it ed4fe41d7a Merge pull request #7349 from smpaz7467/fix/login-redirect-deeplink
[Web] redirect deep links to the matching login page
2026-08-18 14:37:32 +02:00
FreddleSpl0it 6b9d875773 Merge pull request #7347 from smpaz7467/fix/dns-zonefile-absolute-rhs
[Web] use absolute RHS names in generated DNS zonefile
2026-08-18 14:30:50 +02:00
FreddleSpl0it 4d5b9d1c80 Merge pull request #7342 from FrauJulian/feat/show-password
[Web] Add show/hide password toggle on login pages
2026-08-18 14:25:22 +02:00
FreddleSpl0it 8abf453e1c Merge pull request #7428 from mailcow/feat/forbid-external-alias
[Web] Add ACL + global switch to disable external alias goto
2026-08-18 14:13:39 +02:00
FreddleSpl0it 8bd9e12a73 [Web] Add ACL + global switch to disable external alias goto 2026-08-18 14:07:37 +02:00
FreddleSpl0it c665d430de Merge pull request #7344 from smpaz7467/fix/forced-pw-change-raw-lang-key
[Web] translate password errors in the forced password change modal
2026-08-18 13:31:35 +02:00
FreddleSpl0it 2195a674ff Merge pull request #7290 from wryfi/7150-fix-passwd-verify
Fix #7150: passwd-verify.lua wipes auth cache during any nginx outage
2026-08-18 13:27:01 +02:00
FreddleSpl0it 089c44aee1 Merge pull request #7334 from DerLinkman/feat/postfix-tlspol-update
postfix-tlspol: upgrade to trixie + 1.11.0 update
2026-08-18 13:20:16 +02:00
FreddleSpl0it 6644699e65 Merge pull request #7426 from mailcow/fix/web-hardening
[Web] Minor hardening across web UI and nginx
2026-08-18 09:10:09 +02:00
FreddleSpl0it 91d3b7246a [Web] Minor hardening across web UI and nginx 2026-08-18 09:08:17 +02:00
FreddleSpl0it b362c7105d Merge pull request #7425 from mailcow/feat/redis-7.4.10
[Redis] Update to 7.4.10
2026-08-18 08:14:21 +02:00
FreddleSpl0it 555e7ed706 [Redis] Update to 7.4.10 2026-08-18 08:12:32 +02:00
FreddleSpl0it 59e2a9ac77 Merge pull request #7423 from mailcow/fix/7418
[Dovecot] Remove legacy DeltaChat auto-filing sieve rule
2026-08-17 08:41:18 +02:00
FreddleSpl0it feed5ad183 [Dovecot] Remove legacy DeltaChat auto-filing sieve rule 2026-08-17 08:35:53 +02:00
FreddleSpl0it f1f7a9800b Merge pull request #7422 from mailcow/feat/sogo-5.12.10
[SOGo] Update to 5.12.10
2026-08-17 08:24:10 +02:00
FreddleSpl0it 8c85044781 [SOGo] Update to 5.12.10 2026-08-17 08:20:45 +02:00
FreddleSpl0it 8fb32e4b59 Merge pull request #7415 from mailcow/feat/clamd-1.4.6
[Clamd] Update to 1.4.6
2026-08-13 13:59:13 +02:00
FreddleSpl0it b5fa926bc0 [Clamd] Update to 1.4.6 2026-08-13 13:56:49 +02:00
d1a2f4e168 Translations update from Weblate (#7400)
* [Web] Updated lang.si-si.json

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

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

Co-authored-by: André Glazastov <andre@glazastov.com>

---------

Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
Co-authored-by: André Glazastov <andre@glazastov.com>
2026-08-04 18:37:38 +02:00
milkmaker f7a536d634 update postscreen_access.cidr (#7394) 2026-08-04 18:35:48 +02:00
Maximal Benedikt 8b456a33e7 Merge pull request #7392 from mailcow/update-sponsoring
update README.md sponsors
2026-07-30 14:21:39 +02:00
MaximalBenedikt 54da526d41 update README.md sponsors 2026-07-30 14:11:32 +02:00
FreddleSpl0it 50be483c39 Merge pull request #7390 from mailcow/fix/6859
[ACME] Skip mta-sts certificate request when MTA-STS is not active for a domain
2026-07-30 10:44:24 +02:00
FreddleSpl0it d64c923aca [ACME] Skip mta-sts certificate request when MTA-STS is not active for a domain 2026-07-30 10:33:55 +02:00
FreddleSpl0it 2d5f166ba8 Merge pull request #7389 from mailcow/fix/default-mbox-template
[Web] Create default mailbox template with eas and dav access
2026-07-30 09:23:24 +02:00
FreddleSpl0it 94b5a623ab Merge pull request #7388 from mailcow/fix/7329
[Web] Move mailcow update check to server side
2026-07-30 09:13:19 +02:00
FreddleSpl0it e406ecd461 Merge pull request #7387 from mailcow/fix/mfk25
Hardening mailcow
2026-07-30 09:07:10 +02:00
FreddleSpl0it 68a95c3af3 Merge pull request #7386 from mailcow/feat/rspamd-4.1.4
[Rspamd] update to 4.1.4
2026-07-30 08:59:04 +02:00
FreddleSpl0it bd037d5644 [Nginx] Set image tag to 1.30.4-1 2026-07-30 08:51:21 +02:00
FreddleSpl0it 7036dbf4f8 [Rspamd] update to 4.1.4 2026-07-30 08:50:18 +02:00
FreddleSpl0it 2f10a4633f Merge pull request #7385 from mailcow/fix/cors
[Web] harden CORS origin matching and add Vary: Origin
2026-07-30 08:41:01 +02:00
FreddleSpl0it 36c70db86c [Web] harden CORS origin matching and add Vary: Origin 2026-07-30 08:37:18 +02:00
Stephen Ritz 9cd16f0001 [Web] show external sender addresses regardless of authsource
The extended_sender_acl field sat inside the
`{% if not result.authsource or result.authsource == 'mailcow' %}` block
that hides the local password fields for mailboxes authenticating against
an external identity provider. As a result the "External sender addresses"
input was not rendered at all for keycloak / generic-oidc / ldap mailboxes,
even for a full admin, so those addresses could neither be reviewed nor
edited in the UI while the underlying sender_acl rows stayed active.

Extended sender ACLs are unrelated to local password management. Move the
field out of that block; it stays gated by acl.extend_sender_acl as intended.

Fixes #7365
2026-07-28 07:12:17 -07:00
FreddleSpl0it f4961c4023 Merge pull request #7333 from fallmo/fix/cors-settings-validation
fix: cors allowed origins settings validation
2026-07-28 15:35:29 +02:00
FreddleSpl0it 38de21592c Merge pull request #7358 from SYNLINQ/staging
Fix nginx CVE-2026-42533
2026-07-28 14:40:30 +02:00
FreddleSpl0it f44bd2f36a [Web] document sender_acl in get/mailbox API examples 2026-07-28 14:19:45 +02:00
FreddleSpl0it 95a77f2dcb Merge pull request #7348 from smpaz7467/fix/api-get-mailbox-sender-acl
[Web] return sender_acl in get/mailbox API
2026-07-28 14:12:57 +02:00
FreddleSpl0it e856510fb2 Merge pull request #7345 from smpaz7467/fix/time-limited-alias-api
[Web] fix add/time_limited_alias silently discarding requests and validity
2026-07-28 13:49:37 +02:00
FreddleSpl0it d51d06d716 Merge pull request #7343 from smpaz7467/fix/nginx-ipv6-default-server
[Nginx] only bind IPv6 default_server when ENABLE_IPV6 is set
2026-07-28 13:35:17 +02:00
FreddleSpl0it 54170d075a [Web] Move mailcow update check to server side 2026-07-28 13:32:18 +02:00
renovate[bot] b4bb1a625b Update actions/stale action to v11 (#7375) 2026-07-28 11:07:20 +02:00
FreddleSpl0it 2e5a29bf69 Merge pull request #7367 from oidipos/fix/quarantine-subject
fix: restore subject display in quarantine overview
2026-07-28 10:54:23 +02:00
FreddleSpl0it e245ac04d9 [Web] enforce tenant boundary for SOGo SSO 2026-07-28 09:21:29 +02:00
oidipos c877fdf0a5 fix: remove MIME decoding of JSON encoded subject
Since moving to rspamd's multipart metadata_exporter,
`subject` is a JSON encoded UTF-8 string and must not be MIME decoded.
2026-07-24 21:03:00 +02:00
FreddleSpl0it fea38c8e1b [Web] escape mailbox name 2026-07-24 11:16:39 +02:00
FreddleSpl0it cc9af65852 [Web] remove domain admin sso token after use 2026-07-24 10:26:23 +02:00
FreddleSpl0it 92cc8bec90 [Web] Use parameterized LIKE for sogo_acl deletion 2026-07-23 15:29:41 +02:00
FreddleSpl0it 145745329e [Web] add dot stuffing for quarantine raw release 2026-07-23 15:01:44 +02:00
FreddleSpl0it 8e72d22c56 [Web] Escape rspamd_history and rllog 2026-07-23 11:17:55 +02:00
FreddleSpl0it ddd76d99cd [Web] Add sogo_auth_internal nginx marker for sogo-auth.php 2026-07-23 10:10:29 +02:00
SYNLINQ 8c9524a2fe Update docker-compose.yml
reference patched nginx image
2026-07-20 21:32:57 +02:00
SYNLINQ 2ebd32d2ee Update Dockerfile
bump nginx version to 1.30.4
2026-07-20 21:31:57 +02:00
FreddleSpl0it 4b62af9d02 update README.md sponsors 2026-07-15 08:29:46 +02:00
Stephen RitzandClaude Opus 4.8 90ca1bf25a [Web] redirect deep links to the matching login page
An unauthenticated request to a deep link such as /admin/dashboard was
redirected to /, the user login, instead of the admin login. protect_route
always sent unauthenticated visitors to /.

Pick the login page from the request path: /admin/* redirects to /admin,
/domainadmin/* to /domainadmin, everything else to / as before.

Fixes #7284

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:39:19 -07:00
Stephen RitzandClaude Opus 4.8 14772c3a20 [Web] return sender_acl in get/mailbox API
sender_acl can be set through edit/mailbox but was never returned by
get/mailbox, so an API client could not read back what it had written,
and get/mailbox/all / get/mailbox/{mailbox} both omitted it.

Add the mailbox's internal send-as ACL (the sender_acl table rows with
external = 0) to mailbox_details as sender_acl, an array of send_as
values, mirroring the field edit/mailbox accepts. It is added in the same
block as the other detailed fields, so the lightweight get/mailbox/reduced
endpoint is unaffected.

Fixes #7011

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:36:40 -07:00
Stephen RitzandClaude Opus 4.8 e5d0ed8c4c [Web] use absolute RHS names in generated DNS zonefile
The DNS overview "Download" produces a $ORIGIN zonefile, but the
right-hand side of MX, CNAME and SRV records was emitted as a relative
name. A target such as mail.example.net is then read relative to the
origin and expands to mail.example.net.example.org., which is wrong.

The only prior attempt at making names absolute was
str_replace($domain, $domain . '.', ...), which appended a dot only to
targets that happened to contain the origin domain, so cross-domain
targets stayed relative. That same replace also corrupted any TXT value
containing the origin (e.g. a DMARC rua=mailto:x@example.org became
...@example.org.).

Absolutize the RHS per record type at export time only: MX and CNAME
targets, and the SRV target token, get a trailing dot; ports, the SRV
root target ".", IP addresses and TXT character strings are left as is.
The records used for the on-page DNS validation are untouched, so
matching against dns_get_record() output still works.

Fixes #6984

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:33:46 -07:00
Stephen RitzandClaude Opus 4.8 c17cc8a792 [Web] fix add/time_limited_alias silently discarding requests and validity
Three defects in add/time_limited_alias:

The description was read as $_data['description'] without a guard. When a
client omits it, null is bound to spamalias.description, which is TEXT NOT
NULL, so the insert raises a PDOException. The global exception handler is
terminal, so process_add_return() never echoes anything and the caller sees
HTTP 200 with an empty body while no alias was created. Default it to an
empty string instead.

The validity guard used a single condition whose else branch also caught the
success case, so every valid validity was overwritten with the 8760 hour
default and the parameter did nothing. Only invalid values were rejected.
Nest the range check so a valid value survives.

The OpenAPI spec documented only username and domain, while the code also
reads description, validity and permanent. Spec driven clients therefore
could not construct a working request. Document all three.

spamalias.description is the only NOT NULL description column in the schema,
which is why the same unguarded read in add/domain and add/resource does not
fail, both of those columns are nullable.

This does not change the generic exception handling. A database error is
still swallowed into an empty HTTP 200, and the message the handler builds
carries the raw PDOException, so surfacing it to API clients would need
sanitising first. That is left for a separate change.

Refs #7287

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:23:31 -07:00
Stephen RitzandClaude Opus 4.8 4cba448671 [Web] translate password errors in the forced password change modal
The forced password change modal posts to the JSON API and renders
data[0].msg directly. The API returns raw language keys rather than
translated strings, so a user who fails the complexity policy is shown
the literal text "password_complexity" instead of a message.

Every other password form renders errors through alertbox_log_parser(),
which resolves the key against $lang. The modal is the only one that
talks to the API directly, and it never resolved the key.

Resolve it against lang_danger, which base.twig already exposes for this
purpose. This also covers password_mismatch, password_empty and
access_denied, and applies to the admin path as well. Unknown keys still
fall through unchanged.

The lookup is guarded with hasOwnProperty because msg is attacker-
independent but dynamic: a bare lang_danger[msg] would resolve inherited
Object.prototype members such as "constructor" to a function, which
jQuery's .text() would then invoke as a callback.

Language files are untouched: prerequisites.inc.php loads lang.en-gb.json
as the base and merges the active locale over it, so locales that lack the
key inherit the English string.

Fixes #7301

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 15:11:17 -07:00
Stephen RitzandClaude Opus 4.8 7dab6c63d1 [Nginx] only bind IPv6 default_server when ENABLE_IPV6 is set
The HTTP-to-HTTPS redirect server block bound `listen [::]:{{ HTTP_PORT }}
default_server` unconditionally, while every other IPv6 listen directive in
this template is guarded by `{% if ENABLE_IPV6 %}`. On hosts where IPv6 is
disabled at the kernel level, nginx cannot bind `::` and fails to start.

This only triggers when HTTP_REDIRECT is enabled and ENABLE_IPV6 is false,
which is why it survives testing with ENABLE_IPV6=false alone.

Guard the directive like the other six IPv6 listeners in the template.

Refs #7296

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:54:28 -07:00
Paul Seebach 5408bc809d [Web] Use input-group-text for password toggle button
Replaces btn-outline-secondary with input-group-text so the toggle
button border matches the surrounding input group consistently.
2026-07-14 21:20:58 +02:00
Paul Seebach bd5c18b145 [Web] Add show/hide password toggle on login pages
Adds an eye icon button to the password field on the user, admin, and
domain admin login pages. Clicking it toggles between hidden and visible
password text, using Bootstrap Icons (bi-eye / bi-eye-slash).

Closes #6893
2026-07-14 21:20:58 +02:00
DerLinkman 0f06c8e563 postfix: prepare TLSRPT compatibility for tlspol 2026-07-13 19:34:58 +02:00
DerLinkman fffe1aec3d postfix-tlspol: upgrade to trixie + 1.11.0 update 2026-07-13 19:16:10 +02:00
Mohamed Fall 6f7fee49cd fix: cors allowed origins settings validation 2026-07-13 15:29:48 +00:00
FreddleSpl0it c1d75cf808 Merge pull request #7312 from mailcow/renovate/composer-composer-2.x
Update dependency composer/composer to v2.10.2
2026-07-13 09:40:58 +02:00
FreddleSpl0it 473fe2885d Merge pull request #7326 from ralfbergs/fix/quarantine-text-refining
Refined wording for displaying of active settings on quarantine page.
2026-07-13 09:40:24 +02:00
FreddleSpl0it 31e1550668 [Postfix] change postscreen blacklist_action to denylist_action 2026-07-13 08:45:56 +02:00
FreddleSpl0it 315f55bc65 Merge pull request #7323 from DerLinkman/feat/postfix-deb13
postfix: migrate from bookworm to trixie
2026-07-13 08:23:57 +02:00
Ralf Bergs e696ee2f6c Refined wording for displaying of active settings on quarantine page. 2026-07-12 17:22:15 +02:00
renovate[bot] d65aa11870 Update devops-infra/action-pull-request action to v1.4.0 (#7321)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 15:20:18 +02:00
renovate[bot] f6630e5b43 Update actions/stale action to v10.4.0 (#7322)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-12 15:19:49 +02:00
DerLinkman 98e26c5603 compose: upgrade Postfix 2026-07-10 18:41:58 +02:00
DerLinkman 3098caebbc migrate postfix to trixie 2026-07-10 18:29:45 +02:00
renovate[bot] 94e23a3cd3 Update dependency composer/composer to v2.10.2
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-07-01 09:32:23 +00:00
milkmaker 1840074991 update postscreen_access.cidr (#7311) 2026-07-01 11:31:58 +02:00
FreddleSpl0it 99768e16f5 Merge pull request #7305 from mailcow/feat/nginx-1.30.3
[Nignx] Update to 1.30.3
2026-06-25 12:20:25 +02:00
FreddleSpl0it 1f8b4679cc [Nignx] Update to 1.30.3 2026-06-25 12:17:37 +02:00
9e723e942d Translations update from Weblate (#7302)
* [Web] Updated lang.fr-fr.json

Co-authored-by: Romain Ayme <ayme.romain@hotmail.fr>

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

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

---------

Co-authored-by: Romain Ayme <ayme.romain@hotmail.fr>
Co-authored-by: Matjaž Tekavec <matjaz@moj-svet.si>
2026-06-20 13:57:15 +02:00
renovate[bot] 47e4552b44 Update actions/checkout action to v7 (#7300) 2026-06-19 08:34:02 +02:00
Chris Haumesser ddcce811c1 Fixes #7150 (Dovecot passwd-verify.lua wipes auth cache during any nginx restart or unavailability)
- distinguishes between text and numeric responses from nginx
  - prevents auth cache from being invalidated due to nginx unavailability
2026-06-12 16:32:22 -06:00
renovate[bot] 7e6c36dce1 Update alpine Docker tag to v3.24 (#7280) 2026-06-11 11:35:03 +02:00
FreddleSpl0it fa154c71f3 [PHP] Rebuild Image 2026-06-11 10:29:01 +02:00
FreddleSpl0it 83a045ed3e Merge pull request #7193 from mailcow/renovate/composer-composer-2.x
Update dependency composer/composer to v2.10.1
2026-06-11 10:07:19 +02:00
FreddleSpl0it 064817ac70 Merge pull request #7189 from mailcow/renovate/php-pecl-mail-mailparse-3.x
Update dependency php/pecl-mail-mailparse to v3.2.0
2026-06-11 10:06:49 +02:00
FreddleSpl0it 2bd7a24b8c Merge pull request #7212 from mailcow/mkuron-patch-mobileconfig
Escape generated password in mobileconfig
2026-06-11 10:05:17 +02:00
FreddleSpl0it ecc2462e4c Merge pull request #7267 from goodygh/7249-update-sogo
[SOGo] Update to 5.12.9
2026-06-11 10:04:30 +02:00
FreddleSpl0it 2d8db72d46 Merge pull request #7275 from Snafu/fix/admin-mailbox-tfa-missing
Fix force_tfa not available in mailbox template #7216
2026-06-11 10:03:39 +02:00
FreddleSpl0it 277a307fb9 [Web] Fix refresh SOGo view on mailbox deletion 2026-06-11 09:55:32 +02:00
FreddleSpl0it d455555621 Merge pull request #7277 from ibobgunardi/bobi/mailcow-7136-sogo-active-refresh
Refresh SOGo view after mailbox activation
2026-06-11 09:53:30 +02:00
FreddleSpl0it 9ea2ff1dff Merge pull request #7283 from mailcow/feat/update-metadata-exporter
[Rspamd] Migrate metadata_exporter to multipart formatter
2026-06-11 09:40:34 +02:00
FreddleSpl0it 24cc369d84 [Rspamd] Migrate metadata_exporter to multipart formatter 2026-06-11 09:34:27 +02:00
FreddleSpl0it 5f5367f2f9 Merge pull request #7172 from mailcow/dragoangel-patch-4
Update RSPAMD version to 4.1.0 in Dockerfile
2026-06-11 09:04:19 +02:00
milkmaker 03c31f825a update postscreen_access.cidr (#7269) 2026-06-09 12:20:53 +02:00
renovate[bot] c0050e8836 Update devops-infra/action-pull-request action to v1.3.0 (#7272) 2026-06-09 12:19:09 +02:00
Dmytro Alieksieiev 15891960f7 Update RSPAMD version to 4.1.0 2026-06-09 01:19:06 +02:00
Bobby cce02e2b15 Refresh SOGo view after mailbox activation 2026-06-08 00:01:43 +07:00
Snafu 0fafda696b Fix force_tfa not available in mailbox template #7216 2026-06-07 17:03:52 +02:00
renovate[bot] 843db5854c Update dependency composer/composer to v2.10.1
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-06-04 11:04:42 +00:00
goodygh 23e4e4f373 [SOGo] Update to 5.12.9 2026-05-29 01:39:38 +02:00
goodygh 175878f8f1 ui, fail2ban fix german ban_list_info translation (#7265) 2026-05-28 22:20:26 +02:00
milkmakerandPhu D. Nguyen 3fcda21c4e [Web] Updated lang.vi-vn.json (#7263)
Co-authored-by: Phu D. Nguyen <sillycat@duck.com>
2026-05-27 17:46:42 +02:00
milkmakerandEdgars Andersons eac1bf02fc [Web] Updated lang.lv-lv.json (#7262)
Co-authored-by: Edgars Andersons <Edgars+Mailcow+Weblate@gaitenis.id.lv>
2026-05-26 22:02:05 +02:00
renovate[bot] a7f2b2145f Update devops-infra/action-pull-request action to v1.2.1 (#7258)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-26 22:00:43 +02:00
FreddleSpl0it faac56a611 [Unbound] Change version tag 2026-05-26 10:43:25 +02:00
FreddleSpl0it be37bc5a68 Merge pull request #7252 from SYNLINQ/staging
fix unbound CVE-2026-33278
2026-05-26 10:41:52 +02:00
FreddleSpl0it b227c76156 Merge pull request #7259 from mailcow/feat/nginx-1.30.2
[Nginx] Update to 1.30.2
2026-05-26 10:37:57 +02:00
FreddleSpl0it db4e3b4d54 [Nginx] Update to 1.30.2 2026-05-26 10:36:39 +02:00
Michael Kuron ffbc37a00c Escape generated password in mobileconfig
Escape ampersand, less than, greater than to avoid generating invalid XML.

Fixes #7171
2026-05-24 11:52:12 +02:00
renovate[bot] e6fa0c5099 Update devops-infra/action-pull-request action to v1.2.0 (#7254)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 22:10:51 +02:00
renovate[bot] ce3ceaf5b8 Update devops-infra/action-pull-request action to v1.1.3 (#7253)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-23 08:36:46 +02:00
SYNLINQ efc2e76159 Update docker-compose.yml
bump unbound version
2026-05-22 16:27:43 +02:00
SYNLINQ 7d17715e2e Update Dockerfile
install unbound package from alpine:edge
2026-05-22 16:26:47 +02:00
renovate[bot] aed440bcb3 Update devops-infra/action-pull-request action to v1.1.2 (#7247)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 21:09:43 +02:00
milkmakerandAdrian-Kozien 4f2348631a [Web] Updated lang.pl-pl.json (#7245)
Co-authored-by: Adrian-Kozien <adrian@kozien.net>
2026-05-21 17:16:34 +02:00
renovate[bot] da586682f6 Update actions/stale action to v10.3.0 (#7242)
Signed-off-by: milkmaker <milkmaker@mailcow.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-21 10:04:58 +02:00
FreddleSpl0it 22ae1f5363 Merge pull request #7241 from mailcow/fix/quarantinetable
escape HTML in quarantine table
2026-05-20 18:02:19 +02:00
FreddleSpl0it c9cf8d7f38 Merge pull request #7240 from mailcow/feat/nginx1.30.1
[Nginx] Update to 1.30.1
2026-05-20 17:58:29 +02:00
FreddleSpl0it cbd3d8b9bc escape HTML in quarantine table 2026-05-20 17:53:47 +02:00
FreddleSpl0it f553f38635 [Nginx] Update to 1.30.1 2026-05-20 17:24:41 +02:00
renovate[bot] 07373907e3 Update devops-infra/action-pull-request action to v1.1.1 (#7234) 2026-05-18 08:38:18 +02:00
renovate[bot] 104af58628 Update dependency php/pecl-mail-mailparse to v3.2.0
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-05-14 18:33:03 +00:00
Jahongir Qurbonov 4ddcee28e4 Add Uzbek language (#7224) 2026-05-13 16:42:38 +02:00
milkmakerandEdson Wolf 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
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
FreddleSpl0it 8ead057ce9 [Web] Create default mailbox template with eas and dav access 2026-04-15 09:33:23 +02:00
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
Dmitriy Alekseev b0873edb6a Update RSPAMD version in Dockerfile 2026-04-06 17:28:32 +02:00
Dmitriy Alekseev 694d751915 Update RSPAMD version in Dockerfile 2026-04-06 17:28:14 +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
Dmitriy Alekseev 08278a8be9 Update RSPAMD version to 4.0.0 in Dockerfile 2026-03-30 23:51:05 +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
milkmakerandTom18314 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
milkmakerandMatjaž Tekavec 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
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
milkmakerandEdgars Andersons 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
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
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
milkmakerandSándor 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
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
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
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
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
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
Carlos 890295bbfc Add DNS-01 challenge support with configuration files and scripts 2025-11-14 07:10:17 +00:00
Carlos a52e977b89 Add DNS-01 challenge support for ACME certificates and related configurations 2025-11-13 07:19:38 +00:00
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
1944 changed files with 130608 additions and 55693 deletions

No files matched your search

@@ -14,7 +14,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Mark/Close Stale Issues and Pull Requests 🗑️ - name: Mark/Close Stale Issues and Pull Requests 🗑️
uses: actions/stale@v10.1.1 uses: actions/stale@v11.0.0
with: with:
repo-token: ${{ secrets.STALE_ACTION_PAT }} repo-token: ${{ secrets.STALE_ACTION_PAT }}
days-before-stale: 60 days-before-stale: 60
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- "watchdog-mailcow" - "watchdog-mailcow"
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Setup Docker - name: Setup Docker
run: | run: |
curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh
+2 -2
View File
@@ -8,11 +8,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Run the Action - name: Run the Action
uses: devops-infra/action-pull-request@v1.0.2 uses: devops-infra/action-pull-request@v1.4.0
with: with:
github_token: ${{ secrets.PRTONIGHTLY_ACTION_PAT }} github_token: ${{ secrets.PRTONIGHTLY_ACTION_PAT }}
title: Automatic PR to nightly from ${{ github.event.repository.updated_at}} title: Automatic PR to nightly from ${{ github.event.repository.updated_at}}
+5 -5
View File
@@ -13,24 +13,24 @@ jobs:
packages: write packages: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
- name: Login to GHCR - name: Login to GHCR
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v7
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Generate postscreen_access.cidr - name: Generate postscreen_access.cidr
run: | run: |
+6 -1
View File
@@ -51,6 +51,7 @@ data/conf/sogo/cron.creds
data/conf/sogo/custom-fulllogo.svg data/conf/sogo/custom-fulllogo.svg
data/conf/sogo/custom-shortlogo.svg data/conf/sogo/custom-shortlogo.svg
data/conf/sogo/custom-fulllogo.png data/conf/sogo/custom-fulllogo.png
data/conf/acme/dns-01.conf
data/gitea/ data/gitea/
data/gogs/ data/gogs/
data/hooks/dovecot/* data/hooks/dovecot/*
@@ -63,6 +64,8 @@ data/web/templates/cache/*
!data/web/templates/cache/.gitkeep !data/web/templates/cache/.gitkeep
data/web/.well-known/acme-challenge data/web/.well-known/acme-challenge
data/web/css/build/0081-custom-mailcow.css data/web/css/build/0081-custom-mailcow.css
data/web/css/themes/*/
!data/web/css/themes/servercow/
data/web/inc/vars.local.inc.php data/web/inc/vars.local.inc.php
data/web/inc/app_info.inc.php data/web/inc/app_info.inc.php
data/web/nextcloud*/ data/web/nextcloud*/
@@ -75,4 +78,6 @@ refresh_images.sh
update_diffs/ update_diffs/
create_cold_standby.sh create_cold_standby.sh
!data/conf/nginx/mailcow_auth.conf !data/conf/nginx/mailcow_auth.conf
data/conf/postfix/postfix-tlspol data/conf/postfix/postfix-tlspol
tests/php/vendor/
tests/php/.phpunit.cache/
+5
View File
@@ -23,10 +23,15 @@ A big thank you to everyone supporting us on GitHub Sponsors—your contribution
<a href="https://www.maehdros.com/" target=_blank><img <a href="https://www.maehdros.com/" target=_blank><img
src="https://avatars.githubusercontent.com/u/173894712" height="58" src="https://avatars.githubusercontent.com/u/173894712" height="58"
/></a> /></a>
<a href="https://garske-systems.de/" target=_blank><img
src="https://garske-systems.de/img/logo_min.png" height="58"
/></a>
### 50$/Month Sponsors ### 50$/Month Sponsors
<a href="https://github.com/vnukhr" target=_blank><img <a href="https://github.com/vnukhr" target=_blank><img
src="https://avatars.githubusercontent.com/u/7805987?s=52&v=4" height="58" src="https://avatars.githubusercontent.com/u/7805987?s=52&v=4" height="58"
/></a><a href="https://github.com/humiico" target=_blank><img
src="https://avatars.githubusercontent.com/u/110134705?s=52&v=4" height="58"
/></a> /></a>
## Info, documentation and support ## Info, documentation and support
+22 -5
View File
@@ -57,11 +57,14 @@ adapt_new_options() {
"DISABLE_NETFILTER_ISOLATION_RULE" "DISABLE_NETFILTER_ISOLATION_RULE"
"HTTP_REDIRECT" "HTTP_REDIRECT"
"ENABLE_IPV6" "ENABLE_IPV6"
"ACME_DNS_CHALLENGE"
"ACME_DNS_PROVIDER"
"ACME_ACCOUNT_EMAIL"
) )
sed -i --follow-symlinks '$a\' mailcow.conf sed -i --follow-symlinks '$a\' mailcow.conf
for option in ${CONFIG_ARRAY[@]}; do for option in ${CONFIG_ARRAY[@]}; do
if grep -q "${option}" mailcow.conf; then if grep -q "^#\?${option}=" mailcow.conf; then
continue continue
fi fi
@@ -108,16 +111,16 @@ adapt_new_options() {
echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf
;; ;;
API_KEY) API_KEY)
echo '# Create or override API key for web UI' >> mailcow.conf echo '# Read-write service API key, shown read-only in the web UI' >> mailcow.conf
echo "#API_KEY=" >> mailcow.conf echo "#API_KEY=" >> mailcow.conf
;; ;;
API_KEY_READ_ONLY) API_KEY_READ_ONLY)
echo '# Create or override read-only API key for web UI' >> mailcow.conf echo '# Read-only service API key, shown read-only in the web UI' >> mailcow.conf
echo "#API_KEY_READ_ONLY=" >> mailcow.conf echo "#API_KEY_READ_ONLY=" >> mailcow.conf
;; ;;
API_ALLOW_FROM) API_ALLOW_FROM)
echo '# Must be set for API_KEY to be active' >> mailcow.conf echo '# Must be set for API_KEY and API_KEY_READ_ONLY to be active' >> mailcow.conf
echo '# IPs only, no networks (networks can be set via UI)' >> mailcow.conf echo '# Comma separated list of IPs/networks' >> mailcow.conf
echo "#API_ALLOW_FROM=" >> mailcow.conf echo "#API_ALLOW_FROM=" >> mailcow.conf
;; ;;
SNAT_TO_SOURCE) SNAT_TO_SOURCE)
@@ -292,6 +295,20 @@ adapt_new_options() {
echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
;; ;;
ACME_DNS_CHALLENGE)
echo '# Enable DNS-01 challenge for ACME (acme-mailcow) - y/n' >> mailcow.conf
echo '# This requires you to set ACME_DNS_PROVIDER and ACME_ACCOUNT_EMAIL below' >> mailcow.conf
echo 'ACME_DNS_CHALLENGE=n' >> mailcow.conf
;;
ACME_DNS_PROVIDER)
echo '# DNS provider for DNS-01 challenge (e.g. dns_cf, dns_azure, dns_gd, etc.)' >> mailcow.conf
echo '# See the dns-01 provider documentation for more information.' >> mailcow.conf
echo 'ACME_DNS_PROVIDER=dns_xxx' >> mailcow.conf
;;
ACME_ACCOUNT_EMAIL)
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
;;
*) *)
echo "${option}=" >> mailcow.conf echo "${option}=" >> mailcow.conf
;; ;;
+13 -2
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21 FROM alpine:3.24
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -14,11 +14,22 @@ RUN apk upgrade --no-cache \
tini \ tini \
tzdata \ tzdata \
python3 \ python3 \
acme-tiny acme-tiny \
git \
socat \
&& git clone --depth 1 https://github.com/acmesh-official/acme.sh.git /opt/acme.sh \
&& chmod +x /opt/acme.sh/acme.sh \
&& mkdir -p /var/lib/acme/acme-sh
ENV ACME_SH_BIN=/opt/acme.sh/acme.sh \
ACME_SH_HOME=/opt/acme.sh \
ACME_SH_CONFIG_HOME=/var/lib/acme/acme-sh
COPY acme.sh /srv/acme.sh COPY acme.sh /srv/acme.sh
COPY functions.sh /srv/functions.sh COPY functions.sh /srv/functions.sh
COPY obtain-certificate.sh /srv/obtain-certificate.sh COPY obtain-certificate.sh /srv/obtain-certificate.sh
COPY obtain-certificate-dns.sh /srv/obtain-certificate-dns.sh
COPY load-dns-config.sh /srv/load-dns-config.sh
COPY reload-configurations.sh /srv/reload-configurations.sh COPY reload-configurations.sh /srv/reload-configurations.sh
COPY expand6.sh /srv/expand6.sh COPY expand6.sh /srv/expand6.sh
+92 -6
View File
@@ -14,6 +14,17 @@ until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
sleep 2 sleep 2
done done
# Create DNS-01 configuration template if it doesn't exist
if [[ ! -f /etc/acme/dns-01.conf ]]; then
mkdir -p /etc/acme
cat > /etc/acme/dns-01.conf <<'EOF'
# Add here your DNS-01 challenge configuration
# For more information, visit the acme.sh documentation:
# https://github.com/acmesh-official/acme.sh/wiki/dnsapi
EOF
echo "Created DNS-01 configuration template at /etc/acme/dns-01.conf"
fi
source /srv/functions.sh source /srv/functions.sh
# Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6 # Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6
source /srv/expand6.sh source /srv/expand6.sh
@@ -42,6 +53,10 @@ if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
ENABLE_SSL_SNI=y ENABLE_SSL_SNI=y
fi fi
if [[ "${ACME_DNS_CHALLENGE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
ACME_DNS_CHALLENGE=y
fi
if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..." log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..."
sleep 365d sleep 365d
@@ -234,18 +249,71 @@ while true; do
done <<< "${SQL_DOMAINS}" done <<< "${SQL_DOMAINS}"
if [[ ${ONLY_MAILCOW_HOSTNAME} != "y" ]]; then if [[ ${ONLY_MAILCOW_HOSTNAME} != "y" ]]; then
# Fetch all domains with an active MTA-STS policy once.
unset MTA_STS_ACTIVE_DOMAINS
declare -A MTA_STS_ACTIVE_DOMAINS
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
SQL_MTA_STS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM mta_sts WHERE active = 1" -Bs)
if [[ $? -eq 0 ]]; then
while read mta_sts_domain; do
if [[ -z "${mta_sts_domain}" ]]; then
# ignore empty lines
continue
fi
MTA_STS_ACTIVE_DOMAINS["${mta_sts_domain}"]=1
done <<< "${SQL_MTA_STS_DOMAINS}"
fi
fi
for SQL_DOMAIN in "${SQL_DOMAIN_ARR[@]}"; do for SQL_DOMAIN in "${SQL_DOMAIN_ARR[@]}"; do
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}") # Skip mta-sts subdomain unless MTA-STS is enabled (active) for this domain
fi if [[ "${SUBDOMAIN}" == "mta-sts" && -z "${MTA_STS_ACTIVE_DOMAINS[${SQL_DOMAIN}]}" ]]; then
log_f "MTA-STS is not enabled for ${SQL_DOMAIN} - skipping mta-sts subdomain certificate"
continue
fi
# 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 fi
done done
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}") VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
done 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 fi
if check_domain ${MAILCOW_HOSTNAME}; then if check_domain ${MAILCOW_HOSTNAME}; then
@@ -274,13 +342,31 @@ while true; do
done done
fi fi
# Check if MAILCOW_HOSTNAME is covered by a wildcard in ADDITIONAL_SAN
MAILCOW_HOSTNAME_COVERED=0
if [[ ! -z ${VALIDATED_MAILCOW_HOSTNAME} ]]; then
if is_covered_by_wildcard "${VALIDATED_MAILCOW_HOSTNAME}"; then
MAILCOW_PARENT_DOMAIN=$(echo ${VALIDATED_MAILCOW_HOSTNAME} | cut -d. -f2-)
log_f "MAILCOW_HOSTNAME '${VALIDATED_MAILCOW_HOSTNAME}' is covered by wildcard '*.${MAILCOW_PARENT_DOMAIN}' - skipping explicit hostname"
MAILCOW_HOSTNAME_COVERED=1
fi
fi
# Unique domains for server certificate # Unique domains for server certificate
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
# create certificate for server name and fqdn SANs only # create certificate for server name and fqdn SANs only
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs)) if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
SERVER_SAN_VALIDATED=($(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
else
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
fi
else else
# create certificate for all domains, including all subdomains from other domains [*] # create certificate for all domains, including all subdomains from other domains [*]
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs)) if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
SERVER_SAN_VALIDATED=($(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
else
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
fi
fi fi
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
CERT_NAME=${SERVER_SAN_VALIDATED[0]} CERT_NAME=${SERVER_SAN_VALIDATED[0]}
+34
View File
@@ -80,6 +80,11 @@ check_domain(){
return 1 return 1
fi fi
fi fi
if [[ ${ACME_DNS_CHALLENGE} == "y" ]]; then
log_f "ACME_DNS_CHALLENGE=y - skipping IP and HTTP validation for ${DOMAIN}"
return 0
fi
# Check if CNAME without v6 enabled target # Check if CNAME without v6 enabled target
if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then
AAAA_DOMAIN= AAAA_DOMAIN=
@@ -130,3 +135,32 @@ verify_challenge_path(){
return 1 return 1
fi fi
} }
# Check if a domain is covered by a wildcard (*.example.com) in ADDITIONAL_SAN
# Usage: is_covered_by_wildcard "subdomain.example.com"
# Returns: 0 if covered, 1 if not covered
# Note: Only returns 0 (covered) when DNS-01 challenge is enabled,
# as wildcards cannot be validated with HTTP-01 challenge
is_covered_by_wildcard() {
local DOMAIN=$1
# Only skip if DNS challenge is enabled (wildcards require DNS-01)
if [[ ${ACME_DNS_CHALLENGE} != "y" ]]; then
return 1
fi
# Return early if no ADDITIONAL_SAN is set
if [[ -z ${ADDITIONAL_SAN} ]]; then
return 1
fi
# Extract parent domain (e.g., mail.example.com -> example.com)
local PARENT_DOMAIN=$(echo ${DOMAIN} | cut -d. -f2-)
# Check if ADDITIONAL_SAN contains a wildcard for this parent domain
if [[ "${ADDITIONAL_SAN}" == *"*.${PARENT_DOMAIN}"* ]]; then
return 0 # Covered by wildcard
fi
return 1 # Not covered
}
+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" log_f "Unknown certificate type '${TYPE}' requested"
exit 5 exit 5
fi fi
if [[ "${ACME_DNS_CHALLENGE}" == "y" ]]; then
exec /srv/obtain-certificate-dns.sh "$@"
fi
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
+3 -3
View File
@@ -1,7 +1,7 @@
FROM alpine:3.21 AS builder FROM alpine:3.24 AS builder
WORKDIR /src WORKDIR /src
ENV CLAMD_VERSION=1.4.2 ENV CLAMD_VERSION=1.4.6
RUN apk upgrade --no-cache \ RUN apk upgrade --no-cache \
&& apk add --update --no-cache \ && apk add --update --no-cache \
@@ -68,7 +68,7 @@ RUN wget -P /src https://www.clamav.net/downloads/production/clamav-${CLAMD_VERS
"/clamav/etc/clamav/clamav-milter.conf.sample" > "/clamav/etc/clamav/clamav-milter.conf" || exit 1 "/clamav/etc/clamav/clamav-milter.conf.sample" > "/clamav/etc/clamav/clamav-milter.conf" || exit 1
FROM alpine:3.21 FROM alpine:3.24
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21 FROM alpine:3.24
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
+2 -2
View File
@@ -110,12 +110,12 @@ async def get_container(container_id : str):
return Response(content=json.dumps(res, indent=4), media_type="application/json") return Response(content=json.dumps(res, indent=4), media_type="application/json")
@app.get("/containers/json") @app.get("/containers/json")
async def get_containers(): async def get_containers(all: bool = False):
global dockerapi global dockerapi
containers = {} containers = {}
try: try:
for container in (await dockerapi.async_docker_client.containers.list()): for container in (await dockerapi.async_docker_client.containers.list(all=all)):
container_info = await container.show() container_info = await container.show()
containers.update({container_info['Id']: container_info}) containers.update({container_info['Id']: container_info})
return Response(content=json.dumps(containers, indent=4), media_type="application/json") return Response(content=json.dumps(containers, indent=4), media_type="application/json")
+103 -3
View File
@@ -412,6 +412,63 @@ class DockerApi:
sogo_return = container.exec_run(["/bin/bash", "-c", f"sogo-tool rename-user '{old_username}' '{new_username}'"], user='sogo') 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) return self.exec_run_handler('generic', sogo_return)
# api call: container_post - post_action: exec - cmd: sogo - task: subscribe
# Subscribes users to a folder of the owner, or unsubscribes them; values are passed as environment and
# arguments, never through the shell
def container_post__exec__sogo__subscribe(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):
owner = str(request_json.get('owner', ''))
folder = str(request_json.get('folder', ''))
users = [str(user) for user in request_json.get('users', []) if str(user)]
if not owner or not users or not re.match(r'^(Calendar|Contacts)/[A-Za-z0-9._-]+$', folder):
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'owner, folder and users required' }, indent=4), media_type="application/json")
script = 'for user in "$@"; do sogo-tool manage-acl "$SOGO_ACTION" "$SOGO_OWNER" "$SOGO_FOLDER" "$user" || exit 1; done'
subscribe_return = container.exec_run(["/bin/bash", "-c", script, "subscribe"] + users, user='sogo',
environment={ 'SOGO_ACTION': 'unsubscribe' if request_json.get('unsubscribe') else 'subscribe', 'SOGO_OWNER': owner, 'SOGO_FOLDER': folder })
return self.exec_run_handler('generic', subscribe_return)
# api call: container_post - post_action: exec - cmd: sogo - task: init_user
# Creates the personal folders of users that never logged in to SOGo (e.g. shared mailboxes)
def container_post__exec__sogo__init_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']}
for container in self.sync_docker_client.containers.list(filters=filters):
users = [str(user) for user in request_json.get('users', []) if str(user)]
if not users:
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'users required' }, indent=4), media_type="application/json")
init_return = container.exec_run(["/bin/bash", "-c", 'for user in "$@"; do sogo-tool init-user "$user" || exit 1; done', "init_user"] + users, user='sogo')
return self.exec_run_handler('generic', init_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: subscribe_shared
# Subscribes users to every folder of a mailbox shared with them (Shared/<owner>/...), or unsubscribes them
def container_post__exec__doveadm__subscribe_shared(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):
owner = str(request_json.get('owner', ''))
users = [str(user) for user in request_json.get('users', []) if str(user)]
if not owner or not users or any(c in owner for c in '/*%'):
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'owner and users required' }, indent=4), media_type="application/json")
if request_json.get('unsubscribe'):
# Without access the user no longer sees the folders, their names come from the owner
script = ('set -o pipefail; for user in "$@"; do doveadm mailbox list -u "$SUB_OWNER" | while IFS= read -r folder; do '
'case "$folder" in Shared|Shared/*) continue;; esac; doveadm mailbox unsubscribe -u "$user" "Shared/$SUB_OWNER/$folder" || exit 1; '
'done || exit 1; done')
else:
# No visible folder means no access yet (ACL, auth cache): fail, so the subscription is retried
script = ('for user in "$@"; do folders=$(doveadm mailbox list -u "$user" "Shared/$SUB_OWNER/*") && [ -n "$folders" ] || exit 1; '
'while IFS= read -r folder; do doveadm mailbox subscribe -u "$user" "$folder" || exit 1; done <<< "$folders"; done')
subscribe_return = container.exec_run(["/bin/bash", "-c", script, "subscribe_shared"] + users, environment={ 'SUB_OWNER': owner })
return self.exec_run_handler('generic', subscribe_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: get_acl # api call: container_post - post_action: exec - cmd: doveadm - task: get_acl
def container_post__exec__doveadm__get_acl(self, request_json, **kwargs): def container_post__exec__doveadm__get_acl(self, request_json, **kwargs):
if 'container_id' in kwargs: if 'container_id' in kwargs:
@@ -439,7 +496,8 @@ class DockerApi:
if len(acls) >= 2: if len(acls) >= 2:
for acl in acls[1:]: for acl in acls[1:]:
user_id, rights = acl.split(maxsplit=1) user_id, rights = acl.split(maxsplit=1)
user_id = user_id.split('=')[1] # user=<name> as plain name, other identifiers (group=...) keep their type
user_id = user_id.split('=', 1)[1] if user_id.startswith('user=') else user_id
mailbox_seen.append(mailbox) mailbox_seen.append(mailbox)
formatted_acls.append({ 'user': id, 'id': user_id, 'mailbox': mailbox, 'rights': rights.split() }) formatted_acls.append({ 'user': id, 'id': user_id, 'mailbox': mailbox, 'rights': rights.split() })
elif "Shared" in shared_folder and "/" in shared_folder: elif "Shared" in shared_folder and "/" in shared_folder:
@@ -476,7 +534,7 @@ class DockerApi:
id = request_json['id'].replace("'", "'\\''") id = request_json['id'].replace("'", "'\\''")
if user and mailbox and id: if user and mailbox and id:
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' 'user={id}'"]) acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' '{self.acl_identifier(id)}'"])
return self.exec_run_handler('generic', acl_delete_return) return self.exec_run_handler('generic', acl_delete_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl # api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs): def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
@@ -510,8 +568,50 @@ class DockerApi:
rights += right + " " rights += right + " "
if user and mailbox and id and rights: 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}"]) acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' '{self.acl_identifier(id)}' {rights}"])
return self.exec_run_handler('generic', acl_set_return) return self.exec_run_handler('generic', acl_set_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl_all
# Sets the rights of an identifier on every folder of a mailbox, no rights = delete; values are passed as
# environment, never through the shell
def container_post__exec__doveadm__set_acl_all(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.get('user', '')
id = self.acl_identifier(request_json.get('id', ''))
rights = [right.lower() for right in request_json.get('rights', []) if right.lower() in self.acl_rights]
if not user or id in ('user=', 'group='):
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'user and id required' }, indent=4), media_type="application/json")
action = 'doveadm acl set -u "$ACL_USER" "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" "$folder" "$ACL_ID"'
script = ('set -o pipefail; doveadm mailbox list -u "$ACL_USER" | while IFS= read -r folder; do '
'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"')
acl_return = container.exec_run(["/bin/bash", "-c", script],
environment={ 'ACL_USER': user, 'ACL_ID': id, 'ACL_RIGHTS': ' '.join(rights) })
return self.exec_run_handler('generic', acl_return)
# api call: container_post - post_action: exec - cmd: doveadm - task: auth_cache_flush
# Drops cached userdb results (e.g. acl_groups) of the given users, all users if none are given
def container_post__exec__doveadm__auth_cache_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):
users = [str(user) for user in request_json.get('users', []) if str(user)]
flush_return = container.exec_run(["doveadm", "auth", "cache", "flush"] + users)
return self.exec_run_handler('generic', flush_return)
# Rights doveadm acl set accepts from the API
acl_rights = ["admin", "create", "delete", "expunge", "insert", "lookup", "post", "read", "write", "write-deleted", "write-seen"]
# ACL identifier for doveadm: "user=<name>" for a plain name, "user=..." / "group=..." as given
def acl_identifier(self, id):
if id.startswith('user=') or id.startswith('group='):
return id
return 'user=' + id
# Collect host stats # Collect host stats
+44 -15
View File
@@ -1,12 +1,48 @@
FROM alpine:3.21 # Alpine >= 3.22 only ships Dovecot 2.4, Dovecot 2.3 is built from the Alpine 3.21 packaging
FROM alpine:3.24 AS builder
# Last commit of the aports 3.21-stable branch
ARG APORTS_COMMIT=55126a11a4b9dcd14de3068e3348e9951d144ca6
RUN apk add --no-cache alpine-sdk git \
&& git init -q /aports \
&& cd /aports \
&& git remote add origin https://github.com/alpinelinux/aports.git \
&& git sparse-checkout set main/dovecot community/dovecot-fts-flatcurve \
&& git fetch -q --depth 1 --filter=blob:none origin ${APORTS_COMMIT} \
&& git checkout -q FETCH_HEAD \
&& abuild-keygen -a -n \
&& cp /root/.abuild/*.rsa.pub /etc/apk/keys/ \
&& apk update \
&& cd /aports/main/dovecot \
# newer gettext does not overwrite the shipped config.rpath without -f
&& sed -i 's/autoreconf -vi/autoreconf -fvi/' APKBUILD \
&& abuild -F -r \
# flatcurve must be built against the Dovecot 2.3 headers
&& apk add --no-cache /root/packages/main/*/dovecot-2*.apk /root/packages/main/*/dovecot-dev-*.apk \
&& cd /aports/community/dovecot-fts-flatcurve \
&& abuild -F -r \
&& mkdir /packages \
&& cp /root/.abuild/*.rsa.pub /packages/ \
&& for pkg in dovecot dovecot-dev dovecot-lmtpd dovecot-lua dovecot-ldap dovecot-mysql dovecot-sql \
dovecot-submissiond dovecot-pigeonhole-plugin dovecot-pop3d dovecot-fts-flatcurve; do \
cp /root/packages/*/*/${pkg}-[0-9]*.apk /packages/; \
done
FROM alpine:3.24
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$ # renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
ARG GOSU_VERSION=1.17 ARG GOSU_VERSION=1.20
ENV LANG=C.UTF-8 ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8 ENV LC_ALL=C.UTF-8
# MariaDB Connector/C >= 3.4 refuses DB servers without TLS, this covers Perl DBI, client.cnf covers Dovecot
ENV MARIADB_TLS_DISABLE_PEER_VERIFICATION=1
COPY --from=builder /packages /tmp/packages
# Add groups and users before installing Dovecot to not break compatibility # Add groups and users before installing Dovecot to not break compatibility
RUN addgroup -g 5000 vmail \ RUN addgroup -g 5000 vmail \
@@ -18,6 +54,7 @@ RUN addgroup -g 5000 vmail \
&& adduser -D -u 5000 -G vmail -h /var/vmail vmail \ && adduser -D -u 5000 -G vmail -h /var/vmail vmail \
&& adduser -D -G dovecot -u 401 -h /dev/null -s /sbin/nologin dovecot \ && adduser -D -G dovecot -u 401 -h /dev/null -s /sbin/nologin dovecot \
&& adduser -D -G dovenull -u 402 -h /dev/null -s /sbin/nologin dovenull \ && adduser -D -G dovenull -u 402 -h /dev/null -s /sbin/nologin dovenull \
&& cp /tmp/packages/*.rsa.pub /etc/apk/keys/ \
&& apk add --no-cache --update \ && apk add --no-cache --update \
bash \ bash \
bind-tools \ bind-tools \
@@ -68,7 +105,6 @@ RUN addgroup -g 5000 vmail \
perl-package-stash-xs \ perl-package-stash-xs \
perl-par-packer \ perl-par-packer \
perl-parse-recdescent \ perl-parse-recdescent \
perl-lockfile-simple \
libproc2 \ libproc2 \
perl-readonly \ perl-readonly \
perl-regexp-common \ perl-regexp-common \
@@ -99,28 +135,21 @@ RUN addgroup -g 5000 vmail \
supervisor \ supervisor \
tzdata \ tzdata \
wget \ wget \
dovecot \ /tmp/packages/*.apk \
dovecot-dev \ && rm -rf /tmp/packages \
dovecot-lmtpd \
dovecot-lua \
dovecot-ldap \
dovecot-mysql \
dovecot-sql \
dovecot-submissiond \
dovecot-pigeonhole-plugin \
dovecot-pop3d \
dovecot-fts-flatcurve \
&& arch=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) \ && arch=$(arch | sed s/aarch64/arm64/ | sed s/x86_64/amd64/) \
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$arch" \ && wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$arch" \
&& chmod +x /usr/local/bin/gosu \ && chmod +x /usr/local/bin/gosu \
&& gosu nobody true && gosu nobody true
# Dovecot's MySQL driver reads [client] from /etc/my.cnf, Connector/C ignores the !includedir there
COPY client.cnf /etc/my.cnf
COPY trim_logs.sh /usr/local/bin/trim_logs.sh COPY trim_logs.sh /usr/local/bin/trim_logs.sh
COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
COPY imapsync /usr/local/bin/imapsync COPY imapsync /usr/local/bin/imapsync
COPY imapsync_runner.pl /usr/local/bin/imapsync_runner.pl COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py
COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
+3
View File
@@ -0,0 +1,3 @@
[client]
ssl = false
ssl-verify-server-cert = false
+12 -2
View File
@@ -127,7 +127,8 @@ cat <<EOF > /etc/dovecot/sql/dovecot-dict-sql-userdb.conf
# Autogenerated by mailcow # Autogenerated by mailcow
driver = mysql driver = mysql
connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}" connect = "host=/var/run/mysqld/mysqld.sock dbname=${DBNAME} user=${DBUSER} password=${DBPASS}"
user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%d/%n/${MAILDIR_SUB}:VOLATILEDIR=/var/volatile/%u:INDEX=/var/vmail_index/%u') AS mail, '%s' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule FROM mailbox WHERE username = '%u' AND (active = '1' OR active = '2') # acl_groups: active mailbox groups of the user, IMAP grants are ACLs on group=mcg_<id>
user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format')), mailbox_path_prefix, '%d/%n/${MAILDIR_SUB}:VOLATILEDIR=/var/volatile/%u:INDEX=/var/vmail_index/%u') AS mail, '%s' AS protocol, 5000 AS uid, 5000 AS gid, concat('*:bytes=', quota) AS quota_rule, (SELECT GROUP_CONCAT(CONCAT('mcg_', gm.group_id)) FROM mailbox_group_member gm JOIN mailbox_group g ON g.id = gm.group_id AND g.active = 1 WHERE gm.username = mailbox.username) AS acl_groups FROM mailbox WHERE username = '%u' AND (active = '1' OR active = '2')
iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2'; iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2';
EOF EOF
@@ -279,7 +280,7 @@ chmod g+rw /dev/console
chown root:tty /dev/console chown root:tty /dev/console
chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \ chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
/usr/lib/dovecot/sieve/rspamd-pipe-spam \ /usr/lib/dovecot/sieve/rspamd-pipe-spam \
/usr/local/bin/imapsync_runner.pl \ /usr/local/bin/imapsync_runner.py \
/usr/local/bin/imapsync \ /usr/local/bin/imapsync \
/usr/local/bin/trim_logs.sh \ /usr/local/bin/trim_logs.sh \
/usr/local/bin/sa-rules.sh \ /usr/local/bin/sa-rules.sh \
@@ -304,6 +305,15 @@ IMAPSYNC_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DB
# Envsubst maildir_gc # Envsubst maildir_gc
echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh
# Wait for updated schema (DB_VERSION from docker-compose.yml), the userdb query reads tables the init_db of php-fpm creates on updates
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; do
echo "Waiting for schema update..."
sleep 5
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
done
echo "DB schema is ${DBV_NOW}"
# GUID generation # GUID generation
while [[ ${VERSIONS_OK} != 'OK' ]]; do while [[ ${VERSIONS_OK} != 'OK' ]]; do
if [[ ! -z $(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = \"${DBNAME}\" AND TABLE_NAME = 'versions'") ]]; then if [[ ! -z $(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -B -e "SELECT 'OK' FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = \"${DBNAME}\" AND TABLE_NAME = 'versions'") ]]; then
File diff suppressed because it is too large. Load diff
-196
View File
@@ -1,196 +0,0 @@
#!/usr/bin/perl
use DBI;
use LockFile::Simple qw(lock trylock unlock);
use Proc::ProcessTable;
use Data::Dumper qw(Dumper);
use IPC::Run 'run';
use File::Temp;
use Try::Tiny;
use sigtrap 'handler' => \&sig_handler, qw(INT TERM KILL QUIT);
sub trim { my $s = shift; $s =~ s/^\s+|\s+$//g; return $s };
my $t = Proc::ProcessTable->new;
my $imapsync_running = grep { $_->{cmndline} =~ /imapsync\s/i } @{$t->table};
if ($imapsync_running ge 1)
{
print "imapsync is active, exiting...";
exit;
}
sub qqw($) {
my @params = ();
my @values = split(/(?=--)/, $_[0]);
foreach my $val (@values) {
my @tmpparam = split(/ /, $val, 2);
foreach my $tmpval (@tmpparam) {
if ($tmpval ne '') {
push @params, $tmpval;
}
}
}
foreach my $val (@params) {
$val=trim($val);
}
return @params;
}
$run_dir="/tmp";
$dsn = 'DBI:mysql:database=' . $ENV{'DBNAME'} . ';mysql_socket=/var/run/mysqld/mysqld.sock';
$lock_file = $run_dir . "/imapsync_busy";
$lockmgr = LockFile::Simple->make(-autoclean => 1, -max => 1);
$lockmgr->lock($lock_file) || die "can't lock ${lock_file}";
$dbh = DBI->connect($dsn, $ENV{'DBUSER'}, $ENV{'DBPASS'}, {
mysql_auto_reconnect => 1,
mysql_enable_utf8mb4 => 1
});
$dbh->do("UPDATE imapsync SET is_running = 0");
sub sig_handler {
# Send die to force exception in "run"
die "sig_handler received signal, preparing to exit...\n";
};
open my $file, '<', "/etc/sogo/sieve.creds";
my $creds = <$file>;
close $file;
my ($master_user, $master_pass) = split /:/, $creds;
my $sth = $dbh->prepare("SELECT id,
user1,
user2,
host1,
authmech1,
password1,
exclude,
port1,
enc1,
delete2duplicates,
maxage,
subfolder2,
delete1,
delete2,
automap,
skipcrossduplicates,
maxbytespersecond,
custom_params,
subscribeall,
timeout1,
timeout2,
dry
FROM imapsync
WHERE active = 1
AND is_running = 0
AND (
UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(last_run) > mins_interval * 60
OR
last_run IS NULL)
ORDER BY last_run");
$sth->execute();
my $row;
while ($row = $sth->fetchrow_arrayref()) {
$id = @$row[0];
$user1 = @$row[1];
$user2 = @$row[2];
$host1 = @$row[3];
$authmech1 = @$row[4];
$password1 = @$row[5];
$exclude = @$row[6];
$port1 = @$row[7];
$enc1 = @$row[8];
$delete2duplicates = @$row[9];
$maxage = @$row[10];
$subfolder2 = @$row[11];
$delete1 = @$row[12];
$delete2 = @$row[13];
$automap = @$row[14];
$skipcrossduplicates = @$row[15];
$maxbytespersecond = @$row[16];
$custom_params = @$row[17];
$subscribeall = @$row[18];
$timeout1 = @$row[19];
$timeout2 = @$row[20];
$dry = @$row[21];
if ($enc1 eq "TLS") { $enc1 = "--tls1"; } elsif ($enc1 eq "SSL") { $enc1 = "--ssl1"; } else { undef $enc1; }
my $template = $run_dir . '/imapsync.XXXXXXX';
my $passfile1 = File::Temp->new(TEMPLATE => $template);
my $passfile2 = File::Temp->new(TEMPLATE => $template);
binmode( $passfile1, ":utf8" );
print $passfile1 "$password1\n";
print $passfile2 trim($master_pass) . "\n";
my @custom_params_a = qqw($custom_params);
my $custom_params_ref = \@custom_params_a;
my $generated_cmds = [ "/usr/local/bin/imapsync",
"--tmpdir", "/tmp",
"--nofoldersizes",
"--addheader",
($timeout1 le "0" ? () : ('--timeout1', $timeout1)),
($timeout2 le "0" ? () : ('--timeout2', $timeout2)),
($exclude eq "" ? () : ("--exclude", $exclude)),
($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)),
($maxage eq "0" ? () : ('--maxage', $maxage)),
($maxbytespersecond eq "0" ? () : ('--maxbytespersecond', $maxbytespersecond)),
($delete2duplicates ne "1" ? () : ('--delete2duplicates')),
($subscribeall ne "1" ? () : ('--subscribeall')),
($delete1 ne "1" ? () : ('--delete')),
($delete2 ne "1" ? () : ('--delete2')),
($automap ne "1" ? () : ('--automap')),
($skipcrossduplicates ne "1" ? () : ('--skipcrossduplicates')),
(!defined($enc1) ? () : ($enc1)),
"--host1", $host1,
"--user1", $user1,
"--passfile1", $passfile1->filename,
"--port1", $port1,
"--host2", "localhost",
"--user2", $user2 . '*' . trim($master_user),
"--passfile2", $passfile2->filename,
($dry eq "1" ? ('--dry') : ()),
'--no-modulesversion',
'--noreleasecheck'];
try {
$is_running = $dbh->prepare("UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL WHERE id = ?");
$is_running->bind_param( 1, ${id} );
$is_running->execute();
run [@$generated_cmds, @$custom_params_ref], '&>', \my $stdout;
# check exit code and status
($exit_code, $exit_status) = ($stdout =~ m/Exiting\swith\sreturn\svalue\s(\d+)\s\(([^:)]+)/);
$success = 0;
if (defined $exit_code && $exit_code == 0) {
$success = 1;
}
$update = $dbh->prepare("UPDATE imapsync SET returned_text = ?, success = ?, exit_status = ? WHERE id = ?");
$update->bind_param( 1, ${stdout} );
$update->bind_param( 2, ${success} );
$update->bind_param( 3, ${exit_status} );
$update->bind_param( 4, ${id} );
$update->execute();
} catch {
$update = $dbh->prepare("UPDATE imapsync SET returned_text = 'Could not start or finish imapsync', success = 0 WHERE id = ?");
$update->bind_param( 1, ${id} );
$update->execute();
} finally {
$update = $dbh->prepare("UPDATE imapsync SET last_run = NOW(), is_running = 0 WHERE id = ?");
$update->bind_param( 1, ${id} );
$update->execute();
};
}
$sth->finish();
$dbh->disconnect();
$lockmgr->unlock($lock_file);
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/python3
import fcntl
import json
import os
import re
import signal
import subprocess
import sys
import tempfile
import time
import MySQLdb
import MySQLdb.cursors
run_dir = '/tmp'
# Max size of imapsync output stored in returned_text (the tail holds the summary)
max_output = 16 * 1024 * 1024
lock_file = run_dir + '/imapsync_busy.lock'
# An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens)
token_min_valid = 900
# Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect
token_sync_interval = 60
# Next due job: higher prio first, never-run jobs first, then oldest run
next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage,
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
WHERE i.active = 1
AND s.active = 1
AND i.is_running = 0
AND (UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(i.last_run) > i.mins_interval * 60 OR i.last_run IS NULL)
{skip}
ORDER BY i.prio DESC, i.last_run IS NOT NULL, i.last_run ASC, i.id ASC
LIMIT 1"""
# Atomic claim, a job is only started when this flips is_running 0 -> 1
claim_sql = """UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL
WHERE id = %s AND is_running = 0 AND active = 1"""
# Access token: client_credentials share the source token, authorization_code use the token of
# source + local mailbox (user2) + remote user (user1). issued/error describe the last refresh.
source_token_sql = """SELECT oauth_access_token AS access_token, oauth_token_expires AS token_expires,
UNIX_TIMESTAMP(oauth_last_refresh_attempt) AS issued, oauth_last_refresh_error AS error
FROM imapsync_source WHERE id = %s"""
user_token_sql = """SELECT access_token, token_expires, UNIX_TIMESTAMP(oauth_last_refresh_attempt) AS issued,
last_refresh_error AS error FROM imapsync_source_oauth_token
WHERE source_id = %s AND owner = %s AND username = %s"""
conn = None
# Popen => job of running workers
running = {}
# Jobs released without a run (token not ready), skipped for the rest of this runner round
deferred = set()
stopping = False
def num(value):
try:
return int(value)
except (TypeError, ValueError):
return 0
def unlink(files):
for path in files:
try:
os.unlink(path)
except OSError:
pass
def imapsync_running():
for pid in filter(str.isdigit, os.listdir('/proc')):
try:
with open('/proc/' + pid + '/cmdline', 'rb') as f:
cmdline = f.read().replace(b'\0', b' ')
except OSError:
continue
if re.search(rb'imapsync\s', cmdline, re.I):
return True
return False
def connect():
return MySQLdb.connect(unix_socket='/var/run/mysqld/mysqld.sock', user=os.environ['DBUSER'],
passwd=os.environ['DBPASS'], db=os.environ['DBNAME'], charset='utf8mb4', autocommit=True)
# Reconnect when the server dropped the connection (syncs can outlast wait_timeout)
def query(sql, args=None):
global conn
try:
conn.ping()
except MySQLdb.Error:
conn = connect()
cur = conn.cursor(MySQLdb.cursors.DictCursor)
cur.execute(sql, args)
return cur
# Store a result and release the job
def finish_job(job_id, text, success, exit_status):
try:
query("UPDATE imapsync SET returned_text = %s, success = %s, exit_status = %s, last_run = NOW(), is_running = 0 WHERE id = %s",
(text, success, exit_status, job_id))
except MySQLdb.Error:
query("UPDATE imapsync SET success = 0, last_run = NOW(), is_running = 0 WHERE id = %s", (job_id,))
# Release a job without a run; last_run stays, so the next runner round retries it
def defer_job(job_id, text, exit_status):
query("UPDATE imapsync SET returned_text = %s, success = 0, exit_status = %s, is_running = 0 WHERE id = %s",
(text, exit_status, job_id))
deferred.add(job_id)
def oauth_token(j):
if j['oauth_flow'] == 'authorization_code':
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
return query(source_token_sql, (j['source_id'],)).fetchone() or {}
# Valid for token_min_valid, or half the lifetime of a short-lived token (known when its last refresh succeeded)
def token_ready(row):
token, expires, issued = row.get('access_token'), row.get('token_expires'), row.get('issued')
if not token or expires is None:
return False
need = token_min_valid
if issued and not row.get('error') and expires > int(issued):
need = min(need, (expires - int(issued)) // 2)
return expires - time.time() >= need
# Write a refreshed token over the token file of a running job (atomic, same path)
def sync_token_file(oauth):
token = oauth_token(oauth['job']).get('access_token')
if not token or token == oauth['token']:
return
fd, tmp = tempfile.mkstemp(prefix='imapsync.', dir=run_dir)
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(token + '\n')
os.replace(tmp, oauth['file'])
oauth['token'] = token
except OSError:
unlink([tmp])
# Max concurrent imapsync processes and global per-process bandwidth cap in bytes/s (0 = off),
# re-read before every claim round so changes apply to long runs
def read_settings():
settings = {r['name']: r['value'] for r in query("SELECT name, value FROM imapsync_settings")}
return max(num(settings.get('max_parallel')), 1), max(num(settings.get('max_bytes_per_second')), 0)
# Build temp files and argv for a claimed job. Returns None when the job was finished without running imapsync.
def prepare_job(j, global_bw, files):
def tmpfile(content):
fd, path = tempfile.mkstemp(prefix='imapsync.', dir=run_dir)
files.append(path)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(content)
return path
auth_type = j['auth_type'] or 'PLAIN'
oauth = None
if auth_type == 'XOAUTH2':
row = oauth_token(j)
if not token_ready(row):
defer_job(j['id'], "OAuth access token for the configured source is missing or expires too soon; refresh cron will retry.",
'OAUTH_TOKEN_NOT_READY')
return None
oauth = {'job': j, 'token': row['access_token'], 'file': tmpfile(row['access_token'] + '\n')}
auth_args = ['--authmech1', 'XOAUTH2', '--oauthaccesstoken1', oauth['file']]
else:
auth_args = ['--passfile1', tmpfile(j['password1'] + '\n')]
# imapsync auto-picks PLAIN, only pass --authmech1 for LOGIN/CRAM-MD5
if auth_type != 'PLAIN':
auth_args += ['--authmech1', auth_type]
# Custom params: JSON array of {o,v}, validated by the backend on write. One argv token per pair,
# "--opt" or "--opt=value" (value glued to its option, no shell)
custom_params = []
try:
pairs = json.loads(j['custom_params'] or '[]')
except ValueError:
pairs = []
for p in pairs if isinstance(pairs, list) else []:
if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']):
continue
value = '' if p.get('v') is None else str(p['v'])
custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value)
# Effective per-process bandwidth cap (bytes/s): per-job value, capped by the global limit
eff_bw = num(j['maxbytespersecond'])
if global_bw > 0 and (eff_bw <= 0 or eff_bw > global_bw):
eff_bw = global_bw
# --nolog: the output is stored in returned_text, imapsync's own log dir would be the (read-only) cwd
cmd = ['/usr/local/bin/imapsync', '--tmpdir', '/tmp', '--nolog', '--nofoldersizes', '--addheader']
if num(j['timeout1']) > 0:
cmd += ['--timeout1', str(j['timeout1'])]
if num(j['timeout2']) > 0:
cmd += ['--timeout2', str(j['timeout2'])]
if j['exclude']:
cmd += ['--exclude', j['exclude']]
if j['subfolder2']:
cmd += ['--subfolder2', j['subfolder2']]
if num(j['maxage']) != 0:
cmd += ['--maxage', str(j['maxage'])]
if eff_bw > 0:
cmd += ['--maxbytespersecond', str(eff_bw)]
for col, opt in (('delete2duplicates', '--delete2duplicates'), ('subscribeall', '--subscribeall'),
('delete1', '--delete1'), ('delete2', '--delete2'), ('automap', '--automap'),
('skipcrossduplicates', '--skipcrossduplicates')):
if j[col] == 1:
cmd.append(opt)
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
if enc1:
cmd.append(enc1)
cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])]
cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
if j['dry'] == 1:
cmd.append('--dry')
cmd += ['--no-modulesversion', '--noreleasecheck'] + custom_params
return {'id': j['id'], 'files': files, 'outfile': tmpfile(''), 'cmd': cmd, 'oauth': oauth}
# Worker: imapsync with output to a file, no inherited fds (DB socket, lock)
def start_job(job):
with open(job['outfile'], 'wb') as out:
return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT)
# Store the result of a finished worker and remove its temp files
def reap_job(job):
stdout = b''
try:
with open(job['outfile'], 'rb') as f:
size = os.fstat(f.fileno()).st_size
if size > max_output:
f.seek(size - max_output)
stdout = b'[output truncated, last %d bytes]\n' % max_output
stdout += f.read()
except OSError:
pass
unlink(job['files'])
text = stdout.decode('utf-8', 'replace')
# Last match: the echoed command line at the top may contain user controlled values
m = re.findall(r'Exiting\swith\sreturn\svalue\s(\d+)\s\(([^:)]+)', text)
exit_code, exit_status = m[-1] if m else (None, None)
if exit_code is None:
text = 'Could not start or finish imapsync\n' + text
finish_job(job['id'], text, 1 if exit_code is not None and int(exit_code) == 0 else 0, exit_status)
# Stop claiming new jobs, pass the signal on to the running imapsync processes
def stop(signum, frame):
global stopping
stopping = True
for proc in list(running):
proc.send_signal(signal.SIGTERM)
# Released by the kernel when the process ends, a crashed run leaves no stale lock
lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
sys.exit("can't lock " + lock_file)
if imapsync_running():
print('imapsync is active, exiting...')
sys.exit(0)
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT):
signal.signal(sig, stop)
conn = connect()
# Stale flags from crashed runs, safe only here: under the lock and with no imapsync running
query("UPDATE imapsync SET is_running = 0")
with open('/etc/sogo/sieve.creds') as f:
master_user, master_pass = [s.strip() for s in f.readline().split(':', 1)]
# Worker pool: keep up to max_parallel workers busy, claim the next due job whenever a slot is free
# (after every worker exit and at least every 10 seconds while workers run)
last_token_sync = time.monotonic()
try:
while True:
for proc, job in list(running.items()):
if proc.poll() is not None:
del running[proc]
reap_job(job)
if time.monotonic() - last_token_sync >= token_sync_interval:
last_token_sync = time.monotonic()
for job in running.values():
if job['oauth']:
try:
sync_token_file(job['oauth'])
except MySQLdb.Error as e:
print('imapsync job %d: %s' % (job['id'], e), file=sys.stderr)
max_parallel, global_bw = read_settings()
# Deferred and finished jobs drop out of next_sql, only failed claims are capped
failed_claims = 0
while not stopping and len(running) < max_parallel and failed_claims < 100:
skip = ('AND i.id NOT IN (%s)' % ', '.join(['%s'] * len(deferred))) if deferred else ''
j = query(next_sql.format(skip=skip), tuple(deferred) or None).fetchone()
if not j:
break
if query(claim_sql, (j['id'],)).rowcount != 1:
failed_claims += 1
continue
files = []
try:
job = prepare_job(j, global_bw, files)
except Exception as e:
print('imapsync job %d: %s' % (j['id'], e), file=sys.stderr)
finish_job(j['id'], 'Could not start or finish imapsync', 0, None)
job = None
if not job:
unlink(files)
continue
try:
running[start_job(job)] = job
except OSError as e:
# Release the claim, the job is picked up again later
print('imapsync job %d: %s' % (j['id'], e), file=sys.stderr)
unlink(files)
query("UPDATE imapsync SET is_running = 0 WHERE id = %s", (j['id'],))
break
if not running:
break
# A worker exit ends the wait early
for _ in range(50):
if any(proc.poll() is not None for proc in running):
break
time.sleep(0.2)
finally:
# Never leave password/token files behind
for job in running.values():
unlink(job['files'])
@@ -47,7 +47,7 @@ try:
if max_score == "": if max_score == "":
max_score = 9999.0 max_score = 9999.0
def query_mysql(query, headers = True, update = False): def query_mysql(query, params = None, headers = True, update = False):
while True: while True:
try: try:
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci") cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
@@ -57,7 +57,10 @@ try:
else: else:
break break
cur = cnx.cursor() cur = cnx.cursor()
cur.execute(query) if params:
cur.execute(query, params)
else:
cur.execute(query)
if not update: if not update:
result = [] result = []
columns = tuple( [d[0] for d in cur.description] ) columns = tuple( [d[0] for d in cur.description] )
@@ -76,7 +79,7 @@ try:
def notify_rcpt(rcpt, msg_count, quarantine_acl, category): def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
if category == "add_header": category = "add header" if category == "add_header": category = "add header"
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category)) meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = %s AND score < %s AND (action = %s OR "all" = %s)', (rcpt, max_score, category, category))
print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count)) print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
if len(meta_query) == 0: if len(meta_query) == 0:
return return
@@ -130,7 +133,7 @@ try:
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text) server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
server.quit() server.quit()
for res in meta_query: for res in meta_query:
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%d"' % (res['id']), update = True) query_mysql('UPDATE quarantine SET notified = 1 WHERE id = %s', (res['id'],), update = True)
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now) r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
break break
except Exception as ex: except Exception as ex:
@@ -138,7 +141,7 @@ try:
print('%s' % (ex)) print('%s' % (ex))
time.sleep(3) time.sleep(3)
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %f AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt' % (max_score)) records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %s AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt', (max_score,))
for record in records: for record in records:
attrs = '' attrs = ''
@@ -156,7 +159,7 @@ try:
except Exception as ex: except Exception as ex:
print('Could not determine last notification for %s, assuming never' % (record['rcpt'])) print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
last_notification = 0 last_notification = 0
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = "%s"' % (record['rcpt'])) attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = %s', (record['rcpt'],))
attrs = attrs_json[0]['attributes'] attrs = attrs_json[0]['attributes']
if isinstance(attrs, str): if isinstance(attrs, str):
# if attr is str then just load it # if attr is str then just load it
+2 -2
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21 FROM alpine:3.24
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -40,4 +40,4 @@ COPY ./docker-entrypoint.sh /app/
RUN chmod +x /app/docker-entrypoint.sh RUN chmod +x /app/docker-entrypoint.sh
CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"] CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"]
@@ -17,8 +17,8 @@ if [ $nftables_found -gt $iptables_found ]; then
fi fi
if [ $nftables_found -eq 0 ] && [ $nftables_found -eq $iptables_found ]; then if [ $nftables_found -eq 0 ] && [ $nftables_found -eq $iptables_found ]; then
nftables_lines=$(nft list ruleset | wc -l) nftables_lines=$(nft list ruleset 2>/dev/null | wc -l)
iptables_lines=$(iptables-save | wc -l) iptables_lines=$(iptables-save 2>/dev/null | wc -l)
if [ $nftables_lines -gt $iptables_lines ]; then if [ $nftables_lines -gt $iptables_lines ]; then
backend=nftables backend=nftables
else else
+57 -548
View File
@@ -1,548 +1,57 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
DEBUG = False import os
import sys
import re import time
import os import signal
import sys import redis
import time from modules.Logger import Logger
import atexit from modules.Netfilter import Netfilter
import signal
import ipaddress chain_name = "MAILCOW"
from collections import Counter
from random import randint def connect_redis(logger):
from threading import Thread if os.getenv('REDIS_SLAVEOF_IP', ''):
from threading import Lock host, port = os.getenv('REDIS_SLAVEOF_IP'), os.getenv('REDIS_SLAVEOF_PORT', '')
import redis else:
import json host, port = os.getenv('IPV4_NETWORK', '172.22.1') + '.249', 6379
import dns.resolver while True:
import dns.exception try:
import uuid # log lines are not always valid UTF-8
from modules.Logger import Logger r = redis.StrictRedis(host=host, port=port, db=0, password=os.environ['REDISPASS'], decode_responses=True,
from modules.IPTables import IPTables encoding_errors='replace', socket_connect_timeout=5, socket_timeout=30)
from modules.NFTables import NFTables r.ping()
return r
def logdebug(msg): except Exception as ex:
if DEBUG: logger.logWarn('Redis connection failed: %s - trying again in 3 seconds' % ex)
logger.logInfo("DEBUG: %s" % msg) time.sleep(3)
# Globals if __name__ == '__main__':
WHITELIST = [] logger = Logger()
BLACKLIST = []
bans = {} backend = sys.argv[1]
quit_now = False if backend == "nftables":
exit_code = 0 from modules.NFTables import NFTables
lock = Lock() logger.logInfo('Using NFTables backend')
chain_name = "MAILCOW" tables = NFTables(chain_name, logger)
r = None else:
pubsub = None from modules.IPTables import IPTables
clear_before_quit = False logger.logInfo('Using IPTables backend')
logger.logWarn(
def refreshF2boptions(): "DEPRECATION: iptables-legacy is deprecated and will be removed in future releases. "
global f2boptions "Please switch to nftables on your host to ensure complete compatibility."
global quit_now )
global exit_code time.sleep(5)
f2boptions = {} tables = IPTables(chain_name, logger)
if not r.get('F2B_OPTIONS'): r = connect_redis(logger)
f2boptions['ban_time'] = r.get('F2B_BAN_TIME') logger.set_redis(r)
f2boptions['max_ban_time'] = r.get('F2B_MAX_BAN_TIME')
f2boptions['ban_time_increment'] = r.get('F2B_BAN_TIME_INCREMENT') netfilter = Netfilter(r, tables, logger, os.environ)
f2boptions['max_attempts'] = r.get('F2B_MAX_ATTEMPTS') signal.signal(signal.SIGTERM, lambda signum, frame: netfilter.stop.set())
f2boptions['retry_window'] = r.get('F2B_RETRY_WINDOW') signal.signal(signal.SIGINT, lambda signum, frame: netfilter.stop.set())
f2boptions['netban_ipv4'] = r.get('F2B_NETBAN_IPV4')
f2boptions['netban_ipv6'] = r.get('F2B_NETBAN_IPV6') netfilter.setup()
else: netfilter.run()
try: netfilter.shutdown()
f2boptions = json.loads(r.get('F2B_OPTIONS')) sys.exit(0)
except ValueError as e:
logger.logCrit(
'Error loading F2B options: F2B_OPTIONS is not json. Exception: %s' % e)
quit_now = True
exit_code = 2
verifyF2boptions(f2boptions)
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)
def verifyF2boption(f2boptions, f2boption, f2bdefault):
f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
def refreshF2bregex():
global f2bregex
global quit_now
global exit_code
if not r.get('F2B_REGEX'):
f2bregex = {}
f2bregex[1] = r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
f2bregex[2] = r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)'
f2bregex[3] = r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+'
f2bregex[4] = r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+'
f2bregex[5] = r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+'
f2bregex[6] = r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)'
f2bregex[7] = r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)'
f2bregex[8] = r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
f2bregex[9] = r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+'
r.set('F2B_REGEX', json.dumps(f2bregex, ensure_ascii=False))
else:
try:
f2bregex = {}
f2bregex = json.loads(r.get('F2B_REGEX'))
except ValueError:
logger.logCrit('Error loading F2B options: F2B_REGEX is not json')
quit_now = True
exit_code = 2
def get_ip(address):
ip = ipaddress.ip_address(address)
if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
ip = ip.ipv4_mapped
if ip.is_private or ip.is_loopback:
return False
return ip
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'])
NETBAN_IPV4 = '/' + str(f2boptions['netban_ipv4'])
NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
ip = get_ip(address)
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)
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 = 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()))
NET_BAN_TIME = calcNetBanTime(bans[net]['ban_counter'])
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))
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('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
def permBan(net, unban=False):
global f2boptions
global lock
is_unbanned = False
is_banned = False
if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network:
with lock:
if unban:
is_unbanned = tables.unbanIPv4(net)
elif int(f2boptions['manage_external']) != 1:
is_banned = tables.banIPv4(net)
else:
with lock:
if unban:
is_unbanned = tables.unbanIPv6(net)
elif int(f2boptions['manage_external']) != 1:
is_banned = tables.banIPv6(net)
if is_unbanned:
r.hdel('F2B_PERM_BANS', '%s' % 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 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:
if r is not None:
r.delete('F2B_ACTIVE_BANS')
r.delete('F2B_PERM_BANS')
except Exception as ex:
logger.logWarn('Error clearing redis keys F2B_ACTIVE_BANS and F2B_PERM_BANS: %s' % ex)
def watch():
global pubsub
global quit_now
global exit_code
logger.logInfo('Watching Redis channel F2B_CHANNEL')
pubsub.subscribe('F2B_CHANNEL')
while not quit_now:
try:
for item in pubsub.listen():
refreshF2bregex()
for rule_id, rule_regex in f2bregex.items():
if item['data'] and item['type'] == 'message':
try:
result = re.search(rule_regex, item['data'])
except re.error:
result = False
if result:
addr = result.group(1)
ip = ipaddress.ip_address(addr)
if ip.is_private or ip.is_loopback:
continue
logger.logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data']))
ban(addr)
except Exception as ex:
logger.logWarn('Error reading log line from pubsub: %s' % ex)
pubsub = None
quit_now = True
exit_code = 2
def snat4(snat_target):
global lock
global quit_now
while not quit_now:
time.sleep(10)
with lock:
tables.snat4(snat_target, os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24')
def snat6(snat_target):
global lock
global quit_now
while not quit_now:
time.sleep(10)
with lock:
tables.snat6(snat_target, os.getenv('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'))
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))
# 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
global quit_now
global exit_code
while not quit_now:
time.sleep(10)
with lock:
quit_now, exit_code = tables.checkIPv4ChainOrder()
if quit_now: return
quit_now, exit_code = tables.checkIPv6ChainOrder()
def calcNetBanTime(ban_counter):
global f2boptions
BAN_TIME = int(f2boptions['ban_time'])
MAX_BAN_TIME = int(f2boptions['max_ban_time'])
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** ban_counter
NET_BAN_TIME = max([BAN_TIME, min([NET_BAN_TIME, MAX_BAN_TIME])])
return NET_BAN_TIME
def isIpNetwork(address):
try:
ipaddress.ip_network(address, False)
except ValueError:
return False
return True
def genNetworkList(list):
resolver = dns.resolver.Resolver()
hostnames = []
networks = []
for key in list:
if isIpNetwork(key):
networks.append(key)
else:
hostnames.append(key)
for hostname in hostnames:
hostname_ips = []
for rdtype in ['A', 'AAAA']:
try:
answer = resolver.resolve(qname=hostname, rdtype=rdtype, lifetime=3)
except dns.exception.Timeout:
logger.logInfo('Hostname %s timedout on resolve' % hostname)
break
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
continue
except dns.exception.DNSException as dnsexception:
logger.logInfo('%s' % dnsexception)
continue
for rdata in answer:
hostname_ips.append(rdata.to_text())
networks.extend(hostname_ips)
return set(networks)
def whitelistUpdate():
global lock
global quit_now
global WHITELIST
while not quit_now:
start_time = time.time()
list = r.hgetall('F2B_WHITELIST')
new_whitelist = []
if list:
new_whitelist = genNetworkList(list)
with lock:
if Counter(new_whitelist) != Counter(WHITELIST):
WHITELIST = new_whitelist
logger.logInfo('Allowlist was changed, it has %s entries' % len(WHITELIST))
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
def blacklistUpdate():
global quit_now
global BLACKLIST
while not quit_now:
start_time = time.time()
list = r.hgetall('F2B_BLACKLIST')
new_blacklist = []
if list:
new_blacklist = genNetworkList(list)
if Counter(new_blacklist) != Counter(BLACKLIST):
addban = set(new_blacklist).difference(BLACKLIST)
delban = set(BLACKLIST).difference(new_blacklist)
BLACKLIST = new_blacklist
logger.logInfo('Denylist was changed, it has %s entries' % len(BLACKLIST))
if addban:
for net in addban:
permBan(net=net)
if delban:
for net in delban:
permBan(net=net, unban=True)
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
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 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__':
logger = Logger()
logdebug("Sys.argv: %s" % sys.argv)
atexit.register(before_quit)
signal.signal(signal.SIGTERM, sigterm_quit)
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)
clear()
logger.logInfo("Initializing mailcow netfilter chain")
tables.initChainIPv4()
tables.initChainIPv6()
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")
tables.create_mailcow_isolation_rule("br-mailcow", [3306, 6379, 8983, 12345], os.getenv("MAILCOW_REPLICA_IP"))
# connect to redis
while True:
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'])
else:
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:
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")
if r.exists('F2B_LOG'):
logdebug("Renaming F2B_LOG to NETFILTER_LOG")
r.rename('F2B_LOG', 'NETFILTER_LOG')
r.delete('F2B_ACTIVE_BANS')
r.delete('F2B_PERM_BANS')
refreshF2boptions()
watch_thread = Thread(target=watch)
watch_thread.daemon = True
watch_thread.start()
if os.getenv('SNAT_TO_SOURCE') and os.getenv('SNAT_TO_SOURCE') != 'n':
try:
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.daemon = True
snat4_thread.start()
except ValueError:
print(os.getenv('SNAT_TO_SOURCE') + ' is not a valid IPv4 address')
if os.getenv('SNAT6_TO_SOURCE') and os.getenv('SNAT6_TO_SOURCE') != 'n':
try:
snat_ip = os.getenv('SNAT6_TO_SOURCE')
snat_ipo = ipaddress.ip_address(snat_ip)
if type(snat_ipo) is ipaddress.IPv6Address:
snat6_thread = Thread(target=snat6,args=(snat_ip,))
snat6_thread.daemon = True
snat6_thread.start()
except ValueError:
print(os.getenv('SNAT6_TO_SOURCE') + ' is not a valid IPv6 address')
autopurge_thread = Thread(target=autopurge)
autopurge_thread.daemon = True
autopurge_thread.start()
mailcowchainwatch_thread = Thread(target=mailcowChainOrder)
mailcowchainwatch_thread.daemon = True
mailcowchainwatch_thread.start()
blacklistupdate_thread = Thread(target=blacklistUpdate)
blacklistupdate_thread.daemon = True
blacklistupdate_thread.start()
whitelistupdate_thread = Thread(target=whitelistUpdate)
whitelistupdate_thread.daemon = True
whitelistupdate_thread.start()
while not quit_now:
time.sleep(0.5)
logdebug("Exiting with code %s" % exit_code)
sys.exit(exit_code)
@@ -0,0 +1,236 @@
import ipaddress
import time
class BanManager:
"""Failed attempts and automatic bans (Redis F2B_ACTIVE_BANS: network => expiry), the denylist (F2B_PERM_BANS)
and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter
(chains, SNAT), because the backends must not run in parallel."""
# Seconds failed logins of an address are not counted after the auth backend was unavailable for it
EXCUSE_TIME = 60
def __init__(self, r, tables, options, logger, lock, clock=time.time):
self.r = r
self.tables = tables
self.options = options
self.logger = logger
self.lock = lock
self.clock = clock
self.counters = {}
self.banned = set()
self.denied = {}
self.allowlist = []
self.excused = {}
def excuse(self, ip):
"""The auth backend could not check a login of ip: its failed logins are not counted for EXCUSE_TIME"""
with self.lock:
now = self.clock()
if self.excused.get(ip, 0) <= now:
self.logger.logWarn('Auth backend unavailable for %s, its failed logins of the next %d seconds are not counted' % (
ip, self.EXCUSE_TIME))
self.excused[ip] = now + self.EXCUSE_TIME
def attempt(self, ip):
"""Counts a failed attempt of ip and bans its network after max_attempts within retry_window"""
options = self.options.values
prefix = options['netban_ipv4'] if ip.version == 4 else options['netban_ipv6']
net = str(ipaddress.ip_network('%s/%d' % (ip, prefix), strict=False))
with self.lock:
now = self.clock()
allowed = self.allowed_by(ip)
if allowed is not None:
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
return
if self.excused.get(ip, 0) > now:
self.logger.logInfo('Not counting the failed login of %s, the auth backend was unavailable' % ip)
return
if net in self.banned:
# log lines of connections from before the ban
return
counter = self.counter(net)
if now - counter['last_attempt'] > options['retry_window']:
counter['attempts'] = 0
counter['attempts'] += 1
counter['last_attempt'] = now
if counter['attempts'] < options['max_attempts']:
self.logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (
options['max_attempts'] - counter['attempts'], options['retry_window'], net))
return
ban_time = self.options.ban_time(counter['ban_counter'])
self.logger.logCrit('Banning %s for %d minutes' % (net, ban_time / 60))
# Redis first: a firewall rule without expiry would never be lifted
self.r.hset('F2B_ACTIVE_BANS', net, int(round(now + ban_time)))
self.banned.add(net)
if options['manage_external'] != 1:
self.apply([net])
def unban(self, net):
"""Lifts the automatic ban of a network; the rule stays while the denylist bans the same network"""
with self.lock:
normalized = self.normalize(net)
if normalized is None:
self.logger.logWarn('Removing invalid network %s from the active bans' % net)
else:
self.logger.logInfo('Unbanning %s' % net)
if normalized not in self.denied:
self.remove([normalized])
self.r.hdel('F2B_ACTIVE_BANS', net)
self.r.hdel('F2B_QUEUE_UNBAN', net)
if net in self.banned:
self.banned.discard(net)
counter = self.counter(net)
counter['attempts'] = 0
counter['ban_counter'] += 1
def unban_queued(self, entry):
"""Unban request of the UI: a banned network, or a network that contains banned ones (allowlisting)"""
network = self.network(entry)
if network is None:
self.logger.logWarn('Removing invalid network %s from the unban queue' % entry)
self.r.hdel('F2B_QUEUE_UNBAN', entry)
return
with self.lock:
banned = set(self.r.hkeys('F2B_ACTIVE_BANS')) | self.banned
targets = [net for net in banned if self.overlaps(net, network)]
for net in targets:
self.unban(net)
if not targets and str(network) not in self.denied:
# a rule without known ban, e.g. left by an older netfilter
if self.remove([str(network)]):
self.logger.logInfo('Unbanning %s' % network)
else:
self.logger.logInfo('%s is not banned, removing it from the unban queue' % network)
self.r.hdel('F2B_QUEUE_UNBAN', entry)
def purge(self):
"""Processes the unban queue of the UI and lifts expired bans"""
for entry in self.r.hkeys('F2B_QUEUE_UNBAN'):
self.unban_queued(entry)
now = self.clock()
for net, expires in self.r.hgetall('F2B_ACTIVE_BANS').items():
try:
expired = self.network(net) is None or float(expires) <= now
except (TypeError, ValueError):
expired = True
if expired:
self.unban(net)
with self.lock:
# bans whose Redis entry is gone, e.g. deleted by hand
for net in self.banned - set(self.r.hkeys('F2B_ACTIVE_BANS')):
self.unban(net)
self.forget_idle(now)
def restore(self):
"""Applies the unexpired automatic bans of Redis again after a start of netfilter and drops the expired ones"""
now = self.clock()
restored = []
with self.lock:
for net, expires in self.r.hgetall('F2B_ACTIVE_BANS').items():
try:
valid = self.network(net) is not None and float(expires) > now
except (TypeError, ValueError):
valid = False
if not valid:
self.r.hdel('F2B_ACTIVE_BANS', net)
continue
self.banned.add(net)
self.counter(net)
restored.append(net)
if restored and self.options.values['manage_external'] != 1:
self.apply(restored)
if restored:
self.logger.logInfo('Restored %d active bans' % len(restored))
def reapply(self):
"""Applies all bans again, e.g. after the MAILCOW chain had to be recreated"""
with self.lock:
if self.options.values['manage_external'] != 1:
self.apply(list(self.banned) + list(self.denied))
def set_denylist(self, entries):
"""Bans the networks of the denylist permanently and lifts the bans of removed entries"""
networks = {}
for entry in entries:
net = self.normalize(entry)
if net is None:
self.logger.logWarn('Skipping invalid denylist entry %s' % entry)
continue
networks[net] = entry
with self.lock:
if networks.keys() == self.denied.keys():
return
self.logger.logInfo('Denylist was changed, it has %d entries' % len(networks))
added = [net for net in networks if net not in self.denied]
removed = [net for net in self.denied if net not in networks]
if added and self.options.values['manage_external'] != 1:
self.apply(added)
for net in added:
self.r.hset('F2B_PERM_BANS', networks[net], int(round(self.clock())))
self.logger.logCrit('Added host/network %s to denylist' % networks[net])
# an automatic ban of the same network keeps its rule
auto_banned = {self.normalize(net) for net in self.banned}
self.remove([net for net in removed if net not in auto_banned])
for net in removed:
self.r.hdel('F2B_PERM_BANS', self.denied[net])
self.logger.logCrit('Removed host/network %s from denylist' % self.denied[net])
self.denied = networks
def set_allowlist(self, entries):
networks = []
for entry in entries:
network = self.network(entry)
if network is None:
self.logger.logWarn('Skipping invalid allowlist entry %s' % entry)
continue
networks.append(network)
with self.lock:
if set(networks) != set(self.allowlist):
self.allowlist = networks
self.logger.logInfo('Allowlist was changed, it has %d entries' % len(networks))
def allowed_by(self, ip):
address = ipaddress.ip_network(ip)
for network in self.allowlist:
if network.version == address.version and network.overlaps(address):
return network
return None
def counter(self, net):
return self.counters.setdefault(net, {'attempts': 0, 'last_attempt': 0.0, 'ban_counter': 0})
def forget_idle(self, now):
"""Drops counters of networks that were never banned and had no attempt within the retry window"""
window = self.options.values['retry_window']
with self.lock:
for net in [net for net, counter in self.counters.items()
if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]:
del self.counters[net]
for ip in [ip for ip, until in self.excused.items() if until <= now]:
del self.excused[ip]
def apply(self, nets):
for net in self.tables.ban(nets):
self.logger.logWarn('Could not add the firewall rule for %s' % net)
def remove(self, nets):
return self.tables.unban(nets) if nets else []
@staticmethod
def network(entry):
try:
return ipaddress.ip_network(entry, strict=False)
except (TypeError, ValueError):
return None
@classmethod
def normalize(cls, entry):
network = cls.network(entry)
return str(network) if network is not None else None
@classmethod
def overlaps(cls, net, network):
other = cls.network(net)
return other is not None and other.version == network.version and other.overlaps(network)
+132 -116
View File
@@ -1,6 +1,7 @@
import iptc import iptc
import time import time
import os import ipaddress
import contextlib
class IPTables: class IPTables:
def __init__(self, chain_name, logger): def __init__(self, chain_name, logger):
@@ -8,62 +9,10 @@ class IPTables:
self.logger = logger self.logger = logger
def initChainIPv4(self): def initChainIPv4(self):
if not iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name) in iptc.Table(iptc.Table.FILTER).chains: self.init_chain(iptc.Table(iptc.Table.FILTER))
iptc.Table(iptc.Table.FILTER).create_chain(self.chain_name)
for c in ['FORWARD', 'INPUT']:
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), c)
rule = iptc.Rule()
rule.src = '0.0.0.0/0'
rule.dst = '0.0.0.0/0'
target = iptc.Target(rule, self.chain_name)
rule.target = target
if rule not in chain.rules:
chain.insert_rule(rule)
def initChainIPv6(self): def initChainIPv6(self):
if not iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name) in iptc.Table6(iptc.Table6.FILTER).chains: self.init_chain(iptc.Table6(iptc.Table6.FILTER))
iptc.Table6(iptc.Table6.FILTER).create_chain(self.chain_name)
for c in ['FORWARD', 'INPUT']:
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), c)
rule = iptc.Rule6()
rule.src = '::/0'
rule.dst = '::/0'
target = iptc.Target(rule, self.chain_name)
rule.target = target
if rule not in chain.rules:
chain.insert_rule(rule)
def checkIPv4ChainOrder(self):
filter_table = iptc.Table(iptc.Table.FILTER)
filter_table.refresh()
return self.checkChainOrder(filter_table)
def checkIPv6ChainOrder(self):
filter_table = iptc.Table6(iptc.Table6.FILTER)
filter_table.refresh()
return self.checkChainOrder(filter_table)
def checkChainOrder(self, filter_table):
err = False
exit_code = None
forward_chain = iptc.Chain(filter_table, 'FORWARD')
input_chain = iptc.Chain(filter_table, 'INPUT')
for chain in [forward_chain, input_chain]:
target_found = False
for position, item in enumerate(chain.rules):
if item.target.name == self.chain_name:
target_found = True
if position > 2:
self.logger.logCrit('Error in %s chain: %s target not found, restarting container' % (chain.name, self.chain_name))
err = True
exit_code = 2
if not target_found:
self.logger.logCrit('Error in %s chain: %s target not found, restarting container' % (chain.name, self.chain_name))
err = True
exit_code = 2
return err, exit_code
def clearIPv4Table(self): def clearIPv4Table(self):
self.clearTable(iptc.Table(iptc.Table.FILTER)) self.clearTable(iptc.Table(iptc.Table.FILTER))
@@ -71,68 +20,133 @@ class IPTables:
def clearIPv6Table(self): def clearIPv6Table(self):
self.clearTable(iptc.Table6(iptc.Table6.FILTER)) self.clearTable(iptc.Table6(iptc.Table6.FILTER))
def clearTable(self, filter_table): @contextlib.contextmanager
filter_table.autocommit = False def transaction(self, table):
forward_chain = iptc.Chain(filter_table, "FORWARD") # One commit for all changes, autocommit rewrites the whole table for every change
input_chain = iptc.Chain(filter_table, "INPUT") table.autocommit = False
mailcow_chain = iptc.Chain(filter_table, self.chain_name) table.refresh()
if mailcow_chain in filter_table.chains: try:
for rule in mailcow_chain.rules: yield
mailcow_chain.delete_rule(rule) table.commit()
for rule in forward_chain.rules: finally:
if rule.target.name == self.chain_name: table.refresh()
forward_chain.delete_rule(rule) table.autocommit = True
for rule in input_chain.rules:
if rule.target.name == self.chain_name: def new_rule(self, table):
input_chain.delete_rule(rule) return iptc.Rule6() if isinstance(table, iptc.Table6) else iptc.Rule()
filter_table.delete_chain(self.chain_name)
filter_table.commit() def family(self, table):
return 'IPv6' if isinstance(table, iptc.Table6) else 'IPv4'
def init_chain(self, filter_table):
# Empty MAILCOW chain with one jump at the top of the input and forward chains
filter_table.refresh() filter_table.refresh()
filter_table.autocommit = True if filter_table.is_chain(self.chain_name):
filter_table.flush_entries(self.chain_name)
else:
filter_table.create_chain(self.chain_name)
self.ensure_jumps(filter_table)
def banIPv4(self, source): def ensure_chains(self):
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name) # Recreates missing MAILCOW chains and moves the jumps back to the top, True if a chain was recreated
rule = iptc.Rule() recreated = False
rule.src = source for filter_table in [iptc.Table(iptc.Table.FILTER), iptc.Table6(iptc.Table6.FILTER)]:
target = iptc.Target(rule, "REJECT") filter_table.refresh()
rule.target = target if not filter_table.is_chain(self.chain_name):
if rule in chain.rules: filter_table.create_chain(self.chain_name)
return False self.logger.logWarn(f'MAILCOW {self.family(filter_table)} chain was missing and has been recreated')
chain.insert_rule(rule) recreated = True
return True self.ensure_jumps(filter_table)
return recreated
def banIPv6(self, source): def ensure_jumps(self, filter_table):
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name) # Exactly one jump to MAILCOW as first rule: docker inserts its jump (DOCKER-USER) at the top
rule = iptc.Rule6() for name in ['INPUT', 'FORWARD']:
rule.src = source chain = iptc.Chain(filter_table, name)
target = iptc.Target(rule, "REJECT") positions = [position for position, rule in enumerate(chain.rules) if rule.target.name == self.chain_name]
rule.target = target if positions == [0]: continue
if rule in chain.rules:
return False
chain.insert_rule(rule)
return True
def unbanIPv4(self, source): with self.transaction(filter_table):
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name) for rule in [rule for rule in chain.rules if rule.target.name == self.chain_name]:
rule = iptc.Rule() chain.delete_rule(rule)
rule.src = source rule = self.new_rule(filter_table)
target = iptc.Target(rule, "REJECT") rule.target = iptc.Target(rule, self.chain_name)
rule.target = target chain.insert_rule(rule)
if rule not in chain.rules: if positions:
return False self.logger.logInfo(f'Moved MAILCOW jump to the top of the {self.family(filter_table)} {name} chain, it was at position {positions[0] + 1}')
chain.delete_rule(rule) else:
return True self.logger.logInfo(f'Added MAILCOW jump to the {self.family(filter_table)} {name} chain')
def unbanIPv6(self, source): def clearTable(self, filter_table):
chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), self.chain_name) try:
rule = iptc.Rule6() with self.transaction(filter_table):
rule.src = source if not filter_table.is_chain(self.chain_name):
target = iptc.Target(rule, "REJECT") return
rule.target = target for chain in filter_table.chains:
if rule not in chain.rules: for rule in chain.rules:
return False if rule.target.name == self.chain_name:
chain.delete_rule(rule) chain.delete_rule(rule)
return True filter_table.flush_entries(self.chain_name)
filter_table.delete_chain(self.chain_name)
self.logger.logInfo(f'Clear completed: {self.family(filter_table)}')
except Exception as ex:
self.logger.logWarn(f'Error clearing the {self.family(filter_table)} MAILCOW chain: {ex}')
def ban_rule_network(self, rule):
# Source network of a ban rule, None for every other rule
if rule.target.name not in ('REJECT', 'DROP') or rule.matches or rule.in_interface or rule.out_interface:
return None
try:
return ipaddress.ip_network(rule.src, strict=False)
except ValueError:
return None
def split_tables(self, sources):
networks = {4: [], 6: []}
for source in sources:
network = ipaddress.ip_network(source, strict=False)
networks[network.version].append(network)
tables = [(iptc.Table(iptc.Table.FILTER), networks[4]), (iptc.Table6(iptc.Table6.FILTER), networks[6])]
return [(table, table_networks) for table, table_networks in tables if table_networks]
def ban(self, sources):
# One REJECT rule per network, existing rules are kept. Returns the networks that could not be banned
failed = []
for filter_table, networks in self.split_tables(sources):
try:
with self.transaction(filter_table):
chain = iptc.Chain(filter_table, self.chain_name)
existing = {self.ban_rule_network(rule) for rule in chain.rules}
for network in dict.fromkeys(networks):
if network in existing: continue
rule = self.new_rule(filter_table)
rule.src = str(network)
rule.target = iptc.Target(rule, "REJECT")
chain.insert_rule(rule)
except Exception as ex:
self.logger.logWarn(f'iptables: {ex}')
failed += networks
return [str(network) for network in failed]
def unban(self, sources):
# Removes every rule of the networks, also duplicates of older versions. Returns the unbanned networks
removed = []
for filter_table, networks in self.split_tables(sources):
targets = set(networks)
try:
with self.transaction(filter_table):
chain = iptc.Chain(filter_table, self.chain_name)
found = set()
for rule in chain.rules:
network = self.ban_rule_network(rule)
if network in targets:
chain.delete_rule(rule)
found.add(network)
except Exception as ex:
self.logger.logWarn(f'iptables: {ex}')
continue
removed += found
return [str(network) for network in removed]
def snat4(self, snat_target, source): def snat4(self, snat_target, source):
try: try:
@@ -188,7 +202,7 @@ class IPTables:
if item == new_rule: if item == new_rule:
if position != 0: if position != 0:
chain.delete_rule(new_rule) chain.delete_rule(new_rule)
table.commit() table.commit()
table.autocommit = True table.autocommit = True
except: except:
@@ -215,7 +229,9 @@ class IPTables:
def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""): def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
try: try:
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name) table = iptc.Table(iptc.Table.FILTER)
table.refresh()
chain = iptc.Chain(table, self.chain_name)
# insert mailcow isolation rule # insert mailcow isolation rule
rule = iptc.Rule() rule = iptc.Rule()
@@ -231,7 +247,7 @@ class IPTables:
chain.insert_rule(rule, position=0) chain.insert_rule(rule, position=0)
# insert mailcow isolation exception rule # insert mailcow isolation exception rule
if _allow != "": if _allow:
rule = iptc.Rule() rule = iptc.Rule()
rule.src = _allow rule.src = _allow
rule.in_interface = f'!{_interface}' rule.in_interface = f'!{_interface}'
@@ -249,4 +265,4 @@ class IPTables:
return True return True
except Exception as e: except Exception as e:
self.logger.logCrit(f"Error adding {self.chain_name} isolation: {e}") self.logger.logCrit(f"Error adding {self.chain_name} isolation: {e}")
return False return False
+262 -458
View File
@@ -1,8 +1,9 @@
import nftables import nftables
import ipaddress import ipaddress
import os
class NFTables: class NFTables:
ISOLATION_COMMENTS = ('mailcow isolation', 'mailcow isolation allow')
def __init__(self, chain_name, logger): def __init__(self, chain_name, logger):
self.chain_name = chain_name self.chain_name = chain_name
self.logger = logger self.logger = logger
@@ -12,41 +13,15 @@ class NFTables:
self.nft.set_handle_output(True) self.nft.set_handle_output(True)
self.nft_chain_names = {'ip': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} }, self.nft_chain_names = {'ip': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} },
'ip6': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} } } 'ip6': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} } }
self.mailcow_chains = set()
self.search_current_chains() self.search_current_chains()
def initChainIPv4(self): def initChainIPv4(self):
self.insert_mailcow_chains("ip") self.init_chain("ip")
def initChainIPv6(self): def initChainIPv6(self):
self.insert_mailcow_chains("ip6") self.init_chain("ip6")
def checkIPv4ChainOrder(self):
return self.checkChainOrder("ip")
def checkIPv6ChainOrder(self):
return self.checkChainOrder("ip6")
def checkChainOrder(self, filter_table):
err = False
exit_code = None
for chain in ['input', 'forward']:
chain_position = self.check_mailcow_chains(filter_table, chain)
if chain_position is None: continue
if chain_position is False:
self.logger.logCrit(f'MAILCOW target not found in {filter_table} {chain} table, restarting container to fix it...')
err = True
exit_code = 2
if chain_position > 0:
chain_position += 1
self.logger.logCrit(f'MAILCOW target is in position {chain_position} in the {filter_table} {chain} table, restarting container to fix it...')
err = True
exit_code = 2
return err, exit_code
def clearIPv4Table(self): def clearIPv4Table(self):
self.clearTable("ip") self.clearTable("ip")
@@ -54,84 +29,19 @@ class NFTables:
def clearIPv6Table(self): def clearIPv6Table(self):
self.clearTable("ip6") self.clearTable("ip6")
def clearTable(self, _family):
is_empty_dict = True
json_command = self.get_base_dict()
chain_handle = self.get_chain_handle(_family, "filter", self.chain_name)
# if no handle, the chain doesn't exists
if chain_handle is not None:
is_empty_dict = False
# flush chain
mailcow_chain = {'family': _family, 'table': 'filter', 'name': self.chain_name}
flush_chain = {'flush': {'chain': mailcow_chain}}
json_command["nftables"].append(flush_chain)
# remove rule in forward chain
# remove rule in input chain
chains_family = [self.nft_chain_names[_family]['filter']['input'],
self.nft_chain_names[_family]['filter']['forward'] ]
for chain_base in chains_family:
if not chain_base: continue
rules_handle = self.get_rules_handle(_family, "filter", chain_base)
if rules_handle is not None:
for r_handle in rules_handle:
is_empty_dict = False
mailcow_rule = {'family':_family,
'table': 'filter',
'chain': chain_base,
'handle': r_handle }
delete_rules = {'delete': {'rule': mailcow_rule} }
json_command["nftables"].append(delete_rules)
# remove chain
# after delete all rules referencing this chain
if chain_handle is not None:
mc_chain_handle = {'family':_family,
'table': 'filter',
'name': self.chain_name,
'handle': chain_handle }
delete_chain = {'delete': {'chain': mc_chain_handle} }
json_command["nftables"].append(delete_chain)
if is_empty_dict == False:
if self.nft_exec_dict(json_command):
self.logger.logInfo(f"Clear completed: {_family}")
def banIPv4(self, source):
ban_dict = self.get_ban_ip_dict(source, "ip")
return self.nft_exec_dict(ban_dict)
def banIPv6(self, source):
ban_dict = self.get_ban_ip_dict(source, "ip6")
return self.nft_exec_dict(ban_dict)
def unbanIPv4(self, source):
unban_dict = self.get_unban_ip_dict(source, "ip")
if not unban_dict:
return False
return self.nft_exec_dict(unban_dict)
def unbanIPv6(self, source):
unban_dict = self.get_unban_ip_dict(source, "ip6")
if not unban_dict:
return False
return self.nft_exec_dict(unban_dict)
def snat4(self, snat_target, source): def snat4(self, snat_target, source):
self.snat_rule("ip", snat_target, source) self.snat_rule("ip", snat_target, source)
def snat6(self, snat_target, source): def snat6(self, snat_target, source):
self.snat_rule("ip6", snat_target, source) self.snat_rule("ip6", snat_target, source)
def nft_exec_dict(self, query: dict, quiet: bool = False):
def nft_exec_dict(self, query: dict):
if not query: return False if not query: return False
rc, output, error = self.nft.json_cmd(query) rc, output, error = self.nft.json_cmd(query)
if rc != 0: if rc != 0:
#self.logger.logCrit(f"Nftables Error: {error}") if not quiet:
self.logger.logWarn(f"nftables: {str(error).strip() or f'error {rc}'}")
return False return False
# Prevent returning False or empty string on commands that do not produce output # Prevent returning False or empty string on commands that do not produce output
@@ -144,118 +54,165 @@ class NFTables:
return {'nftables': [{ 'metainfo': { 'json_schema_version': 1} } ] } return {'nftables': [{ 'metainfo': { 'json_schema_version': 1} } ] }
def search_current_chains(self): def search_current_chains(self):
nft_chain_names = {'ip': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} },
'ip6': {'filter': {'input': '', 'forward': ''}, 'nat': {'postrouting': ''} } }
nft_chain_priority = {'ip': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} }, nft_chain_priority = {'ip': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} },
'ip6': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} } } 'ip6': {'filter': {'input': None, 'forward': None}, 'nat': {'postrouting': None} } }
mailcow_chains = set()
# Command: 'nft list chains' # Command: 'nft list chains'
_list = {'list' : {'chains': 'null'} } _list = {'list' : {'chains': 'null'} }
command = self.get_base_dict() command = self.get_base_dict()
command['nftables'].append(_list) command['nftables'].append(_list)
kernel_ruleset = self.nft_exec_dict(command) kernel_ruleset = self.nft_exec_dict(command)
if kernel_ruleset: if not kernel_ruleset:
for _object in kernel_ruleset['nftables']: return
chain = _object.get("chain")
if not chain: continue
_family = chain['family'] for _object in kernel_ruleset['nftables']:
_table = chain['table']
_hook = chain.get("hook")
_priority = chain.get("prio")
_name = chain['name']
if _family not in self.nft_chain_names: continue
if _table not in self.nft_chain_names[_family]: continue
if _hook not in self.nft_chain_names[_family][_table]: continue
if _priority is None: continue
_saved_priority = nft_chain_priority[_family][_table][_hook]
if _saved_priority is None or _priority < _saved_priority:
# at this point, we know the chain has:
# hook and priority set
# and it has the lowest priority
nft_chain_priority[_family][_table][_hook] = _priority
self.nft_chain_names[_family][_table][_hook] = _name
def search_for_chain(self, kernel_ruleset: dict, chain_name: str):
found = False
for _object in kernel_ruleset["nftables"]:
chain = _object.get("chain") chain = _object.get("chain")
if not chain: if not chain: continue
continue
ch_name = chain.get("name") _family = chain['family']
if ch_name == chain_name: _table = chain['table']
found = True _hook = chain.get("hook")
break _priority = chain.get("prio")
return found _name = chain['name']
if _family not in nft_chain_names: continue
if _table not in nft_chain_names[_family]: continue
if _table == 'filter' and _name == self.chain_name:
mailcow_chains.add(_family)
if _hook not in nft_chain_names[_family][_table]: continue
if _priority is None: continue
_saved_priority = nft_chain_priority[_family][_table][_hook]
if _saved_priority is None or _priority < _saved_priority:
# at this point, we know the chain has:
# hook and priority set
# and it has the lowest priority
nft_chain_priority[_family][_table][_hook] = _priority
nft_chain_names[_family][_table][_hook] = _name
self.nft_chain_names = nft_chain_names
self.mailcow_chains = mailcow_chains
def has_filter_chains(self, _family: str):
# no base chains: the filter table does not exist, e.g. ip6 if docker has ipv6 disabled
return any(self.nft_chain_names[_family]['filter'].values())
def list_rules(self, _family: str, _table: str, _chain: str):
# Command: 'nft list chain {family} {table} {chain}', None if the chain does not exist
_list = {'list': {'chain': {'family': _family, 'table': _table, 'name': _chain}}}
command = self.get_base_dict()
command['nftables'].append(_list)
kernel_ruleset = self.nft_exec_dict(command, quiet=True)
if not kernel_ruleset or not isinstance(kernel_ruleset, dict):
return None
return [_object["rule"] for _object in kernel_ruleset["nftables"] if _object.get("rule")]
def get_chain_dict(self, _family: str, _name: str): def get_chain_dict(self, _family: str, _name: str):
# nft (add | create) chain [<family>] <table> <name> # nft (add | create) chain [<family>] <table> <name>
_chain_opts = {'family': _family, 'table': 'filter', 'name': _name } _chain_opts = {'family': _family, 'table': 'filter', 'name': _name }
_add = {'add': {'chain': _chain_opts} } _add = {'add': {'chain': _chain_opts} }
final_chain = self.get_base_dict() final_chain = self.get_base_dict()
final_chain["nftables"].append(_add) final_chain["nftables"].append(_add)
return final_chain return final_chain
def get_mailcow_jump_rule_dict(self, _family: str, _chain: str): def get_mailcow_jump_rule(self, _family: str, _chain: str):
_jump_rule = self.get_base_dict() _expr_opt = [{'counter': {'family': _family, 'table': 'filter', 'packets': 0, 'bytes': 0}},
_expr_opt=[] {'jump': {'target': self.chain_name}}]
_expr_counter = {'family': _family, 'table': 'filter', 'packets': 0, 'bytes': 0}
_counter_dict = {'counter': _expr_counter}
_expr_opt.append(_counter_dict)
_jump_opts = {'jump': {'target': self.chain_name} }
_expr_opt.append(_jump_opts)
_rule_params = {'family': _family, _rule_params = {'family': _family,
'table': 'filter', 'table': 'filter',
'chain': _chain, 'chain': _chain,
'expr': _expr_opt, 'expr': _expr_opt,
'comment': "mailcow" } 'comment': "mailcow" }
return {'insert': {'rule': _rule_params} }
_add_rule = {'insert': {'rule': _rule_params} } def is_mailcow_jump(self, rule: dict):
# by target, jumps restored by iptables-restore carry no nft comment
return any(isinstance(expr, dict) and isinstance(expr.get('jump'), dict) and expr['jump'].get('target') == self.chain_name
for expr in rule.get('expr', []))
_jump_rule["nftables"].append(_add_rule) def init_chain(self, _family: str):
# Empty MAILCOW chain with one jump at the top of the input and forward chains
if not self.has_filter_chains(_family): return
return _jump_rule if self.list_rules(_family, 'filter', self.chain_name) is None:
if self.nft_exec_dict(self.get_chain_dict(_family, self.chain_name)):
self.logger.logInfo(f"MAILCOW {_family} chain created successfully.")
else:
flush_chain = {'flush': {'chain': {'family': _family, 'table': 'filter', 'name': self.chain_name}}}
command = self.get_base_dict()
command['nftables'].append(flush_chain)
self.nft_exec_dict(command)
def insert_mailcow_chains(self, _family: str): self.ensure_jumps(_family)
nft_input_chain = self.nft_chain_names[_family]['filter']['input']
nft_forward_chain = self.nft_chain_names[_family]['filter']['forward'] def ensure_chains(self):
# Command: 'nft list table <family> filter' # Recreates missing MAILCOW chains and moves the jumps back to the top, True if a chain was recreated
_table_opts = {'family': _family, 'name': 'filter'} self.search_current_chains()
_list = {'list': {'table': _table_opts} } recreated = False
for _family in ['ip', 'ip6']:
if not self.has_filter_chains(_family): continue
if _family not in self.mailcow_chains:
if self.nft_exec_dict(self.get_chain_dict(_family, self.chain_name)):
self.logger.logWarn(f"MAILCOW {_family} chain was missing and has been recreated")
recreated = True
self.ensure_jumps(_family)
return recreated
def ensure_jumps(self, _family: str):
# Exactly one jump to MAILCOW as first rule: docker inserts its jumps (DOCKER-USER, DOCKER-FORWARD) at the top
for hook in ['input', 'forward']:
base_chain = self.nft_chain_names[_family]['filter'][hook]
if not base_chain: continue
rules = self.list_rules(_family, 'filter', base_chain)
if rules is None: continue
positions = [position for position, rule in enumerate(rules) if self.is_mailcow_jump(rule)]
if positions == [0]: continue
# delete and insert in one transaction, the chain is never without jump
command = self.get_base_dict()
for position in positions:
_rule_opts = {'family': _family, 'table': 'filter', 'chain': base_chain, 'handle': rules[position]['handle']}
command['nftables'].append({'delete': {'rule': _rule_opts}})
command['nftables'].append(self.get_mailcow_jump_rule(_family, base_chain))
if self.nft_exec_dict(command):
if positions:
self.logger.logInfo(f"Moved MAILCOW jump to the top of the {_family} {base_chain} chain, it was at position {positions[0] + 1}")
else:
self.logger.logInfo(f"Added MAILCOW jump to the {_family} {base_chain} chain")
def clearTable(self, _family: str):
# Jumps first, the chain can only be deleted without references. Separate steps, so one failing step
# does not roll back the others
_list = {'list': {'table': {'family': _family, 'name': 'filter'}}}
command = self.get_base_dict() command = self.get_base_dict()
command['nftables'].append(_list) command['nftables'].append(_list)
kernel_ruleset = self.nft_exec_dict(command) kernel_ruleset = self.nft_exec_dict(command, quiet=True)
if kernel_ruleset: if not kernel_ruleset or not isinstance(kernel_ruleset, dict):
# chain return
if not self.search_for_chain(kernel_ruleset, self.chain_name):
cadena = self.get_chain_dict(_family, self.chain_name)
if self.nft_exec_dict(cadena):
self.logger.logInfo(f"MAILCOW {_family} chain created successfully.")
input_jump_found, forward_jump_found = False, False delete_jumps = self.get_base_dict()
for _object in kernel_ruleset["nftables"]:
rule = _object.get("rule")
if rule and self.is_mailcow_jump(rule):
_rule_opts = {'family': _family, 'table': 'filter', 'chain': rule['chain'], 'handle': rule['handle']}
delete_jumps['nftables'].append({'delete': {'rule': _rule_opts}})
if len(delete_jumps['nftables']) > 1:
self.nft_exec_dict(delete_jumps)
for _object in kernel_ruleset["nftables"]: if self.list_rules(_family, 'filter', self.chain_name) is None:
if not _object.get("rule"): return
continue mailcow_chain = {'family': _family, 'table': 'filter', 'name': self.chain_name}
command = self.get_base_dict()
rule = _object["rule"] command['nftables'].append({'flush': {'chain': mailcow_chain}})
if nft_input_chain and rule["chain"] == nft_input_chain: command['nftables'].append({'delete': {'chain': mailcow_chain}})
if rule.get("comment") and rule["comment"] == "mailcow": if self.nft_exec_dict(command):
input_jump_found = True self.logger.logInfo(f"Clear completed: {_family}")
if nft_forward_chain and rule["chain"] == nft_forward_chain:
if rule.get("comment") and rule["comment"] == "mailcow":
forward_jump_found = True
if not input_jump_found:
command = self.get_mailcow_jump_rule_dict(_family, nft_input_chain)
self.nft_exec_dict(command)
if not forward_jump_found:
command = self.get_mailcow_jump_rule_dict(_family, nft_forward_chain)
self.nft_exec_dict(command)
def delete_nat_rule(self, _family:str, _chain: str, _handle:str): def delete_nat_rule(self, _family:str, _chain: str, _handle:str):
delete_command = self.get_base_dict() delete_command = self.get_base_dict()
@@ -285,23 +242,14 @@ class NFTables:
# no postrouting chain, may occur if docker has ipv6 disabled. # no postrouting chain, may occur if docker has ipv6 disabled.
if not chain_name: return if not chain_name: return
# Command: nft list chain <family> nat <chain_name> rules = self.list_rules(_family, 'nat', chain_name)
_chain_opts = {'family': _family, 'table': 'nat', 'name': chain_name} if rules is None:
_list = {'list':{'chain': _chain_opts} }
command = self.get_base_dict()
command['nftables'].append(_list)
kernel_ruleset = self.nft_exec_dict(command)
if not kernel_ruleset:
return return
rule_position = 0 rule_position = 0
rule_handle = None rule_handle = None
rule_found = False rule_found = False
for _object in kernel_ruleset["nftables"]: for rule in rules:
if not _object.get("rule"):
continue
rule = _object["rule"]
if not rule.get("comment") or not rule["comment"] == "mailcow": if not rule.get("comment") or not rule["comment"] == "mailcow":
rule_position +=1 rule_position +=1
continue continue
@@ -314,23 +262,28 @@ class NFTables:
target_net = ipaddress.ip_network(snat_target, strict=False) target_net = ipaddress.ip_network(snat_target, strict=False)
if rule_found: if rule_found:
saddr_ip = rule["expr"][0]["match"]["right"]["prefix"]["addr"] try:
saddr_len = int(rule["expr"][0]["match"]["right"]["prefix"]["len"]) saddr_ip = rule["expr"][0]["match"]["right"]["prefix"]["addr"]
saddr_len = int(rule["expr"][0]["match"]["right"]["prefix"]["len"])
daddr_ip = rule["expr"][1]["match"]["right"]["prefix"]["addr"] daddr_ip = rule["expr"][1]["match"]["right"]["prefix"]["addr"]
daddr_len = int(rule["expr"][1]["match"]["right"]["prefix"]["len"]) daddr_len = int(rule["expr"][1]["match"]["right"]["prefix"]["len"])
target_ip = rule["expr"][3]["snat"]["addr"] target_ip = rule["expr"][3]["snat"]["addr"]
saddr_net = ipaddress.ip_network(saddr_ip + '/' + str(saddr_len), strict=False) saddr_net = ipaddress.ip_network(saddr_ip + '/' + str(saddr_len), strict=False)
daddr_net = ipaddress.ip_network(daddr_ip + '/' + str(daddr_len), strict=False) daddr_net = ipaddress.ip_network(daddr_ip + '/' + str(daddr_len), strict=False)
current_target_net = ipaddress.ip_network(target_ip, strict=False) current_target_net = ipaddress.ip_network(target_ip, strict=False)
match = all(( match = all((
dest_net == saddr_net, dest_net == saddr_net,
dest_net == daddr_net, dest_net == daddr_net,
target_net == current_target_net target_net == current_target_net
)) ))
except (KeyError, IndexError, TypeError, ValueError):
# rule of another format, replace it
saddr_net = dest_net
match = False
try: try:
if rule_position == 0: if rule_position == 0:
if not match: if not match:
@@ -377,49 +330,13 @@ class NFTables:
except: except:
self.logger.logCrit(f"Error running SNAT on {_family}, retrying...") self.logger.logCrit(f"Error running SNAT on {_family}, retrying...")
def get_chain_handle(self, _family: str, _table: str, chain_name: str):
chain_handle = None
# Command: 'nft list chains {family}'
_list = {'list': {'chains': {'family': _family} } }
command = self.get_base_dict()
command['nftables'].append(_list)
kernel_ruleset = self.nft_exec_dict(command)
if kernel_ruleset:
for _object in kernel_ruleset["nftables"]:
if not _object.get("chain"):
continue
chain = _object["chain"]
if chain["family"] == _family and chain["table"] == _table and chain["name"] == chain_name:
chain_handle = chain["handle"]
break
return chain_handle
def get_rules_handle(self, _family: str, _table: str, chain_name: str, _comment_filter = "mailcow"): def get_rules_handle(self, _family: str, _table: str, chain_name: str, _comment_filter = "mailcow"):
rule_handle = [] rules = self.list_rules(_family, _table, chain_name) or []
# Command: 'nft list chain {family} {table} {chain_name}' return [rule["handle"] for rule in rules if rule.get("comment") == _comment_filter]
_chain_opts = {'family': _family, 'table': _table, 'name': chain_name}
_list = {'list': {'chain': _chain_opts} }
command = self.get_base_dict()
command['nftables'].append(_list)
kernel_ruleset = self.nft_exec_dict(command)
if kernel_ruleset:
for _object in kernel_ruleset["nftables"]:
if not _object.get("rule"):
continue
rule = _object["rule"]
if rule["family"] == _family and rule["table"] == _table and rule["chain"] == chain_name:
if rule.get("comment") and rule["comment"] == _comment_filter:
rule_handle.append(rule["handle"])
return rule_handle
def get_ban_ip_dict(self, ipaddr: str, _family: str):
json_command = self.get_base_dict()
def get_ban_rule(self, network, _family: str):
expr_opt = [] expr_opt = []
ipaddr_net = ipaddress.ip_network(ipaddr, strict=False) right_dict = {'prefix': {'addr': str(network.network_address), 'len': int(network.prefixlen) } }
right_dict = {'prefix': {'addr': str(ipaddr_net.network_address), 'len': int(ipaddr_net.prefixlen) } }
left_dict = {'payload': {'protocol': _family, 'field': 'saddr'} } left_dict = {'payload': {'protocol': _family, 'field': 'saddr'} }
match_dict = {'op': '==', 'left': left_dict, 'right': right_dict } match_dict = {'op': '==', 'left': left_dict, 'right': right_dict }
@@ -432,228 +349,115 @@ class NFTables:
rule_dict = {'family': _family, 'table': "filter", 'chain': self.chain_name, 'expr': expr_opt} rule_dict = {'family': _family, 'table': "filter", 'chain': self.chain_name, 'expr': expr_opt}
base_dict = {'insert': {'rule': rule_dict} } return {'insert': {'rule': rule_dict} }
json_command["nftables"].append(base_dict)
return json_command def ban_rule_network(self, rule: dict, _family: str):
# Source network of a ban rule (saddr match and drop), None for every other rule
if rule.get("comment") in self.ISOLATION_COMMENTS:
return None
exprs = [expr for expr in rule.get("expr", []) if isinstance(expr, dict)]
if not any('drop' in expr or 'reject' in expr for expr in exprs):
return None
for expr in exprs:
match = expr.get("match")
if not isinstance(match, dict) or match.get("op") != "==":
continue
left = match.get("left")
payload = left.get("payload") if isinstance(left, dict) else None
if not isinstance(payload, dict) or payload.get("protocol") != _family or payload.get("field") != "saddr":
continue
right = match.get("right")
try:
if isinstance(right, dict) and isinstance(right.get("prefix"), dict):
return ipaddress.ip_network(f'{right["prefix"]["addr"]}/{right["prefix"]["len"]}', strict=False)
if isinstance(right, str):
return ipaddress.ip_network(right, strict=False)
except (KeyError, ValueError):
return None
return None
def get_unban_ip_dict(self, ipaddr:str, _family: str): def split_families(self, sources):
json_command = self.get_base_dict() families = {}
# Command: 'nft list chain {s_family} filter MAILCOW' for source in sources:
_chain_opts = {'family': _family, 'table': 'filter', 'name': self.chain_name} network = ipaddress.ip_network(source, strict=False)
_list = {'list': {'chain': _chain_opts} } families.setdefault('ip' if network.version == 4 else 'ip6', []).append(network)
command = self.get_base_dict() return families
command['nftables'].append(_list)
kernel_ruleset = self.nft_exec_dict(command)
rule_handle = None
if kernel_ruleset:
for _object in kernel_ruleset["nftables"]:
if not _object.get("rule"):
continue
rule = _object["rule"]["expr"][0]["match"] def ban(self, sources):
if not "payload" in rule["left"]: # One drop rule per network, existing rules are kept. Returns the networks that could not be banned
continue failed = []
left_opt = rule["left"]["payload"] for _family, networks in self.split_families(sources).items():
if not left_opt["protocol"] == _family: rules = self.list_rules(_family, 'filter', self.chain_name)
continue if rules is None:
if not left_opt["field"] =="saddr": failed += networks
continue continue
existing = {self.ban_rule_network(rule, _family) for rule in rules}
missing = [network for network in dict.fromkeys(networks) if network not in existing]
if not missing: continue
# ip currently banned command = self.get_base_dict()
rule_right = rule["right"] for network in missing:
if isinstance(rule_right, dict): command['nftables'].append(self.get_ban_rule(network, _family))
current_rule_ip = rule_right["prefix"]["addr"] + '/' + str(rule_right["prefix"]["len"]) if not self.nft_exec_dict(command):
else: failed += missing
current_rule_ip = rule_right return [str(network) for network in failed]
current_rule_net = ipaddress.ip_network(current_rule_ip)
# ip to ban def unban(self, sources):
candidate_net = ipaddress.ip_network(ipaddr, strict=False) # Removes every rule of the networks, also duplicates of older versions. Returns the unbanned networks
removed = []
for _family, networks in self.split_families(sources).items():
rules = self.list_rules(_family, 'filter', self.chain_name)
if not rules: continue
if current_rule_net == candidate_net: targets = set(networks)
rule_handle = _object["rule"]["handle"] command = self.get_base_dict()
break found = set()
for rule in rules:
if rule_handle is not None: network = self.ban_rule_network(rule, _family)
mailcow_rule = {'family': _family, 'table': 'filter', 'chain': self.chain_name, 'handle': rule_handle} if network in targets:
delete_rule = {'delete': {'rule': mailcow_rule} } _rule_opts = {'family': _family, 'table': 'filter', 'chain': self.chain_name, 'handle': rule['handle']}
json_command["nftables"].append(delete_rule) command['nftables'].append({'delete': {'rule': _rule_opts}})
else: found.add(network)
return False if found and self.nft_exec_dict(command):
removed += found
return json_command return [str(network) for network in removed]
def check_mailcow_chains(self, family: str, chain: str):
position = 0
rule_found = False
chain_name = self.nft_chain_names[family]['filter'][chain]
if not chain_name: return None
_chain_opts = {'family': family, 'table': 'filter', 'name': chain_name}
_list = {'list': {'chain': _chain_opts}}
command = self.get_base_dict()
command['nftables'].append(_list)
kernel_ruleset = self.nft_exec_dict(command)
if kernel_ruleset:
for _object in kernel_ruleset["nftables"]:
if not _object.get("rule"):
continue
rule = _object["rule"]
if rule.get("comment") and rule["comment"] == "mailcow":
rule_found = True
break
position+=1
return position if rule_found else False
def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""): def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
family = "ip" # One rule per port: iptables-nft cannot show an anonymous set, a host that saves and restores the ruleset
table = "filter" # with iptables-save (e.g. netfilter-persistent) would get a drop for all ports
comment_filter_drop = "mailcow isolation" for comment in self.ISOLATION_COMMENTS:
comment_filter_allow = "mailcow isolation allow" for handle in self.get_rules_handle("ip", "filter", self.chain_name, comment):
json_command = self.get_base_dict() self.delete_filter_rule("ip", self.chain_name, handle)
# Delete old mailcow isolation rules if not self.nft_exec_dict(self.get_isolation_dict(_interface, _dports, self.ISOLATION_COMMENTS[0], {"drop": None})):
handles = self.get_rules_handle(family, table, self.chain_name, comment_filter_drop)
for handle in handles:
self.delete_filter_rule(family, self.chain_name, handle)
handles = self.get_rules_handle(family, table, self.chain_name, comment_filter_allow)
for handle in handles:
self.delete_filter_rule(family, self.chain_name, handle)
# insert mailcow isolation rule
_match_dict_drop = [
{
"match": {
"op": "!=",
"left": {
"meta": {
"key": "iifname"
}
},
"right": _interface
}
},
{
"match": {
"op": "==",
"left": {
"meta": {
"key": "oifname"
}
},
"right": _interface
}
},
{
"match": {
"op": "==",
"left": {
"payload": {
"protocol": "tcp",
"field": "dport"
}
},
"right": {
"set": _dports
}
}
},
{
"counter": {
"packets": 0,
"bytes": 0
}
},
{
"drop": None
}
]
rule_drop = { "insert": { "rule": {
"family": family,
"table": table,
"chain": self.chain_name,
"comment": comment_filter_drop,
"expr": _match_dict_drop
}}}
json_command["nftables"].append(rule_drop)
# insert mailcow isolation allow rule
if _allow != "":
_match_dict_allow = [
{
"match": {
"op": "==",
"left": {
"payload": {
"protocol": "ip",
"field": "saddr"
}
},
"right": _allow
}
},
{
"match": {
"op": "!=",
"left": {
"meta": {
"key": "iifname"
}
},
"right": _interface
}
},
{
"match": {
"op": "==",
"left": {
"meta": {
"key": "oifname"
}
},
"right": _interface
}
},
{
"match": {
"op": "==",
"left": {
"payload": {
"protocol": "tcp",
"field": "dport"
}
},
"right": {
"set": _dports
}
}
},
{
"counter": {
"packets": 0,
"bytes": 0
}
},
{
"accept": None
}
]
rule_allow = { "insert": { "rule": {
"family": family,
"table": table,
"chain": self.chain_name,
"comment": comment_filter_allow,
"expr": _match_dict_allow
}}}
json_command["nftables"].append(rule_allow)
success = self.nft_exec_dict(json_command)
if success == False:
self.logger.logCrit(f"Error adding {self.chain_name} isolation") self.logger.logCrit(f"Error adding {self.chain_name} isolation")
return False return False
return True # separate, an invalid address must not prevent the drop rules. Inserted last, so they are above them
if _allow and not self.nft_exec_dict(self.get_isolation_dict(_interface, _dports, self.ISOLATION_COMMENTS[1], {"accept": None}, _allow)):
self.logger.logCrit(f"Error adding {self.chain_name} isolation exception for {_allow}")
return False
return True
def get_isolation_dict(self, _interface:str, _dports:list, _comment:str, _verdict:dict, _saddr:str = ""):
json_command = self.get_base_dict()
for port in _dports:
expr = []
if _saddr:
expr.append({"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": _saddr}})
expr += [
{"match": {"op": "!=", "left": {"meta": {"key": "iifname"}}, "right": _interface}},
{"match": {"op": "==", "left": {"meta": {"key": "oifname"}}, "right": _interface}},
{"match": {"op": "==", "left": {"payload": {"protocol": "tcp", "field": "dport"}}, "right": int(port)}},
{"counter": {"packets": 0, "bytes": 0}},
_verdict
]
json_command["nftables"].append({"insert": {"rule": {
"family": "ip",
"table": "filter",
"chain": self.chain_name,
"comment": _comment,
"expr": expr
}}})
return json_command
@@ -0,0 +1,200 @@
import ipaddress
import threading
import dns.exception
import dns.resolver
from modules.BanManager import BanManager
from modules.Options import Options
from modules.Regexes import Regexes
class Netfilter:
"""Runs the workers of netfilter. Every worker is a loop that logs errors and goes on, so neither a broken log
line nor a Redis restart can stop the expiry of bans."""
ISOLATION_INTERFACE = 'br-mailcow'
ISOLATION_PORTS = [3306, 6379, 8983, 12345]
def __init__(self, r, tables, logger, env):
self.r = r
self.tables = tables
self.logger = logger
self.env = env
# reentrant, bans.reapply() runs within the chain check
self.lock = threading.RLock()
self.stop = threading.Event()
self.options = Options(logger)
self.regexes = Regexes(logger)
self.bans = BanManager(r, tables, self.options, logger, self.lock)
self.snat4_target = self.snat_target('SNAT_TO_SOURCE', 4)
self.snat6_target = self.snat_target('SNAT6_TO_SOURCE', 6)
self.resolved = {}
self.workers = {
'watch': (self.watch, 3),
'purge': (self.purge, 10),
'chains': (self.check_chains, 10),
'lists': (self.update_lists, 60),
}
if self.snat4_target or self.snat6_target:
self.workers['snat'] = (self.snat, 10)
self.threads = {}
def setup(self):
if self.r.exists('F2B_LOG'):
self.r.rename('F2B_LOG', 'NETFILTER_LOG')
# filled again by the lists worker
self.r.delete('F2B_PERM_BANS')
self.options.refresh(self.r)
self.regexes.refresh(self.r)
with self.lock:
self.tables.clearIPv4Table()
self.tables.clearIPv6Table()
self.logger.logInfo('Initializing mailcow netfilter chain')
self.tables.initChainIPv4()
self.tables.initChainIPv6()
self.isolate()
self.bans.restore()
def run(self):
for name in self.workers:
self.start(name)
while not self.stop.wait(5):
for name, thread in list(self.threads.items()):
if not thread.is_alive():
self.logger.logCrit('Worker %s stopped unexpectedly, restarting it' % name)
self.start(name)
def shutdown(self):
"""Removes the firewall rules, the active bans stay in Redis and are restored on the next start"""
self.stop.set()
for thread in self.threads.values():
thread.join(timeout=5)
with self.lock:
self.logger.logInfo('Clearing all bans')
self.tables.clearIPv4Table()
self.tables.clearIPv6Table()
try:
self.r.delete('F2B_PERM_BANS')
except Exception as ex:
self.logger.logWarn('Error clearing redis key F2B_PERM_BANS: %s' % ex)
def start(self, name):
task, interval = self.workers[name]
thread = threading.Thread(target=self.loop, args=(name, task, interval), name=name, daemon=True)
self.threads[name] = thread
thread.start()
def loop(self, name, task, interval):
while not self.stop.is_set():
try:
task()
except Exception as ex:
self.logger.logWarn('Error in worker %s: %s' % (name, ex))
self.stop.wait(interval)
def watch(self):
pubsub = self.r.pubsub(ignore_subscribe_messages=True)
try:
pubsub.subscribe('F2B_CHANNEL')
self.logger.logInfo('Watching Redis channel F2B_CHANNEL')
while not self.stop.is_set():
message = pubsub.get_message(timeout=1.0)
if message and message['type'] == 'message' and message['data']:
self.handle_line(message['data'])
finally:
try:
pubsub.close()
except Exception:
pass
def handle_line(self, line):
# a broken line must not end the watcher
try:
excused = Regexes.temporary_failure(line)
if excused is not None:
ip = Regexes.parse_ip(excused)
if self.external(ip):
self.bans.excuse(ip)
return
result = self.regexes.match(line)
if result is None:
return
rule_id, address = result
ip = Regexes.parse_ip(address)
if ip is None:
self.logger.logWarn('Rule id %s matched an invalid address %s (%s)' % (rule_id, address, line))
return
if not self.external(ip):
return
self.logger.logWarn('%s matched rule id %s (%s)' % (ip, rule_id, line))
self.bans.attempt(ip)
except Exception as ex:
self.logger.logWarn('Error processing log line %r: %s' % (line, ex))
@staticmethod
def external(ip):
# private and loopback addresses are never banned
return ip is not None and not (ip.is_private or ip.is_loopback)
def purge(self):
self.options.refresh(self.r)
self.regexes.refresh(self.r)
self.bans.purge()
def check_chains(self):
with self.lock:
if self.tables.ensure_chains():
self.isolate()
self.bans.reapply()
def snat(self):
with self.lock:
if self.snat4_target:
self.tables.snat4(self.snat4_target, self.env.get('IPV4_NETWORK', '172.22.1') + '.0/24')
if self.snat6_target:
self.tables.snat6(self.snat6_target, self.env.get('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'))
def update_lists(self):
self.bans.set_allowlist(self.resolve(self.r.hkeys('F2B_WHITELIST')))
self.bans.set_denylist(self.resolve(self.r.hkeys('F2B_BLACKLIST')))
def isolate(self):
if self.env.get('DISABLE_NETFILTER_ISOLATION_RULE', '').lower() in ('y', 'yes'):
self.logger.logInfo('Skipping MAILCOW isolation')
return
self.logger.logInfo('Setting MAILCOW isolation')
self.tables.create_mailcow_isolation_rule(self.ISOLATION_INTERFACE, self.ISOLATION_PORTS, self.env.get('MAILCOW_REPLICA_IP', ''))
def resolve(self, entries):
"""Networks of the entries; hostnames are resolved, on DNS errors to their last known addresses"""
resolver = dns.resolver.Resolver()
networks = set()
for entry in entries:
if BanManager.network(entry) is not None:
networks.add(entry)
continue
addresses = set()
for rdtype in ['A', 'AAAA']:
try:
answer = resolver.resolve(qname=entry, rdtype=rdtype, lifetime=3)
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
continue
except dns.exception.DNSException as ex:
self.logger.logInfo('Could not resolve %s: %s' % (entry, ex))
addresses = self.resolved.get(entry, set())
break
addresses.update(rdata.to_text() for rdata in answer)
self.resolved[entry] = addresses
networks |= addresses
return networks
def snat_target(self, name, version):
value = self.env.get(name, '')
if not value or value == 'n':
return None
try:
if ipaddress.ip_address(value).version == version:
return value
except ValueError:
pass
self.logger.logWarn('%s is not a valid IPv%d address' % (value, version))
return None
@@ -0,0 +1,120 @@
import json
import uuid
class Options:
"""Fail2ban options of the mailcow UI (Redis F2B_OPTIONS). The values are replaced as a whole and never changed
in place, so a thread that took them (options.values) keeps a consistent set."""
DEFAULTS = {
'ban_time': 1800,
'max_ban_time': 10000,
'ban_time_increment': True,
'max_attempts': 10,
'retry_window': 600,
'netban_ipv4': 32,
'netban_ipv6': 128,
'manage_external': 0,
}
# Single keys of old installations, read while F2B_OPTIONS does not exist
LEGACY_KEYS = {
'ban_time': 'F2B_BAN_TIME',
'max_ban_time': 'F2B_MAX_BAN_TIME',
'ban_time_increment': 'F2B_BAN_TIME_INCREMENT',
'max_attempts': 'F2B_MAX_ATTEMPTS',
'retry_window': 'F2B_RETRY_WINDOW',
'netban_ipv4': 'F2B_NETBAN_IPV4',
'netban_ipv6': 'F2B_NETBAN_IPV6',
}
def __init__(self, logger):
self.logger = logger
self.values = self.normalize({})
def refresh(self, r):
"""Reads the options of the UI; options it lacks are added to Redis once, existing ones are never overwritten"""
raw = r.get('F2B_OPTIONS')
if raw:
try:
stored = json.loads(raw)
except ValueError as ex:
self.logger.logWarn('F2B_OPTIONS is no valid JSON, keeping the current options: %s' % ex)
return
if not isinstance(stored, dict):
self.logger.logWarn('F2B_OPTIONS is no JSON object, keeping the current options')
return
else:
stored = {key: r.get(name) for key, name in self.LEGACY_KEYS.items()}
stored = {key: value for key, value in stored.items() if value is not None}
values = self.normalize(stored)
self.values = values
# First start or old installation: store the complete set, else only what the UI has not saved yet
missing = {key: value for key, value in values.items() if not raw or stored.get(key) is None}
if missing:
self.add_missing(r, missing)
def ban_time(self, ban_counter):
"""Seconds of the next ban of a network that was banned ban_counter times before"""
values = self.values
ban_time = values['ban_time']
if values['ban_time_increment']:
# the exponent is capped, max_ban_time limits the result anyway
ban_time = ban_time * 2 ** min(ban_counter, 32)
return max(values['ban_time'], min(ban_time, values['max_ban_time']))
@classmethod
def normalize(cls, stored):
values = dict(stored)
for key, default in cls.DEFAULTS.items():
if isinstance(default, bool):
values[key] = cls.to_bool(stored.get(key), default)
else:
values[key] = cls.to_int(stored.get(key), default)
values['ban_time'] = max(1, values['ban_time'])
values['max_ban_time'] = max(1, values['max_ban_time'])
values['max_attempts'] = max(1, values['max_attempts'])
values['retry_window'] = max(1, values['retry_window'])
# A short prefix would ban whole regions or everything (/0)
values['netban_ipv4'] = min(32, max(8, values['netban_ipv4']))
values['netban_ipv6'] = min(128, max(8, values['netban_ipv6']))
values['manage_external'] = 1 if values['manage_external'] == 1 else 0
if not values.get('banlist_id'):
values['banlist_id'] = str(uuid.uuid4())
return values
@staticmethod
def to_int(value, default):
try:
return int(value)
except (TypeError, ValueError):
return default
@staticmethod
def to_bool(value, default):
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in ('1', 'true', 'yes', 'on')
return default
def add_missing(self, r, missing):
# WATCH keeps a save of the UI between reading and writing
def fill(pipe):
raw = pipe.get('F2B_OPTIONS')
current = json.loads(raw) if raw else {}
if not isinstance(current, dict):
return
added = {key: value for key, value in missing.items() if current.get(key) is None}
if not added:
return
current.update(added)
pipe.multi()
pipe.set('F2B_OPTIONS', json.dumps(current, ensure_ascii=False))
try:
r.transaction(fill, 'F2B_OPTIONS')
except Exception as ex:
self.logger.logWarn('Could not add default options to F2B_OPTIONS: %s' % ex)
@@ -0,0 +1,88 @@
import ipaddress
import json
import re
class Regexes:
"""Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address"""
DEFAULTS = {
1: r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)',
2: r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)',
3: r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+',
4: r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+',
5: r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
6: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)',
7: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)',
8: r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
9: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
}
# Dovecot could not ask the auth backend (identity provider down, mailcowauth.php unreachable). Postfix logs
# such a temporary failure like a wrong password, so failed logins of the address are not counted for a while.
TEMPORARY_FAILURE = re.compile(r'lua\([^,]+,([0-9a-f\.:]+)(?:,<[^>]+>)?\): passdb-lua: Upstream (?:unavailable|unreachable)')
def __init__(self, logger):
self.logger = logger
self.rules = []
self.source = None
def refresh(self, r):
"""Compiles the rules of the UI again when they changed; invalid rules are skipped"""
raw = r.get('F2B_REGEX')
if not raw:
raw = json.dumps(self.DEFAULTS, ensure_ascii=False)
r.set('F2B_REGEX', raw, nx=True)
if raw == self.source:
return
try:
stored = json.loads(raw)
except ValueError as ex:
self.logger.logWarn('F2B_REGEX is no valid JSON, keeping the current rules: %s' % ex)
return
if not isinstance(stored, dict):
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
return
rules = []
for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])):
try:
rules.append((str(rule_id), re.compile(regex)))
except (re.error, TypeError) as ex:
self.logger.logWarn('Skipping regex of rule id %s: %s' % (rule_id, ex))
self.rules = rules
self.source = raw
def match(self, line):
"""(rule id, captured address) of the first rule that matches the line, None if no rule matches"""
for rule_id, regex in self.rules:
result = regex.search(line)
if result and result.lastindex and result.group(1):
return rule_id, result.group(1)
return None
@classmethod
def temporary_failure(cls, line):
"""Address of a login that failed because the auth backend was unavailable, None for other lines"""
result = cls.TEMPORARY_FAILURE.search(line)
return result.group(1) if result else None
@staticmethod
def parse_ip(text):
"""IP address of a captured address, IPv4-mapped IPv6 as IPv4; a trailing colon (e.g. "1.2.3.4:") is
tolerated. None if the text is no address."""
for candidate in (text, text.rstrip(':')):
try:
ip = ipaddress.ip_address(candidate)
except ValueError:
continue
if ip.version == 6 and ip.ipv4_mapped:
return ip.ipv4_mapped
return ip
return None
@staticmethod
def sort_key(rule_id):
try:
return (0, int(rule_id))
except (TypeError, ValueError):
return (1, str(rule_id))
+1 -1
View File
@@ -1,4 +1,4 @@
FROM nginx:alpine FROM nginx:1.30.6-alpine
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
ENV PIP_BREAK_SYSTEM_PACKAGES=1 ENV PIP_BREAK_SYSTEM_PACKAGES=1
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21 FROM alpine:3.24
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
+4 -3
View File
@@ -67,7 +67,7 @@ logger.debug('olefy listen address string: {} (type {})'.format(olefy_listen_add
if not olefy_listen_addr_string: if not olefy_listen_addr_string:
olefy_listen_addr = "" olefy_listen_addr = ""
else: else:
addr_re = re.compile('[\[" \]]') addr_re = re.compile(r'[\[" \]]')
olefy_listen_addr = addr_re.sub('', olefy_listen_addr_string.replace("'", "")).split(',') olefy_listen_addr = addr_re.sub('', olefy_listen_addr_string.replace("'", "")).split(',')
# log runtime variables # log runtime variables
@@ -206,8 +206,9 @@ class AIO(asyncio.Protocol):
self.transport.close() self.transport.close()
# start the listeners # start the listeners, Python >= 3.14 no longer creates a loop in get_event_loop()
loop = asyncio.get_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# each client connection will create a new protocol instance # each client connection will create a new protocol instance
coro = loop.create_server(AIO, olefy_listen_addr, olefy_listen_port) coro = loop.create_server(AIO, olefy_listen_addr, olefy_listen_port)
server = loop.run_until_complete(coro) server = loop.run_until_complete(coro)
+19 -14
View File
@@ -1,19 +1,19 @@
FROM php:8.2-fpm-alpine3.21 FROM php:8.2-fpm-alpine3.24
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
# renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$ # renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG APCU_PECL_VERSION=5.1.27 ARG APCU_PECL_VERSION=5.1.28
# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$ # 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>.*)$ # renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG MAILPARSE_PECL_VERSION=3.1.9 ARG MAILPARSE_PECL_VERSION=3.2.0
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$ # renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
ARG MEMCACHED_PECL_VERSION=3.3.0 ARG MEMCACHED_PECL_VERSION=3.4.0
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$ # renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
ARG REDIS_PECL_VERSION=6.2.0 ARG REDIS_PECL_VERSION=6.3.0
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$ # renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
ARG COMPOSER_VERSION=2.8.6 ARG COMPOSER_VERSION=2.10.2
RUN apk add -U --no-cache autoconf \ RUN apk add -U --no-cache autoconf \
aspell-dev \ aspell-dev \
@@ -47,6 +47,7 @@ RUN apk add -U --no-cache autoconf \
libressl-dev \ libressl-dev \
librsvg \ librsvg \
libtool \ libtool \
libwebp \
libwebp-dev \ libwebp-dev \
libxml2-dev \ libxml2-dev \
libxpm \ libxpm \
@@ -63,13 +64,16 @@ RUN apk add -U --no-cache autoconf \
samba-client \ samba-client \
zlib-dev \ zlib-dev \
tzdata \ tzdata \
&& pecl install APCu-${APCU_PECL_VERSION} \ && docker-php-source extract \
&& pecl install imagick-${IMAGICK_PECL_VERSION} \ # PECL extensions from their GitHub tags (pecl.php.net is not reliable enough for builds)
&& pecl install mailparse-${MAILPARSE_PECL_VERSION} \ && mkdir -p /usr/src/php/ext/apcu /usr/src/php/ext/imagick /usr/src/php/ext/mailparse /usr/src/php/ext/memcached /usr/src/php/ext/redis \
&& pecl install memcached-${MEMCACHED_PECL_VERSION} \ && curl -fsSL https://github.com/krakjoe/apcu/archive/refs/tags/v${APCU_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/apcu \
&& pecl install redis-${REDIS_PECL_VERSION} \ && curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/${IMAGICK_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/imagick \
&& docker-php-ext-enable apcu imagick memcached mailparse redis \ && sed -i "s/@PACKAGE_VERSION@/${IMAGICK_PECL_VERSION}/" /usr/src/php/ext/imagick/php_imagick.h \
&& pecl clear-cache \ && curl -fsSL https://github.com/php/pecl-mail-mailparse/archive/refs/tags/v${MAILPARSE_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/mailparse \
&& curl -fsSL https://github.com/php-memcached-dev/php-memcached/archive/refs/tags/v${MEMCACHED_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/memcached \
&& curl -fsSL https://github.com/phpredis/phpredis/archive/refs/tags/${REDIS_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/redis \
&& docker-php-ext-install -j 4 apcu imagick mailparse memcached redis \
&& docker-php-ext-configure intl \ && docker-php-ext-configure intl \
&& docker-php-ext-configure exif \ && docker-php-ext-configure exif \
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \ && docker-php-ext-configure gd --with-freetype=/usr/include/ \
@@ -83,6 +87,7 @@ RUN apk add -U --no-cache autoconf \
&& curl --silent --show-error https://getcomposer.org/installer | php -- --version=${COMPOSER_VERSION} \ && curl --silent --show-error https://getcomposer.org/installer | php -- --version=${COMPOSER_VERSION} \
&& mv composer.phar /usr/local/bin/composer \ && mv composer.phar /usr/local/bin/composer \
&& chmod +x /usr/local/bin/composer \ && chmod +x /usr/local/bin/composer \
&& docker-php-source delete \
&& apk del --purge autoconf \ && apk del --purge autoconf \
aspell-dev \ aspell-dev \
cyrus-sasl-dev \ cyrus-sasl-dev \
@@ -1,7 +1,5 @@
#!/bin/bash #!/bin/bash
function array_by_comma { local IFS=","; echo "$*"; }
# Wait for containers # Wait for containers
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
echo "Waiting for SQL..." echo "Waiting for SQL..."
@@ -132,33 +130,6 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
done done
fi fi
# Set API options if env vars are not empty
if [[ ${API_ALLOW_FROM} != "invalid" ]] && [[ ! -z ${API_ALLOW_FROM} ]]; then
IFS=',' read -r -a API_ALLOW_FROM_ARR <<< "${API_ALLOW_FROM}"
declare -a VALIDATED_API_ALLOW_FROM_ARR
REGEX_IP6='^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$'
REGEX_IP4='^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/([0-9]|[1-2][0-9]|3[0-2]))?$'
for IP in "${API_ALLOW_FROM_ARR[@]}"; do
if [[ ${IP} =~ ${REGEX_IP6} ]] || [[ ${IP} =~ ${REGEX_IP4} ]]; then
VALIDATED_API_ALLOW_FROM_ARR+=("${IP}")
fi
done
VALIDATED_IPS=$(array_by_comma ${VALIDATED_API_ALLOW_FROM_ARR[*]})
if [[ ! -z ${VALIDATED_IPS} ]]; then
if [[ ${API_KEY} != "invalid" ]] && [[ ! -z ${API_KEY} ]]; then
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
DELETE FROM api WHERE access = 'rw';
INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY}", "1", "${VALIDATED_IPS}", "rw");
EOF
fi
if [[ ${API_KEY_READ_ONLY} != "invalid" ]] && [[ ! -z ${API_KEY_READ_ONLY} ]]; then
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
DELETE FROM api WHERE access = 'ro';
INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY_READ_ONLY}", "1", "${VALIDATED_IPS}", "ro");
EOF
fi
fi
fi
# Create events (master only, STATUS for event on slave will be SLAVESIDE_DISABLED) # Create events (master only, STATUS for event on slave will be SLAVESIDE_DISABLED)
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
+3 -3
View File
@@ -1,17 +1,17 @@
FROM golang:1.25-bookworm AS builder FROM golang:1.26-trixie AS builder
WORKDIR /src WORKDIR /src
ENV CGO_ENABLED=0 \ ENV CGO_ENABLED=0 \
GO111MODULE=on \ GO111MODULE=on \
NOOPT=1 \ NOOPT=1 \
VERSION=1.8.22 VERSION=1.14.0
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \ RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
cd /src/postfix-tlspol && \ cd /src/postfix-tlspol && \
scripts/build.sh build-only scripts/build.sh build-only
FROM debian:bookworm-slim FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -1,4 +1,4 @@
@version: 3.38 @version: 4.8
@include "scl.conf" @include "scl.conf"
options { options {
chain_hostnames(off); chain_hostnames(off);
@@ -7,7 +7,7 @@ options {
dns_cache(no); dns_cache(no);
use_fqdn(no); use_fqdn(no);
owner("root"); group("adm"); perm(0640); owner("root"); group("adm"); perm(0640);
stats_freq(0); stats(freq(0));
bad_hostname("^gconfd$"); bad_hostname("^gconfd$");
}; };
source s_src { source s_src {
@@ -1,4 +1,4 @@
@version: 3.38 @version: 4.8
@include "scl.conf" @include "scl.conf"
options { options {
chain_hostnames(off); chain_hostnames(off);
@@ -7,7 +7,7 @@ options {
dns_cache(no); dns_cache(no);
use_fqdn(no); use_fqdn(no);
owner("root"); group("adm"); perm(0640); owner("root"); group("adm"); perm(0640);
stats_freq(0); stats(freq(0));
bad_hostname("^gconfd$"); bad_hostname("^gconfd$");
}; };
source s_src { source s_src {
+1 -1
View File
@@ -1,4 +1,4 @@
FROM debian:bookworm-slim FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
+3
View File
@@ -329,14 +329,17 @@ query = SELECT goto FROM alias
SELECT id FROM alias SELECT id FROM alias
WHERE address='%s' WHERE address='%s'
AND (active='1' OR active='2') AND (active='1' OR active='2')
AND sender_allowed='1'
), ( ), (
SELECT id FROM alias SELECT id FROM alias
WHERE address='@%d' WHERE address='@%d'
AND (active='1' OR active='2') AND (active='1' OR active='2')
AND sender_allowed='1'
) )
) )
) )
AND active='1' AND active='1'
AND sender_allowed='1'
AND (domain IN AND (domain IN
(SELECT domain FROM domain (SELECT domain FROM domain
WHERE domain='%d' WHERE domain='%d'
@@ -1,4 +1,4 @@
@version: 3.38 @version: 4.8
@include "scl.conf" @include "scl.conf"
options { options {
chain_hostnames(off); chain_hostnames(off);
@@ -7,7 +7,7 @@ options {
dns_cache(no); dns_cache(no);
use_fqdn(no); use_fqdn(no);
owner("root"); group("adm"); perm(0640); owner("root"); group("adm"); perm(0640);
stats_freq(0); stats(freq(0));
bad_hostname("^gconfd$"); bad_hostname("^gconfd$");
}; };
source s_src { source s_src {
+2 -2
View File
@@ -1,4 +1,4 @@
@version: 3.38 @version: 4.8
@include "scl.conf" @include "scl.conf"
options { options {
chain_hostnames(off); chain_hostnames(off);
@@ -7,7 +7,7 @@ options {
dns_cache(no); dns_cache(no);
use_fqdn(no); use_fqdn(no);
owner("root"); group("adm"); perm(0640); owner("root"); group("adm"); perm(0640);
stats_freq(0); stats(freq(0));
bad_hostname("^gconfd$"); bad_hostname("^gconfd$");
}; };
source s_src { source s_src {
+1 -1
View File
@@ -2,7 +2,7 @@ FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG RSPAMD_VER=rspamd_3.14.1-1~46a758617 ARG RSPAMD_VER=rspamd_4.2.3-1~50bde75
ARG CODENAME=trixie ARG CODENAME=trixie
ENV LC_ALL=C ENV LC_ALL=C
+151 -31
View File
@@ -1,47 +1,167 @@
FROM debian:bookworm-slim # SOGo built from source to enable security patch application
# Repository: https://github.com/Alinto/sogo
# Version: SOGo-5.12.12
#
# Applied security patches:
# -
#
# To add new patches, modify SOGO_SECURITY_PATCHES ARG below with space-separated commit hashes
FROM debian:bookworm
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
ARG DEBIAN_VERSION=bookworm ARG SOGO_VERSION=SOGo-5.12.12
ARG SOGO_DEBIAN_REPOSITORY=https://packagingv2.sogo.nu/sogo-nightly-debian/ ARG SOPE_VERSION=SOPE-5.12.12
# Security patches to apply (space-separated commit hashes)
ARG SOGO_SECURITY_PATCHES=""
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$ # renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
ARG GOSU_VERSION=1.17 ARG GOSU_VERSION=1.20
ENV LC_ALL=C ENV LC_ALL=C
# Prerequisites # Install dependencies, build SOPE and SOGo, then clean up (all in one layer to minimize image size)
RUN echo "Building from repository $SOGO_DEBIAN_REPOSITORY" \ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get update && apt-get install -y --no-install-recommends \ # Build dependencies
apt-transport-https \ git \
ca-certificates \ build-essential \
gettext \ gobjc \
gnupg \ pkg-config \
mariadb-client \ gnustep-make \
rsync \ gnustep-base-runtime \
supervisor \ libgnustep-base-dev \
syslog-ng \ libxml2-dev \
syslog-ng-core \ libldap2-dev \
syslog-ng-mod-redis \ libssl-dev \
dirmngr \ zlib1g-dev \
netcat-traditional \ libpq-dev \
psmisc \ libmariadb-dev-compat \
wget \ libmemcached-dev \
patch \ 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 }')" \ && dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')" \
&& wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \ && wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/$GOSU_VERSION/gosu-$dpkgArch" \
&& chmod +x /usr/local/bin/gosu \ && chmod +x /usr/local/bin/gosu \
&& gosu nobody true \ && gosu nobody true \
&& mkdir /usr/share/doc/sogo \ # Build SOPE
&& touch /usr/share/doc/sogo/empty.sh \ && git clone --depth 1 --branch ${SOPE_VERSION} https://github.com/Alinto/sope.git /tmp/sope \
&& wget -O- https://keys.openpgp.org/vks/v1/by-fingerprint/74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9 | gpg --dearmor | apt-key add - \ && cd /tmp/sope \
&& echo "deb [trusted=yes] ${SOGO_DEBIAN_REPOSITORY} ${DEBIAN_VERSION} main" > /etc/apt/sources.list.d/sogo.list \ && rm -rf .git \
&& apt-get update && apt-get install -y --no-install-recommends \ && . /usr/share/GNUstep/Makefiles/GNUstep.sh \
sogo \ && ./configure --prefix=/usr --disable-debug --disable-strip \
sogo-activesync \ && make -j$(nproc) \
&& apt-get autoclean \ && 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 /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 && touch /etc/default/locale
# Configure library paths
RUN echo "/usr/lib64" > /etc/ld.so.conf.d/sogo.conf \
&& echo "/usr/local/lib/sogo" >> /etc/ld.so.conf.d/sogo.conf \
&& echo "/usr/local/lib/GNUstep/Frameworks/SOGo.framework/Versions/5/sogo" >> /etc/ld.so.conf.d/sogo.conf \
&& ldconfig
# Create sogo user and group
RUN groupadd -r -g 999 sogo \
&& useradd -r -u 999 -g sogo -d /var/lib/sogo -s /bin/bash -c "SOGo Daemon" sogo \
&& mkdir -p /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo \
&& chown -R sogo:sogo /var/lib/sogo /var/run/sogo /var/log/sogo /var/spool/sogo
# Create symlinks for SOGo binaries
RUN ln -s /usr/local/sbin/sogod /usr/sbin/sogod \
&& ln -s /usr/local/sbin/sogo-tool /usr/sbin/sogo-tool \
&& ln -s /usr/local/sbin/sogo-ealarms-notify /usr/sbin/sogo-ealarms-notify \
&& ln -s /usr/local/sbin/sogo-slapd-sockd /usr/sbin/sogo-slapd-sockd
# Copy configuration files and scripts
COPY ./bootstrap-sogo.sh /bootstrap-sogo.sh COPY ./bootstrap-sogo.sh /bootstrap-sogo.sh
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
@@ -56,4 +176,4 @@ RUN chmod +x /bootstrap-sogo.sh \
ENTRYPOINT ["/docker-entrypoint.sh"] ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"] CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"]
+2 -2
View File
@@ -1,5 +1,5 @@
--- /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200 --- /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:57.987504204 +0200
+++ /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200 +++ /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox 2018-08-17 18:29:35.918291298 +0200
@@ -46,7 +46,7 @@ @@ -46,7 +46,7 @@
</md-item-template> </md-item-template>
</md-autocomplete> </md-autocomplete>
+13 -11
View File
@@ -13,13 +13,11 @@ do
sleep 3 sleep 3
done done
# Wait for updated schema # Wait for updated schema (DB_VERSION from docker-compose.yml)
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN) DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
DBV_NEW=$(grep -oE '\$db_version = .*;' init_db.inc.php | sed 's/$db_version = //g;s/;//g' | cut -d \" -f2) while [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; do
while [[ "${DBV_NOW}" != "${DBV_NEW}" ]]; do
echo "Waiting for schema update..." echo "Waiting for schema update..."
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN) DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
DBV_NEW=$(grep -oE '\$db_version = .*;' init_db.inc.php | sed 's/$db_version = //g;s/;//g' | cut -d \" -f2)
sleep 5 sleep 5
done done
echo "DB schema is ${DBV_NOW}" echo "DB schema is ${DBV_NOW}"
@@ -130,18 +128,22 @@ chmod 600 /var/lib/sogo/GNUstep/Defaults/sogod.plist
# Patch ACLs # Patch ACLs
#if [[ ${ACL_ANYONE} == 'allow' ]]; then #if [[ ${ACL_ANYONE} == 'allow' ]]; then
# #enable any or authenticated targets for ACL # #enable any or authenticated targets for ACL
# if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then # if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
# patch -R /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff; # patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
# fi # fi
#else #else
# #disable any or authenticated targets for ACL # #disable any or authenticated targets for ACL
# if patch -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then # if patch -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff > /dev/null; then
# patch /usr/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff; # patch /usr/local/lib/GNUstep/SOGo/Templates/UIxAclEditor.wox < /acl.diff;
# fi # fi
#fi #fi
if patch -R -sfN --dry-run /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then # Apply custom UI patch (reverse patch to ADD buttons)
patch -R /usr/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff; if patch -R -sfN --dry-run /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff > /dev/null; then
echo "Applying navMailcowBtns patch (reverse to add buttons)..."
patch -R /usr/local/lib/GNUstep/SOGo/Templates/UIxTopnavToolbar.wox < /navMailcowBtns.diff;
else
echo "navMailcowBtns patch already applied or cannot be applied"
fi fi
# Rename custom logo, if any # Rename custom logo, if any
@@ -149,7 +151,7 @@ fi
# Rsync web content # Rsync web content
echo "Syncing web content with named volume" echo "Syncing web content with named volume"
rsync -a /usr/lib/GNUstep/SOGo/. /sogo_web/ rsync -a /usr/local/lib/GNUstep/SOGo/. /sogo_web/
# Chown backup path # Chown backup path
chown -R sogo:sogo /sogo_backup chown -R sogo:sogo /sogo_backup
+6 -2
View File
@@ -1,12 +1,16 @@
FROM alpine:3.21 FROM alpine:3.24
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
# install unbound from alpine:edge to get security patches
# minimum version is pinned
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main "unbound>=1.26.2-r0"
# install other packages from regular alpine stable repo
RUN apk add --update --no-cache \ RUN apk add --update --no-cache \
curl \ curl \
bind-tools \ bind-tools \
coreutils \ coreutils \
unbound \
bash \ bash \
openssl \ openssl \
drill \ drill \
+2 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.21 FROM alpine:3.24
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>" LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -37,5 +37,6 @@ RUN apk add --update \
COPY watchdog.sh /watchdog.sh COPY watchdog.sh /watchdog.sh
COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh COPY check_mysql_slavestatus.sh /usr/lib/nagios/plugins/check_mysql_slavestatus.sh
COPY check_dns.sh /usr/lib/mailcow/check_dns.sh COPY check_dns.sh /usr/lib/mailcow/check_dns.sh
COPY client.cnf /etc/my.cnf.d/client.cnf
CMD ["/watchdog.sh"] CMD ["/watchdog.sh"]
+4 -4
View File
@@ -19,19 +19,19 @@ if [ -z "$HOST" ]; then
fi fi
# run dig and measure the time it takes to run # run dig and measure the time it takes to run
START_TIME=$(date +%s%3N) START_TIME=$(perl -MTime::HiRes -e 'print Time::HiRes::time')
dig_output=$(dig +short +timeout=2 +tries=1 "$HOST" @"$SERVER" 2>/dev/null) dig_output=$(dig +short +timeout=2 +tries=1 "$HOST" @"$SERVER" 2>/dev/null)
dig_rc=$? 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 ',' -) dig_output_ips=$(echo "$dig_output" | grep -E '^[0-9.]+$' | sort | paste -sd ',' -)
END_TIME=$(date +%s%3N) ELAPSED_TIME=$(perl -e "printf('%.3f', $END_TIME - $START_TIME)")
ELAPSED_TIME=$((END_TIME - START_TIME))
# validate and perform nagios like output and exit codes # validate and perform nagios like output and exit codes
if [ $dig_rc -ne 0 ] || [ -z "$dig_output" ]; then if [ $dig_rc -ne 0 ] || [ -z "$dig_output" ]; then
echo "Domain $HOST was not found by the server" echo "Domain $HOST was not found by the server"
exit 2 exit 2
elif [ $dig_rc -eq 0 ]; then elif [ $dig_rc -eq 0 ]; then
echo "DNS OK: $ELAPSED_TIME ms response time. $HOST returns $dig_output_ips" echo "DNS OK: $ELAPSED_TIME seconds response time. $HOST returns $dig_output_ips"
exit 0 exit 0
else else
echo "Unknown error" echo "Unknown error"
+3
View File
@@ -0,0 +1,3 @@
[client]
ssl = false
ssl-verify-server-cert = false
+4 -4
View File
@@ -38,7 +38,7 @@ if [[ ! -p /tmp/com_pipe ]]; then
fi fi
# Wait for containers # Wait for containers
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
echo "Waiting for SQL..." echo "Waiting for SQL..."
sleep 2 sleep 2
done done
@@ -359,8 +359,8 @@ mysql_checks() {
while [ ${err_count} -lt ${THRESHOLD} ]; do while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/mysql-mailcow; echo "$(tail -50 /tmp/mysql-mailcow)" > /tmp/mysql-mailcow touch /tmp/mysql-mailcow; echo "$(tail -50 /tmp/mysql-mailcow)" > /tmp/mysql-mailcow
err_c_cur=${err_count} 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 -f /etc/my.cnf.d/client.cnf -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} + $? )) /usr/lib/nagios/plugins/check_mysql_query -f /etc/my.cnf.d/client.cnf -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} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} )) [ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "MySQL/MariaDB" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c} progress "MySQL/MariaDB" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
@@ -384,7 +384,7 @@ mysql_repl_checks() {
while [ ${err_count} -lt ${THRESHOLD} ]; do while [ ${err_count} -lt ${THRESHOLD} ]; do
touch /tmp/mysql_repl_checks; echo "$(tail -50 /tmp/mysql_repl_checks)" > /tmp/mysql_repl_checks touch /tmp/mysql_repl_checks; echo "$(tail -50 /tmp/mysql_repl_checks)" > /tmp/mysql_repl_checks
err_c_cur=${err_count} 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} + $? )) /usr/lib/nagios/plugins/check_mysql_slavestatus.sh -o /etc/my.cnf.d/client.cnf -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} -eq ${err_count} ] && [ ! $((${err_count} - 1)) -lt 0 ] && err_count=$((${err_count} - 1)) diff_c=1
[ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} )) [ ${err_c_cur} -ne ${err_count} ] && diff_c=$(( ${err_c_cur} - ${err_count} ))
progress "MySQL/MariaDB replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c} progress "MySQL/MariaDB replication" ${THRESHOLD} $(( ${THRESHOLD} - ${err_count} )) ${diff_c}
+21 -5
View File
@@ -80,21 +80,37 @@ if ($isSOGoRequest) {
} }
if ($result === false){ if ($result === false){
// If it's a SOGo Request, don't check for protocol access // If it's a SOGo Request, don't check for protocol access
$service = ($isSOGoRequest) ? false : array($post['service'] => true); if ($isSOGoRequest) {
$result = apppass_login($post['username'], $post['password'], $service, array( $service = 'SOGO';
$post['service'] = 'NONE';
} else {
$service = $post['service'];
}
$result = apppass_login($post['username'], $post['password'], array(
'service' => $post['service'],
'is_internal' => true, 'is_internal' => true,
'remote_addr' => $post['real_rip'] 'remote_addr' => $post['real_rip']
)); ));
if ($result) { if ($result) {
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']); 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'], $post['service']); set_sasl_log($post['username'], $post['real_rip'], $service);
} }
} }
if ($result === false){ if ($result === false){
// Init Identity Provider // Init Identity Provider
$iam_provider = identity_provider('init'); $iam_provider = identity_provider('init');
$iam_settings = identity_provider('get'); $iam_settings = identity_provider('get');
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service'])); try {
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
} catch (\Mailcow\Auth\IdpUnavailable $e) {
// temporary failure for Dovecot, no failed login
error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip'] . ": " . $e->getMessage());
http_response_code(503); // Service Unavailable
echo json_encode($return);
session_destroy();
exit;
}
if ($result) { if ($result) {
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']); 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']); set_sasl_log($post['username'], $post['real_rip'], $post['service']);
+16 -1
View File
@@ -32,8 +32,23 @@ function auth_password_verify(request, password)
-- Returning PASSDB_RESULT_PASSWORD_MISMATCH will reset the user's auth cache entry. -- Returning PASSDB_RESULT_PASSWORD_MISMATCH will reset the user's auth cache entry.
-- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry, -- Returning PASSDB_RESULT_INTERNAL_FAILURE keeps the existing cache entry,
-- even if the TTL has expired. Useful to avoid cache eviction during backend issues. -- even if the TTL has expired. Useful to avoid cache eviction during backend issues.
-- On a network-level failure (nginx unreachable, DNS failure, timeout) https.request
-- returns nil plus an error string, so c is not a numeric HTTP status code. Treat this
-- as a backend outage and keep the cache entry, rather than wiping it as a mismatch.
if type(c) ~= "number" then
dovecot.i_info("HTTP request to auth backend failed with " .. tostring(c) .. " for user " .. request.user)
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unreachable"
end
-- 5xx: the backend or the identity provider cannot answer, a temporary failure and no failed login
if c >= 500 then
dovecot.i_info("HTTP request to auth backend failed with " .. tostring(c) .. " for user " .. request.user)
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, "Upstream unavailable"
end
if c ~= 200 and c ~= 401 then if c ~= 200 and c ~= 401 then
dovecot.i_info("HTTP request failed with " .. c .. " for user " .. request.user) dovecot.i_info("HTTP request failed with " .. tostring(c) .. " for user " .. request.user)
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error" return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Upstream error"
end end
+3 -1
View File
@@ -67,9 +67,11 @@ passdb {
} }
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail # check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
# a return of the following passdb is mandatory # a return of the following passdb is mandatory
# cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry
# of a passdb lookup (master logins) and log "unknown user", which netfilter counts as failed login
passdb { passdb {
driver = lua driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
} }
# Set doveadm_password=your-secret-password in data/conf/dovecot/extra.conf (create if missing) # Set doveadm_password=your-secret-password in data/conf/dovecot/extra.conf (create if missing)
service doveadm { service doveadm {
-10
View File
@@ -1,13 +1,3 @@
# global_sieve_before script # global_sieve_before script
# global_sieve_before -> user sieve_before (mailcow UI) -> user sieve_after (mailcow UI) -> global_sieve_after # global_sieve_before -> user sieve_before (mailcow UI) -> user sieve_after (mailcow UI) -> global_sieve_after
require ["mailbox", "fileinto"];
if header :contains ["Chat-Version"] [""] {
if mailboxexists "DeltaChat" {
fileinto "DeltaChat";
} else {
fileinto :create "DeltaChat";
}
stop;
}
+7
View File
@@ -42,12 +42,19 @@ http {
https https; https https;
} }
map $server_port $sogo_auth_internal {
65510 "1";
default "";
}
{% if HTTP_REDIRECT %} {% if HTTP_REDIRECT %}
# HTTP to HTTPS redirect # HTTP to HTTPS redirect
server { server {
root /web; root /web;
listen {{ HTTP_PORT }} default_server; listen {{ HTTP_PORT }} default_server;
{% if ENABLE_IPV6 %}
listen [::]:{{ HTTP_PORT }} default_server; listen [::]:{{ HTTP_PORT }} default_server;
{%endif%}
server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }}; server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }};
@@ -116,6 +116,9 @@ location ~ \.php$ {
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_X_REAL_IP $remote_addr;
# trusted internal-auth marker; empty for external clients (see nginx.conf map)
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
fastcgi_read_timeout 3600; fastcgi_read_timeout 3600;
fastcgi_send_timeout 3600; fastcgi_send_timeout 3600;
} }
@@ -261,19 +264,19 @@ location ~* /sogo$ {
} }
location /SOGo.woa/WebServerResources/ { location /SOGo.woa/WebServerResources/ {
alias /usr/lib/GNUstep/SOGo/WebServerResources/; alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
} }
location /.woa/WebServerResources/ { location /.woa/WebServerResources/ {
alias /usr/lib/GNUstep/SOGo/WebServerResources/; alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
} }
location /SOGo/WebServerResources/ { location /SOGo/WebServerResources/ {
alias /usr/lib/GNUstep/SOGo/WebServerResources/; alias /usr/local/lib/GNUstep/SOGo/WebServerResources/;
} }
location (^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\.(jpg|png|gif|css|js)$) { location (^/SOGo/so/ControlPanel/Products/[^/]*UI/Resources/.*\.(jpg|png|gif|css|js)$) {
alias /usr/lib/GNUstep/SOGo/$1.SOGo/Resources/$2; alias /usr/local/lib/GNUstep/SOGo/$1.SOGo/Resources/$2;
} }
{% endif %} {% endif %}
+65
View File
@@ -0,0 +1,65 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
} catch (PDOException $e) {
fwrite(STDERR, "DB connect failed: " . $e->getMessage() . "\n");
exit(1);
}
$redis = new Redis();
try {
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
} else {
$redis->connect('redis-mailcow', 6379);
}
$redis->auth(getenv("REDISPASS"));
} catch (Exception $e) {
fwrite(STDERR, "Redis connect failed: " . $e->getMessage() . "\n");
exit(1);
}
function logMsg($priority, $message, $task = "Group Shares") {
global $redis;
$redis->lPush('CRON_LOG', json_encode(array(
"time" => time(),
"priority" => $priority,
"task" => $task,
"message" => $message,
)));
}
require_once __DIR__ . '/../web/inc/functions.inc.php';
require_once __DIR__ . '/../web/inc/functions.docker.inc.php';
// One run at a time, released when the process ends
$lock = fopen('/tmp/group-grants.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
exit(0);
}
$sharing = \Mailcow\MailboxGroup\MailboxGroupModule::create($pdo, \Mailcow\Core\Context::system())->sharing;
// Every minute: subscriptions of changed groups; failed ones are retried and logged by the full run
if ((int)date('i') % 15 !== 0) {
$sharing->syncSubscriptions();
exit(0);
}
// Every 15 minutes: IMAP ACLs on new top-level folders and failed grants, SOGo grants of owners that opened SOGo meanwhile
foreach ($sharing->reconcile() as $warning) {
logMsg("warning", $warning[0] . ": " . html_entity_decode(implode(", ", array_slice($warning, 1))));
}
+95
View File
@@ -0,0 +1,95 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
} catch (PDOException $e) {
fwrite(STDERR, "DB connect failed: " . $e->getMessage() . "\n");
exit(1);
}
$redis = new Redis();
try {
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
} else {
$redis->connect('redis-mailcow', 6379);
}
$redis->auth(getenv("REDISPASS"));
} catch (Exception $e) {
fwrite(STDERR, "Redis connect failed: " . $e->getMessage() . "\n");
exit(1);
}
function logMsg($priority, $message, $task = "Identity Sync") {
global $redis;
$redis->lPush('CRON_LOG', json_encode(array(
"time" => time(),
"priority" => $priority,
"task" => $task,
"message" => $message,
)));
}
require_once __DIR__ . '/../web/inc/functions.inc.php';
require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
require_once __DIR__ . '/../web/inc/sessions.inc.php';
require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php';
require_once __DIR__ . '/../web/inc/functions.acl.inc.php';
// The legacy mailbox functions run as admin
$_SESSION['mailcow_cc_username'] = "admin";
$_SESSION['mailcow_cc_role'] = "admin";
$_SESSION['acl']['tls_policy'] = "1";
$_SESSION['acl']['quarantine_notification'] = "1";
$_SESSION['acl']['quarantine_category'] = "1";
$_SESSION['acl']['ratelimit'] = "1";
$_SESSION['acl']['sogo_access'] = "1";
$_SESSION['acl']['protocol_access'] = "1";
$_SESSION['acl']['mailbox_relayhost'] = "1";
$_SESSION['acl']['unlimited_quota'] = "1";
// One run at a time, released when the process ends
$lock = fopen('/tmp/identity-sync.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
exit(0);
}
// Users, offboarding and groups of Keycloak or LDAP; requested runs first, else every sync_interval minutes.
// The IdP is only contacted when a run is due.
$iam_settings = identity_provider('get');
$identity = \Mailcow\Identity\IdentityModule::create($pdo, \Mailcow\Core\Context::system(), $iam_settings, function () {
return identity_provider('init');
});
$run = $identity->sync->runDue();
if ($run !== null) {
$summary = $run['summary'];
if ($run['status'] == 'error') {
logMsg("err", ucfirst($run['mode']) . " failed: " . $summary['error']);
} else {
$counts = array();
foreach ($summary['counts'] as $key => $count) {
if ($count > 0) $counts[] = $key . "=" . $count;
}
logMsg(($run['status'] == 'blocked') ? "warning" : "info", ucfirst($run['mode']) . " " . $run['status'] . ": " . (empty($counts) ? "no changes" : implode(", ", $counts)));
foreach ($summary['details']['conflicts'] as $conflict) {
logMsg("warning", $conflict);
}
foreach ($summary['errors'] ?? array() as $error) {
logMsg("err", $error);
}
}
}
session_destroy();
@@ -0,0 +1,73 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
} catch (PDOException $e) {
fwrite(STDERR, "DB connect failed: " . $e->getMessage() . "\n");
exit(1);
}
$redis = new Redis();
try {
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
} else {
$redis->connect('redis-mailcow', 6379);
}
$redis->auth(getenv("REDISPASS"));
} catch (Exception $e) {
fwrite(STDERR, "Redis connect failed: " . $e->getMessage() . "\n");
exit(1);
}
function logMsg($priority, $message, $task = "imapsync OAuth refresh") {
global $redis;
$redis->lPush('CRON_LOG', json_encode(array(
"time" => time(),
"priority" => $priority,
"task" => $task,
"message" => $message,
)));
}
require_once __DIR__ . '/../web/inc/functions.inc.php';
require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
require_once __DIR__ . '/../web/inc/sessions.inc.php';
require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
// File-lock to prevent concurrent runs
$lock_file = '/tmp/imapsync-oauth-refresh.lock';
if (file_exists($lock_file)) {
$pid = (int)trim(@file_get_contents($lock_file));
if ($pid > 0 && posix_kill($pid, 0)) {
logMsg("info", "Previous refresh still running (pid=$pid), exiting");
exit(0);
}
@unlink($lock_file);
}
file_put_contents($lock_file, getmypid());
try {
// client_credentials source tokens, unused per-user tokens (1 day grace), authorization_code per-user tokens;
// failing tokens are retried 15 minutes after their last attempt (C2), errors are sanitized
$imapsync = \Mailcow\Imapsync\ImapsyncModule::create($pdo, \Mailcow\Core\Context::system(), [
'hostname' => (string)getenv('MAILCOW_HOSTNAME'),
]);
foreach ($imapsync->oauth->refreshDue() as $e) {
logMsg($e['priority'], $e['message']);
}
} finally {
@unlink($lock_file);
}
-231
View File
@@ -1,231 +0,0 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
// Init database
//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
}
catch (PDOException $e) {
logMsg("err", $e->getMessage());
session_destroy();
exit;
}
// Init Redis
$redis = new Redis();
try {
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
}
else {
$redis->connect('redis-mailcow', 6379);
}
$redis->auth(getenv("REDISPASS"));
}
catch (Exception $e) {
echo "Exiting: " . $e->getMessage();
session_destroy();
exit;
}
function logMsg($priority, $message, $task = "Keycloak Sync") {
global $redis;
$finalMsg = array(
"time" => time(),
"priority" => $priority,
"task" => $task,
"message" => $message
);
$redis->lPush('CRON_LOG', json_encode($finalMsg));
}
// Load core functions first
require_once __DIR__ . '/../web/inc/functions.inc.php';
require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
require_once __DIR__ . '/../web/inc/sessions.inc.php';
require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php';
require_once __DIR__ . '/../web/inc/functions.acl.inc.php';
$_SESSION['mailcow_cc_username'] = "admin";
$_SESSION['mailcow_cc_role'] = "admin";
$_SESSION['acl']['tls_policy'] = "1";
$_SESSION['acl']['quarantine_notification'] = "1";
$_SESSION['acl']['quarantine_category'] = "1";
$_SESSION['acl']['ratelimit'] = "1";
$_SESSION['acl']['sogo_access'] = "1";
$_SESSION['acl']['protocol_access'] = "1";
$_SESSION['acl']['mailbox_relayhost'] = "1";
$_SESSION['acl']['unlimited_quota'] = "1";
$iam_settings = identity_provider('get');
if ($iam_settings['authsource'] != "keycloak" || (intval($iam_settings['periodic_sync']) != 1 && intval($iam_settings['import_users']) != 1)) {
session_destroy();
exit;
}
// Set pagination variables
$start = 0;
$max = 100;
// lock sync if already running
$lock_file = '/tmp/iam-sync.lock';
if (file_exists($lock_file)) {
$lock_file_parts = explode("\n", file_get_contents($lock_file));
$pid = $lock_file_parts[0];
if (count($lock_file_parts) > 1){
$last_execution = $lock_file_parts[1];
$elapsed_time = (time() - $last_execution) / 60;
if ($elapsed_time < intval($iam_settings['sync_interval'])) {
logMsg("warning", "Sync not ready (".number_format((float)$elapsed_time, 2, '.', '')."min / ".$iam_settings['sync_interval']."min)");
session_destroy();
exit;
}
}
if (posix_kill($pid, 0)) {
logMsg("warning", "Sync is already running");
session_destroy();
exit;
} else {
unlink($lock_file);
}
}
$lock_file_handle = fopen($lock_file, 'w');
fwrite($lock_file_handle, getmypid());
fclose($lock_file_handle);
// Init Keycloak Provider
$iam_provider = identity_provider('init');
// Loop until all users have been retrieved
while (true) {
// Get admin access token
$admin_token = identity_provider("get-keycloak-admin-token");
// Make the API request to retrieve the users
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users?first=$start&max=$max";
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"Authorization: Bearer " . $admin_token
]);
$response = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code != 200){
logMsg("err", "Received HTTP {$code}");
session_destroy();
exit;
}
try {
$response = json_decode($response, true);
} catch (Exception $e) {
logMsg("err", $e->getMessage());
break;
}
if (!is_array($response)){
logMsg("err", "Received malformed response from keycloak api");
break;
}
if (count($response) == 0) {
break;
}
// Process the batch of users
foreach ($response as $user) {
if (empty($user['email'])){
logMsg("warning", "No email address in keycloak found for user " . $user['name']);
continue;
}
// try get mailbox user
$stmt = $pdo->prepare("SELECT
mailbox.*,
domain.active AS d_active
FROM `mailbox`
INNER JOIN domain on mailbox.domain = domain.domain
WHERE `kind` NOT REGEXP 'location|thing|group'
AND `username` = :user");
$stmt->execute(array(':user' => $user['email']));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// check if matching attribute mapping exists
$user_template = $user['attributes']['mailcow_template'][0];
$mapper_key = array_search($user_template, $iam_settings['mappers']);
$_SESSION['access_all_exception'] = '1';
if (!$row && intval($iam_settings['import_users']) == 1){
if ($mapper_key === false){
if (!empty($iam_settings['default_template'])) {
$mbox_template = $iam_settings['default_template'];
logMsg("warning", "Using default template for user " . $user['email']);
} else {
logMsg("warning", "No matching attribute mapping found for user " . $user['email']);
continue;
}
} else {
$mbox_template = $iam_settings['templates'][$mapper_key];
}
// mailbox user does not exist, create...
logMsg("info", "Creating user " . $user['email']);
$create_res = mailbox('add', 'mailbox_from_template', array(
'domain' => explode('@', $user['email'])[1],
'local_part' => explode('@', $user['email'])[0],
'name' => $user['firstName'] . " " . $user['lastName'],
'authsource' => 'keycloak',
'template' => $mbox_template
));
if (!$create_res){
logMsg("err", "Could not create user " . $user['email']);
continue;
}
} else if ($row && intval($iam_settings['periodic_sync']) == 1 && $row['authsource'] == "keycloak") {
if ($mapper_key === false){
logMsg("warning", "No matching attribute mapping found for user " . $user['email']);
continue;
}
$mbox_template = $iam_settings['templates'][$mapper_key];
// mailbox user does exist, sync attribtues...
logMsg("info", "Syncing attributes for user " . $user['email']);
mailbox('edit', 'mailbox_from_template', array(
'username' => $user['email'],
'name' => $user['firstName'] . " " . $user['lastName'],
'template' => $mbox_template
));
} else {
// skip mailbox user
logMsg("info", "Skipping user " . $user['email']);
}
$_SESSION['access_all_exception'] = '0';
sleep(0.025);
}
// Update the pagination variables for the next batch
$start += $max;
sleep(1);
}
logMsg("info", "DONE!");
// add last execution time to lock file
$lock_file_handle = fopen($lock_file, 'w');
fwrite($lock_file_handle, getmypid() . "\n" . time());
fclose($lock_file_handle);
session_destroy();
-198
View File
@@ -1,198 +0,0 @@
<?php
require_once(__DIR__ . '/../web/inc/vars.inc.php');
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
}
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
// Init database
//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
}
catch (PDOException $e) {
logMsg("err", $e->getMessage());
session_destroy();
exit;
}
// Init Redis
$redis = new Redis();
try {
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
}
else {
$redis->connect('redis-mailcow', 6379);
}
$redis->auth(getenv("REDISPASS"));
}
catch (Exception $e) {
echo "Exiting: " . $e->getMessage();
session_destroy();
exit;
}
function logMsg($priority, $message, $task = "LDAP Sync") {
global $redis;
$finalMsg = array(
"time" => time(),
"priority" => $priority,
"task" => $task,
"message" => $message
);
$redis->lPush('CRON_LOG', json_encode($finalMsg));
}
// Load core functions first
require_once __DIR__ . '/../web/inc/functions.inc.php';
require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
require_once __DIR__ . '/../web/inc/sessions.inc.php';
require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
require_once __DIR__ . '/../web/inc/functions.ratelimit.inc.php';
require_once __DIR__ . '/../web/inc/functions.acl.inc.php';
$_SESSION['mailcow_cc_username'] = "admin";
$_SESSION['mailcow_cc_role'] = "admin";
$_SESSION['acl']['tls_policy'] = "1";
$_SESSION['acl']['quarantine_notification'] = "1";
$_SESSION['acl']['quarantine_category'] = "1";
$_SESSION['acl']['ratelimit'] = "1";
$_SESSION['acl']['sogo_access'] = "1";
$_SESSION['acl']['protocol_access'] = "1";
$_SESSION['acl']['mailbox_relayhost'] = "1";
$_SESSION['acl']['unlimited_quota'] = "1";
$iam_settings = identity_provider('get');
if ($iam_settings['authsource'] != "ldap" || (intval($iam_settings['periodic_sync']) != 1 && intval($iam_settings['import_users']) != 1)) {
session_destroy();
exit;
}
// Set pagination variables
$start = 0;
$max = 100;
// lock sync if already running
$lock_file = '/tmp/iam-sync.lock';
if (file_exists($lock_file)) {
$lock_file_parts = explode("\n", file_get_contents($lock_file));
$pid = $lock_file_parts[0];
if (count($lock_file_parts) > 1){
$last_execution = $lock_file_parts[1];
$elapsed_time = (time() - $last_execution) / 60;
if ($elapsed_time < intval($iam_settings['sync_interval'])) {
logMsg("warning", "Sync not ready (".number_format((float)$elapsed_time, 2, '.', '')."min / ".$iam_settings['sync_interval']."min)");
session_destroy();
exit;
}
}
if (posix_kill($pid, 0)) {
logMsg("warning", "Sync is already running");
session_destroy();
exit;
} else {
unlink($lock_file);
}
}
$lock_file_handle = fopen($lock_file, 'w');
fwrite($lock_file_handle, getmypid());
fclose($lock_file_handle);
// Init Provider
$iam_provider = identity_provider('init');
// Get ldap users
$ldap_query = $iam_provider->query();
if (!empty($iam_settings['filter'])) {
$ldap_query = $ldap_query->rawFilter($iam_settings['filter']);
}
$response = $ldap_query->where($iam_settings['username_field'], "*")
->where($iam_settings['attribute_field'], "*")
->select([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname'])
->paginate($max);
// Process the users
foreach ($response as $user) {
// try get mailbox user
$stmt = $pdo->prepare("SELECT
mailbox.*,
domain.active AS d_active
FROM `mailbox`
INNER JOIN domain on mailbox.domain = domain.domain
WHERE `kind` NOT REGEXP 'location|thing|group'
AND `username` = :user");
$stmt->execute(array(':user' => $user[$iam_settings['username_field']][0]));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// check if matching attribute mapping exists
$user_template = $user[$iam_settings['attribute_field']][0];
$mapper_key = array_search($user_template, $iam_settings['mappers']);
if (empty($user[$iam_settings['username_field']][0])){
logMsg("warning", "Skipping user " . $user['displayname'][0] . " due to empty LDAP ". $iam_settings['username_field'] . " property.");
continue;
}
$_SESSION['access_all_exception'] = '1';
if (!$row && intval($iam_settings['import_users']) == 1){
if ($mapper_key === false){
if (!empty($iam_settings['default_template'])) {
$mbox_template = $iam_settings['default_template'];
} else {
logMsg("warning", "No matching attribute mapping found for user " . $user[$iam_settings['username_field']][0]);
continue;
}
} else {
$mbox_template = $iam_settings['templates'][$mapper_key];
}
// mailbox user does not exist, create...
logMsg("info", "Creating user " . $user[$iam_settings['username_field']][0]);
$create_res = mailbox('add', 'mailbox_from_template', array(
'domain' => explode('@', $user[$iam_settings['username_field']][0])[1],
'local_part' => explode('@', $user[$iam_settings['username_field']][0])[0],
'name' => $user['displayname'][0],
'authsource' => 'ldap',
'template' => $mbox_template
));
if (!$create_res){
logMsg("err", "Could not create user " . $user[$iam_settings['username_field']][0]);
continue;
}
} else if ($row && intval($iam_settings['periodic_sync']) == 1 && $row['authsource'] == "ldap") {
if ($mapper_key === false){
logMsg("warning", "No matching attribute mapping found for user " . $user[$iam_settings['username_field']][0]);
continue;
}
$mbox_template = $iam_settings['templates'][$mapper_key];
// mailbox user does exist, sync attribtues...
logMsg("info", "Syncing attributes for user " . $user[$iam_settings['username_field']][0]);
mailbox('edit', 'mailbox_from_template', array(
'username' => $user[$iam_settings['username_field']][0],
'name' => $user['displayname'][0],
'template' => $mbox_template
));
} else {
// skip mailbox user
logMsg("info", "Skipping user " . $user[$iam_settings['username_field']][0]);
}
$_SESSION['access_all_exception'] = '0';
sleep(0.025);
}
logMsg("info", "DONE!");
// add last execution time to lock file
$lock_file_handle = fopen($lock_file, 'w');
fwrite($lock_file_handle, getmypid() . "\n" . time());
fclose($lock_file_handle);
session_destroy();
+2
View File
@@ -1,3 +1,5 @@
max_execution_time = 3600 max_execution_time = 3600
max_input_time = 3600 max_input_time = 3600
memory_limit = 512M memory_limit = 512M
; not used by mailcow, disabled as hardening (PHP security release 2026-10-21)
session.upload_progress.enabled = Off
+2 -4
View File
@@ -37,7 +37,7 @@ postscreen_access_list = permit_mynetworks,
cidr:/opt/postfix/conf/postscreen_access.cidr, cidr:/opt/postfix/conf/postscreen_access.cidr,
tcp:127.0.0.1:10027 tcp:127.0.0.1:10027
postscreen_bare_newline_enable = no postscreen_bare_newline_enable = no
postscreen_blacklist_action = drop postscreen_denylist_action = drop
postscreen_cache_cleanup_interval = 24h postscreen_cache_cleanup_interval = 24h
postscreen_cache_map = proxy:btree:$data_directory/postscreen_cache postscreen_cache_map = proxy:btree:$data_directory/postscreen_cache
postscreen_dnsbl_action = enforce postscreen_dnsbl_action = enforce
@@ -107,8 +107,6 @@ smtpd_sender_restrictions = reject_authenticated_sender_login_mismatch,
reject_unknown_sender_domain reject_unknown_sender_domain
smtpd_soft_error_limit = 3 smtpd_soft_error_limit = 3
smtpd_tls_auth_only = yes smtpd_tls_auth_only = yes
smtpd_tls_dh1024_param_file = /etc/ssl/mail/dhparams.pem
smtpd_tls_eecdh_grade = auto
smtpd_tls_exclude_ciphers = ECDHE-RSA-RC4-SHA, RC4, aNULL, DES-CBC3-SHA, ECDHE-RSA-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA smtpd_tls_exclude_ciphers = ECDHE-RSA-RC4-SHA, RC4, aNULL, DES-CBC3-SHA, ECDHE-RSA-DES-CBC3-SHA, EDH-RSA-DES-CBC3-SHA
smtpd_tls_loglevel = 1 smtpd_tls_loglevel = 1
@@ -152,7 +150,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_password_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_sender_dependent.cf
smtp_sasl_security_options = smtp_sasl_security_options =
smtp_sasl_mechanism_filter = plain, login smtp_sasl_mechanism_filter = plain, login
smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERY smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERYwithTLSRPT
smtp_header_checks = pcre:/opt/postfix/conf/anonymize_headers.pcre smtp_header_checks = pcre:/opt/postfix/conf/anonymize_headers.pcre
mail_name = Postcow mail_name = Postcow
# local_transport map catches local destinations and prevents routing local dests when the next map would route "*" # local_transport map catches local destinations and prevents routing local dests when the next map would route "*"
-2
View File
@@ -29,7 +29,6 @@ smtps inet n - n - - smtpd
# TLS protocol can be modified by setting submission_smtpd_tls_mandatory_protocols in extra.cf # TLS protocol can be modified by setting submission_smtpd_tls_mandatory_protocols in extra.cf
submission inet n - n - - smtpd submission inet n - n - - smtpd
-o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject -o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject
-o smtpd_enforce_tls=yes
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=$submission_smtpd_tls_mandatory_protocols -o smtpd_tls_mandatory_protocols=$submission_smtpd_tls_mandatory_protocols
-o tls_preempt_cipherlist=yes -o tls_preempt_cipherlist=yes
@@ -38,7 +37,6 @@ submission inet n - n - - smtpd
10587 inet n - n - - smtpd 10587 inet n - n - - smtpd
-o smtpd_upstream_proxy_protocol=haproxy -o smtpd_upstream_proxy_protocol=haproxy
-o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject -o smtpd_client_restrictions=permit_mynetworks,permit_sasl_authenticated,reject
-o smtpd_enforce_tls=yes
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=$submission_smtpd_tls_mandatory_protocols -o smtpd_tls_mandatory_protocols=$submission_smtpd_tls_mandatory_protocols
-o tls_preempt_cipherlist=yes -o tls_preempt_cipherlist=yes
+218 -110
View File
@@ -1,7 +1,8 @@
# Whitelist generated by Postwhite v3.4 on Mon Dec 1 00:24:43 UTC 2025 # Whitelist generated by Postwhite v3.4 on Thu Oct 1 00:35:20 UTC 2026
# https://github.com/stevejenkins/postwhite/ # https://github.com/stevejenkins/postwhite/
# 2186 total rules # 2246 total rules
2a00:1450:4000::/36 permit 2a00:1450:4000::/36 permit
2a00:1450:4864::/56 permit
2a01:111:f400::/48 permit 2a01:111:f400::/48 permit
2a01:111:f403:2800::/53 permit 2a01:111:f403:2800::/53 permit
2a01:111:f403:8000::/51 permit 2a01:111:f403:8000::/51 permit
@@ -28,11 +29,15 @@
2a01:b747:3004:200::/56 permit 2a01:b747:3004:200::/56 permit
2a01:b747:3005:200::/56 permit 2a01:b747:3005:200::/56 permit
2a01:b747:3006:200::/56 permit 2a01:b747:3006:200::/56 permit
2a01:b747:3007::/56 permit
2a02:a60:0:5::/64 permit 2a02:a60:0:5::/64 permit
2a0f:f640::/56 permit 2a0f:f640::/56 permit
2c0f:fb50:4000::/36 permit 2c0f:fb50:4000::/36 permit
2c0f:fb50:4864::/56 permit
2.207.151.32/27 permit
2.207.151.53 permit 2.207.151.53 permit
2.207.217.30 permit 2.207.217.30 permit
2.207.223.160/27 permit
3.64.237.68 permit 3.64.237.68 permit
3.65.3.180 permit 3.65.3.180 permit
3.70.123.177 permit 3.70.123.177 permit
@@ -52,10 +57,15 @@
8.25.194.0/23 permit 8.25.194.0/23 permit
8.25.196.0/23 permit 8.25.196.0/23 permit
8.36.116.0/24 permit 8.36.116.0/24 permit
8.39.54.0/23 permit
8.39.54.240/29 permit
8.39.54.250/31 permit
8.39.144.0/24 permit 8.39.144.0/24 permit
8.40.222.0/23 permit
8.40.222.240/29 permit
8.40.222.250/31 permit
8.47.10.9 permit
12.130.86.238 permit 12.130.86.238 permit
13.107.213.69 permit
13.107.246.69 permit
13.108.16.0/20 permit 13.108.16.0/20 permit
13.110.208.0/21 permit 13.110.208.0/21 permit
13.110.209.0/24 permit 13.110.209.0/24 permit
@@ -115,7 +125,6 @@
23.249.208.0/20 permit 23.249.208.0/20 permit
23.251.224.0/19 permit 23.251.224.0/19 permit
23.253.141.0/24 permit 23.253.141.0/24 permit
23.253.182.0/23 permit
23.253.182.103 permit 23.253.182.103 permit
23.253.183.145 permit 23.253.183.145 permit
23.253.183.146 permit 23.253.183.146 permit
@@ -137,24 +146,7 @@
27.123.206.80/28 permit 27.123.206.80/28 permit
31.47.251.17 permit 31.47.251.17 permit
31.186.239.0/24 permit 31.186.239.0/24 permit
34.2.64.0/22 permit 34.2.64.0/19 permit
34.2.68.0/23 permit
34.2.70.0/23 permit
34.2.71.64/26 permit
34.2.72.0/22 permit
34.2.75.0/26 permit
34.2.78.0/23 permit
34.2.80.0/23 permit
34.2.82.0/23 permit
34.2.84.0/24 permit
34.2.84.64/26 permit
34.2.85.0/24 permit
34.2.85.64/26 permit
34.2.86.0/23 permit
34.2.88.0/23 permit
34.2.90.0/23 permit
34.2.92.0/23 permit
34.2.94.0/23 permit
34.70.158.162 permit 34.70.158.162 permit
34.74.74.140 permit 34.74.74.140 permit
34.83.159.189 permit 34.83.159.189 permit
@@ -169,6 +161,7 @@
34.215.104.144 permit 34.215.104.144 permit
34.218.115.239 permit 34.218.115.239 permit
34.225.212.172 permit 34.225.212.172 permit
34.241.242.183 permit
35.83.148.184 permit 35.83.148.184 permit
35.155.198.111 permit 35.155.198.111 permit
35.158.23.94 permit 35.158.23.94 permit
@@ -180,6 +173,7 @@
35.205.92.9 permit 35.205.92.9 permit
35.228.216.85 permit 35.228.216.85 permit
35.242.169.159 permit 35.242.169.159 permit
37.187.220.204 permit
37.188.97.188 permit 37.188.97.188 permit
37.218.248.47 permit 37.218.248.47 permit
37.218.249.47 permit 37.218.249.47 permit
@@ -192,6 +186,7 @@
40.233.64.216 permit 40.233.64.216 permit
40.233.83.78 permit 40.233.83.78 permit
40.233.88.28 permit 40.233.88.28 permit
43.239.212.33 permit
44.206.138.57 permit 44.206.138.57 permit
44.210.169.44 permit 44.210.169.44 permit
44.217.45.156 permit 44.217.45.156 permit
@@ -252,8 +247,8 @@
46.228.39.112/31 permit 46.228.39.112/31 permit
46.243.88.174 permit 46.243.88.174 permit
46.243.88.175 permit 46.243.88.175 permit
46.243.88.176 permit 46.243.88.176/30 permit
46.243.88.177 permit 46.243.95.96/27 permit
46.243.95.179 permit 46.243.95.179 permit
46.243.95.180 permit 46.243.95.180 permit
50.16.246.183 permit 50.16.246.183 permit
@@ -271,8 +266,11 @@
50.56.130.221 permit 50.56.130.221 permit
50.56.130.222 permit 50.56.130.222 permit
50.112.246.219 permit 50.112.246.219 permit
50.152.146.25 permit
51.83.17.38 permit
52.1.14.157 permit 52.1.14.157 permit
52.5.230.59 permit 52.5.230.59 permit
52.6.74.205 permit
52.12.53.23 permit 52.12.53.23 permit
52.13.214.179 permit 52.13.214.179 permit
52.26.1.71 permit 52.26.1.71 permit
@@ -296,14 +294,6 @@
52.94.124.0/28 permit 52.94.124.0/28 permit
52.95.48.152/29 permit 52.95.48.152/29 permit
52.95.49.88/29 permit 52.95.49.88/29 permit
52.96.91.34 permit
52.96.111.82 permit
52.96.172.98 permit
52.96.222.194 permit
52.96.222.226 permit
52.96.223.2 permit
52.96.228.130 permit
52.96.229.242 permit
52.100.0.0/15 permit 52.100.0.0/15 permit
52.102.0.0/16 permit 52.102.0.0/16 permit
52.103.0.0/17 permit 52.103.0.0/17 permit
@@ -337,6 +327,7 @@
54.244.54.130 permit 54.244.54.130 permit
54.244.242.0/24 permit 54.244.242.0/24 permit
54.255.61.23 permit 54.255.61.23 permit
56.124.6.228 permit
57.103.64.0/18 permit 57.103.64.0/18 permit
57.129.93.249 permit 57.129.93.249 permit
62.13.128.0/24 permit 62.13.128.0/24 permit
@@ -367,7 +358,9 @@
64.127.115.252 permit 64.127.115.252 permit
64.132.88.0/23 permit 64.132.88.0/23 permit
64.132.92.0/24 permit 64.132.92.0/24 permit
64.181.136.241 permit
64.181.194.190 permit 64.181.194.190 permit
64.181.213.254 permit
64.207.219.7 permit 64.207.219.7 permit
64.207.219.8 permit 64.207.219.8 permit
64.207.219.9 permit 64.207.219.9 permit
@@ -397,22 +390,12 @@
64.207.219.143 permit 64.207.219.143 permit
64.233.160.0/19 permit 64.233.160.0/19 permit
65.52.80.137 permit 65.52.80.137 permit
65.54.121.120/29 permit
65.55.29.77 permit 65.55.29.77 permit
65.55.33.64/28 permit
65.55.42.224/28 permit 65.55.42.224/28 permit
65.55.52.224/27 permit
65.55.78.128/25 permit
65.55.81.48/28 permit
65.55.94.0/25 permit
65.55.113.64/26 permit
65.55.126.0/25 permit
65.55.174.0/25 permit
65.55.178.128/27 permit
65.55.234.192/26 permit
65.110.161.77 permit 65.110.161.77 permit
65.123.29.213 permit 65.123.29.213 permit
65.123.29.220 permit 65.123.29.220 permit
65.154.166.0/24 permit
65.212.180.36 permit 65.212.180.36 permit
66.102.0.0/20 permit 66.102.0.0/20 permit
66.119.150.192/26 permit 66.119.150.192/26 permit
@@ -463,7 +446,11 @@
66.218.75.252/31 permit 66.218.75.252/31 permit
66.218.75.254 permit 66.218.75.254 permit
66.220.144.128/25 permit 66.220.144.128/25 permit
66.220.144.178 permit
66.220.144.179 permit
66.220.155.0/24 permit 66.220.155.0/24 permit
66.220.155.178 permit
66.220.155.179 permit
66.220.157.0/25 permit 66.220.157.0/25 permit
66.231.80.0/20 permit 66.231.80.0/20 permit
66.240.227.0/24 permit 66.240.227.0/24 permit
@@ -528,8 +515,9 @@
69.162.98.0/24 permit 69.162.98.0/24 permit
69.169.224.0/20 permit 69.169.224.0/20 permit
69.171.232.0/24 permit 69.171.232.0/24 permit
69.171.232.180 permit
69.171.232.181 permit
69.171.244.0/23 permit 69.171.244.0/23 permit
70.37.151.128/25 permit
70.42.149.35 permit 70.42.149.35 permit
72.3.185.0/24 permit 72.3.185.0/24 permit
72.14.192.0/18 permit 72.14.192.0/18 permit
@@ -650,18 +638,29 @@
77.238.189.148/30 permit 77.238.189.148/30 permit
79.135.106.0/24 permit 79.135.106.0/24 permit
79.135.107.0/24 permit 79.135.107.0/24 permit
80.225.160.128/25 permit
81.88.38.0/23 permit
81.169.146.243 permit 81.169.146.243 permit
81.169.146.245 permit 81.169.146.245 permit
81.169.146.246 permit 81.169.146.246 permit
81.223.46.0/27 permit 81.223.46.0/27 permit
82.165.159.2 permit
82.165.159.3 permit
82.165.159.4 permit
82.165.159.12 permit 82.165.159.12 permit
82.165.159.13 permit 82.165.159.13 permit
82.165.159.14 permit 82.165.159.14 permit
82.165.159.34 permit
82.165.159.35 permit
82.165.159.40 permit 82.165.159.40 permit
82.165.159.41 permit 82.165.159.41 permit
82.165.159.42 permit 82.165.159.42 permit
82.165.159.45 permit
82.165.159.130 permit 82.165.159.130 permit
82.165.159.131 permit 82.165.159.131 permit
84.8.68.0/25 permit
84.8.192.128/25 permit
84.8.224.128/25 permit
85.9.206.169 permit 85.9.206.169 permit
85.9.210.45 permit 85.9.210.45 permit
85.158.136.0/21 permit 85.158.136.0/21 permit
@@ -712,11 +711,11 @@
87.248.117.205 permit 87.248.117.205 permit
87.253.232.0/21 permit 87.253.232.0/21 permit
89.22.108.0/24 permit 89.22.108.0/24 permit
91.198.2.0/24 permit 91.198.2.177 permit
91.198.2.217 permit
91.198.2.222 permit
91.211.240.0/22 permit 91.211.240.0/22 permit
94.236.119.0/26 permit 94.236.119.0/26 permit
94.245.112.0/27 permit
94.245.112.10/31 permit
95.131.104.0/21 permit 95.131.104.0/21 permit
95.217.114.154 permit 95.217.114.154 permit
96.43.144.0/20 permit 96.43.144.0/20 permit
@@ -725,6 +724,7 @@
96.43.148.64/28 permit 96.43.148.64/28 permit
96.43.148.64/31 permit 96.43.148.64/31 permit
96.43.151.64/28 permit 96.43.151.64/28 permit
98.77.0.0/16 permit
98.97.248.0/21 permit 98.97.248.0/21 permit
98.136.44.181 permit 98.136.44.181 permit
98.136.44.182/31 permit 98.136.44.182/31 permit
@@ -1211,14 +1211,15 @@
99.78.197.208/28 permit 99.78.197.208/28 permit
103.9.96.0/22 permit 103.9.96.0/22 permit
103.28.42.0/24 permit 103.28.42.0/24 permit
103.84.217.15 permit
103.84.217.238 permit
103.89.75.238 permit
103.151.192.0/23 permit 103.151.192.0/23 permit
103.168.172.128/27 permit 103.168.172.128/27 permit
103.237.104.0/22 permit 103.237.104.0/22 permit
104.43.243.237 permit 104.43.243.237 permit
104.44.112.128/25 permit 104.44.112.128/25 permit
104.47.0.0/17 permit 104.47.0.0/17 permit
104.130.96.0/28 permit
104.130.122.0/23 permit
106.10.144.64/27 permit 106.10.144.64/27 permit
106.10.144.100/31 permit 106.10.144.100/31 permit
106.10.144.103 permit 106.10.144.103 permit
@@ -1354,11 +1355,6 @@
108.179.144.0/20 permit 108.179.144.0/20 permit
109.224.244.0/24 permit 109.224.244.0/24 permit
109.237.142.0/24 permit 109.237.142.0/24 permit
111.221.23.128/25 permit
111.221.26.0/27 permit
111.221.66.0/25 permit
111.221.69.128/25 permit
111.221.112.0/21 permit
112.19.199.64/29 permit 112.19.199.64/29 permit
112.19.242.64/29 permit 112.19.242.64/29 permit
116.214.12.47 permit 116.214.12.47 permit
@@ -1376,6 +1372,10 @@
117.120.16.0/21 permit 117.120.16.0/21 permit
119.42.242.52/31 permit 119.42.242.52/31 permit
119.42.242.156 permit 119.42.242.156 permit
121.244.91.31 permit
121.244.91.48 permit
121.244.91.52 permit
122.15.156.182 permit
123.126.78.64/29 permit 123.126.78.64/29 permit
124.108.96.24/31 permit 124.108.96.24/31 permit
124.108.96.28/31 permit 124.108.96.28/31 permit
@@ -1412,6 +1412,20 @@
129.146.88.28 permit 129.146.88.28 permit
129.146.147.105 permit 129.146.147.105 permit
129.146.236.58 permit 129.146.236.58 permit
129.148.135.0/25 permit
129.148.148.0/25 permit
129.148.164.0/25 permit
129.148.180.0/25 permit
129.148.215.0/25 permit
129.149.6.0/25 permit
129.149.22.0/25 permit
129.149.38.0/25 permit
129.149.52.0/25 permit
129.149.68.0/25 permit
129.149.84.0/25 permit
129.149.100.0/25 permit
129.149.118.0/25 permit
129.149.126.0/25 permit
129.151.67.221 permit 129.151.67.221 permit
129.153.62.216 permit 129.153.62.216 permit
129.153.104.71 permit 129.153.104.71 permit
@@ -1420,26 +1434,34 @@
129.153.194.228 permit 129.153.194.228 permit
129.154.255.129 permit 129.154.255.129 permit
129.158.56.255 permit 129.158.56.255 permit
129.158.62.153 permit
129.159.22.159 permit 129.159.22.159 permit
129.159.87.137 permit 129.159.87.137 permit
129.213.195.191 permit 129.213.195.191 permit
130.35.116.0/25 permit
130.61.9.72 permit 130.61.9.72 permit
130.162.39.83 permit 130.162.39.83 permit
130.162.194.23 permit
130.248.172.0/24 permit 130.248.172.0/24 permit
130.248.173.0/24 permit 130.248.173.0/24 permit
131.186.12.0/25 permit
131.253.30.0/24 permit 131.253.30.0/24 permit
131.253.121.0/26 permit 131.253.121.0/26 permit
132.145.13.209 permit 132.145.13.209 permit
132.226.26.225 permit 132.226.26.225 permit
132.226.49.32 permit 132.226.49.32 permit
132.226.56.24 permit 132.226.56.24 permit
134.128.64.0/19 permit 134.98.248.128/25 permit
134.128.96.0/19 permit 134.128.64.0/18 permit
134.170.27.8 permit 134.170.27.8 permit
134.170.113.0/26 permit 134.170.113.0/26 permit
134.170.141.64/26 permit 134.170.141.64/26 permit
134.170.143.0/24 permit 134.170.143.0/24 permit
134.170.174.0/24 permit 134.170.174.0/24 permit
135.84.80.0/24 permit
135.84.81.0/24 permit
135.84.82.0/24 permit
135.84.83.0/24 permit
135.84.216.0/22 permit 135.84.216.0/22 permit
136.143.160.0/24 permit 136.143.160.0/24 permit
136.143.161.0/24 permit 136.143.161.0/24 permit
@@ -1459,6 +1481,10 @@
136.147.182.0/24 permit 136.147.182.0/24 permit
136.147.224.0/20 permit 136.147.224.0/20 permit
136.179.50.206 permit 136.179.50.206 permit
136.248.224.128/25 permit
136.248.232.128/25 permit
138.1.108.0/25 permit
138.1.170.0/24 permit
139.60.152.0/22 permit 139.60.152.0/22 permit
139.138.35.44 permit 139.138.35.44 permit
139.138.46.121 permit 139.138.46.121 permit
@@ -1466,8 +1492,12 @@
139.138.46.219 permit 139.138.46.219 permit
139.138.57.55 permit 139.138.57.55 permit
139.138.58.119 permit 139.138.58.119 permit
139.167.79.86 permit
139.177.108.0/25 permit
139.180.17.0/24 permit 139.180.17.0/24 permit
140.238.148.191 permit 140.238.148.191 permit
141.148.55.217 permit
141.148.91.244 permit
141.148.159.229 permit 141.148.159.229 permit
141.193.32.0/23 permit 141.193.32.0/23 permit
141.193.184.32/27 permit 141.193.184.32/27 permit
@@ -1494,14 +1524,14 @@
146.20.14.105 permit 146.20.14.105 permit
146.20.14.106 permit 146.20.14.106 permit
146.20.14.107 permit 146.20.14.107 permit
146.20.112.0/26 permit
146.20.113.0/24 permit
146.20.191.0/24 permit
146.20.215.0/24 permit 146.20.215.0/24 permit
146.20.215.182 permit 146.20.215.182 permit
146.88.28.0/24 permit 146.88.28.0/24 permit
146.148.116.76 permit 146.148.116.76 permit
147.154.32.0/25 permit 147.154.32.0/25 permit
147.154.63.0/24 permit
147.154.126.0/24 permit
147.154.191.0/24 permit
147.243.1.47 permit 147.243.1.47 permit
147.243.1.48 permit 147.243.1.48 permit
147.243.1.153 permit 147.243.1.153 permit
@@ -1509,37 +1539,41 @@
147.243.128.26 permit 147.243.128.26 permit
148.105.0.0/16 permit 148.105.0.0/16 permit
148.105.8.0/21 permit 148.105.8.0/21 permit
148.116.32.128/25 permit
149.72.0.0/16 permit 149.72.0.0/16 permit
149.72.234.184 permit 149.72.234.184 permit
149.72.248.236 permit 149.72.248.236 permit
149.97.173.180 permit 149.97.173.180 permit
149.118.160.128/25 permit
150.136.21.199 permit
150.171.109.178 permit
150.230.98.160 permit 150.230.98.160 permit
151.145.38.14 permit 151.145.38.14 permit
152.67.105.195 permit 152.67.105.195 permit
152.69.165.251 permit
152.69.200.236 permit 152.69.200.236 permit
152.70.155.126 permit 152.70.155.126 permit
155.248.135.128/25 permit
155.248.140.0/25 permit
155.248.148.0/25 permit
155.248.208.51 permit 155.248.208.51 permit
155.248.220.138 permit 155.248.220.138 permit
155.248.234.149 permit 155.248.234.149 permit
155.248.237.141 permit 155.248.237.141 permit
157.55.9.128/25 permit
157.55.11.0/25 permit
157.55.49.0/25 permit
157.55.61.0/24 permit
157.55.157.128/25 permit
157.55.225.0/25 permit
157.56.24.0/25 permit
157.56.120.128/26 permit 157.56.120.128/26 permit
157.56.232.0/21 permit
157.56.240.0/20 permit
157.56.248.0/21 permit
157.58.30.128/25 permit 157.58.30.128/25 permit
157.58.196.96/29 permit 157.58.196.96/29 permit
157.58.249.3 permit 157.58.249.3 permit
157.137.32.128/25 permit
157.137.96.128/25 permit
157.151.208.65 permit 157.151.208.65 permit
157.255.1.64/29 permit 157.255.1.64/29 permit
158.101.211.207 permit 158.101.211.207 permit
158.247.16.0/20 permit 158.247.16.0/20 permit
158.247.100.0/25 permit
159.13.4.0/25 permit
159.13.33.181 permit
159.26.176.0/20 permit
159.92.154.0/24 permit 159.92.154.0/24 permit
159.92.155.0/24 permit 159.92.155.0/24 permit
159.92.157.0/24 permit 159.92.157.0/24 permit
@@ -1561,16 +1595,21 @@
159.135.224.0/20 permit 159.135.224.0/20 permit
159.135.228.10 permit 159.135.228.10 permit
159.183.0.0/16 permit 159.183.0.0/16 permit
159.183.14.233 permit
159.183.68.71 permit 159.183.68.71 permit
159.183.79.38 permit 159.183.79.38 permit
159.183.121.182 permit
159.183.129.172 permit 159.183.129.172 permit
160.1.62.192 permit 160.1.62.192 permit
161.33.94.110 permit
161.33.228.244 permit
161.38.192.0/20 permit 161.38.192.0/20 permit
161.38.204.0/22 permit 161.38.204.0/22 permit
161.71.32.0/19 permit 161.71.32.0/19 permit
161.71.64.0/20 permit 161.71.64.0/20 permit
162.88.4.0/23 permit 162.88.4.0/23 permit
162.88.8.0/24 permit 162.88.8.0/24 permit
162.88.24.0/23 permit
162.88.24.0/24 permit 162.88.24.0/24 permit
162.88.25.0/24 permit 162.88.25.0/24 permit
162.88.36.0/24 permit 162.88.36.0/24 permit
@@ -1582,10 +1621,19 @@
163.114.135.16 permit 163.114.135.16 permit
163.116.128.0/17 permit 163.116.128.0/17 permit
163.192.116.87 permit 163.192.116.87 permit
163.192.125.176 permit
163.192.196.146 permit
163.192.204.161 permit
164.152.23.32 permit 164.152.23.32 permit
164.152.25.241 permit 164.152.25.241 permit
164.177.132.168/30 permit 164.177.132.168/30 permit
166.78.68.0/22 permit 165.1.100.0/25 permit
165.173.128.0/24 permit
165.173.180.0/24 permit
165.173.180.250/31 permit
165.173.182.0/24 permit
165.173.182.250/31 permit
165.173.189.205 permit
166.78.68.221 permit 166.78.68.221 permit
166.78.69.169 permit 166.78.69.169 permit
166.78.69.170 permit 166.78.69.170 permit
@@ -1603,9 +1651,12 @@
167.89.75.126 permit 167.89.75.126 permit
167.89.75.136 permit 167.89.75.136 permit
167.89.75.164 permit 167.89.75.164 permit
167.89.101.2 permit
167.89.101.192/28 permit
167.220.67.232/29 permit 167.220.67.232/29 permit
168.107.248.128/25 permit
168.110.160.128/25 permit
168.110.248.128/25 permit
168.129.184.128/25 permit
168.129.248.128/25 permit
168.138.5.36 permit 168.138.5.36 permit
168.138.73.51 permit 168.138.73.51 permit
168.138.77.31 permit 168.138.77.31 permit
@@ -1614,6 +1665,20 @@
168.245.12.252 permit 168.245.12.252 permit
168.245.46.9 permit 168.245.46.9 permit
168.245.127.231 permit 168.245.127.231 permit
169.148.129.0/24 permit
169.148.131.0/24 permit
169.148.138.0/24 permit
169.148.142.10 permit
169.148.142.33 permit
169.148.144.0/25 permit
169.148.144.10 permit
169.148.146.0/23 permit
169.148.174.10 permit
169.148.175.3 permit
169.148.179.3 permit
169.148.188.0/24 permit
169.148.188.182 permit
170.9.232.254 permit
170.10.128.0/24 permit 170.10.128.0/24 permit
170.10.129.0/24 permit 170.10.129.0/24 permit
170.10.132.56/29 permit 170.10.132.56/29 permit
@@ -1623,11 +1688,11 @@
173.0.84.224/27 permit 173.0.84.224/27 permit
173.0.94.244/30 permit 173.0.94.244/30 permit
173.194.0.0/16 permit 173.194.0.0/16 permit
173.194.0.0/17 permit
173.203.79.182 permit 173.203.79.182 permit
173.203.81.39 permit 173.203.81.39 permit
173.224.161.128/25 permit 173.224.161.128/25 permit
173.224.165.0/26 permit 173.224.165.0/26 permit
173.224.166.48/28 permit
174.36.84.8/29 permit 174.36.84.8/29 permit
174.36.84.16/29 permit 174.36.84.16/29 permit
174.36.84.32/29 permit 174.36.84.32/29 permit
@@ -1642,13 +1707,14 @@
175.41.215.51 permit 175.41.215.51 permit
176.32.105.0/24 permit 176.32.105.0/24 permit
176.32.127.0/24 permit 176.32.127.0/24 permit
176.94.34.32/27 permit
176.94.34.64/27 permit
178.236.10.128/26 permit 178.236.10.128/26 permit
182.50.76.0/22 permit 182.50.76.0/22 permit
182.50.78.64/28 permit 182.50.78.64/28 permit
183.240.219.64/29 permit 183.240.219.64/29 permit
185.4.120.0/22 permit 185.4.120.0/22 permit
185.11.253.128/27 permit 185.11.255.144 permit
185.11.255.0/24 permit
185.12.80.0/22 permit 185.12.80.0/22 permit
185.28.196.0/22 permit 185.28.196.0/22 permit
185.58.84.93 permit 185.58.84.93 permit
@@ -1662,8 +1728,16 @@
185.138.56.128/25 permit 185.138.56.128/25 permit
185.189.236.0/22 permit 185.189.236.0/22 permit
185.211.120.0/22 permit 185.211.120.0/22 permit
185.233.188.0/23 permit 185.233.188.68 permit
185.233.190.0/23 permit 185.233.188.75 permit
185.233.188.84 permit
185.233.188.160 permit
185.233.188.176 permit
185.233.188.247 permit
185.233.189.44 permit
185.233.189.98 permit
185.233.189.122 permit
185.233.189.228 permit
185.250.236.0/22 permit 185.250.236.0/22 permit
185.250.239.148 permit 185.250.239.148 permit
185.250.239.168 permit 185.250.239.168 permit
@@ -1716,30 +1790,42 @@
188.125.85.234/31 permit 188.125.85.234/31 permit
188.125.85.236/31 permit 188.125.85.236/31 permit
188.125.85.238 permit 188.125.85.238 permit
188.165.51.139 permit
188.172.128.0/20 permit 188.172.128.0/20 permit
192.0.64.0/18 permit 192.0.64.0/18 permit
192.9.166.184 permit
192.18.139.154 permit 192.18.139.154 permit
192.18.145.36 permit 192.18.145.36 permit
192.18.152.58 permit 192.18.152.58 permit
192.22.32.128/25 permit
192.22.96.128/25 permit
192.22.160.128/25 permit
192.22.224.128/25 permit
192.28.128.0/18 permit 192.28.128.0/18 permit
192.29.24.0/25 permit
192.29.44.0/25 permit
192.29.72.0/25 permit
192.29.88.0/25 permit
192.29.103.128/25 permit 192.29.103.128/25 permit
192.29.134.0/25 permit
192.29.151.128/25 permit
192.29.172.0/25 permit
192.29.178.0/25 permit
192.29.200.0/25 permit
192.29.216.0/25 permit
192.29.232.0/25 permit
192.29.248.0/25 permit
192.30.252.0/22 permit 192.30.252.0/22 permit
192.161.144.0/20 permit 192.161.144.0/20 permit
192.162.87.0/24 permit 192.162.87.0/24 permit
192.237.158.0/23 permit
192.237.159.42 permit 192.237.159.42 permit
192.237.159.43 permit 192.237.159.43 permit
192.254.112.0/20 permit 192.254.112.0/20 permit
192.254.112.60 permit
192.254.112.98/31 permit
192.254.113.10 permit
192.254.113.101 permit
192.254.114.176 permit
193.109.254.0/23 permit 193.109.254.0/23 permit
193.122.128.100 permit 193.122.128.100 permit
193.123.56.63 permit 193.123.56.63 permit
193.142.157.0/24 permit 193.142.157.15 permit
193.142.157.125 permit
193.142.157.158 permit
193.142.157.191 permit 193.142.157.191 permit
193.142.157.198 permit 193.142.157.198 permit
194.19.134.0/25 permit 194.19.134.0/25 permit
@@ -1773,7 +1859,6 @@
198.21.0.0/21 permit 198.21.0.0/21 permit
198.37.144.0/20 permit 198.37.144.0/20 permit
198.37.152.186 permit 198.37.152.186 permit
198.61.254.0/23 permit
198.61.254.21 permit 198.61.254.21 permit
198.61.254.231 permit 198.61.254.231 permit
198.178.234.57 permit 198.178.234.57 permit
@@ -1799,7 +1884,16 @@
199.16.156.0/22 permit 199.16.156.0/22 permit
199.33.145.1 permit 199.33.145.1 permit
199.33.145.32 permit 199.33.145.32 permit
199.34.22.36 permit
199.59.148.0/22 permit 199.59.148.0/22 permit
199.67.80.2 permit
199.67.80.20 permit
199.67.82.2 permit
199.67.82.20 permit
199.67.84.0/24 permit
199.67.86.0/24 permit
199.67.88.0/24 permit
199.67.90.0/24 permit
199.101.161.130 permit 199.101.161.130 permit
199.101.162.0/25 permit 199.101.162.0/25 permit
199.122.120.0/21 permit 199.122.120.0/21 permit
@@ -1851,13 +1945,17 @@
204.14.232.0/21 permit 204.14.232.0/21 permit
204.14.232.64/28 permit 204.14.232.64/28 permit
204.14.234.64/28 permit 204.14.234.64/28 permit
204.75.18.128/27 permit
204.75.142.0/24 permit 204.75.142.0/24 permit
204.79.197.212 permit
204.92.114.187 permit 204.92.114.187 permit
204.92.114.203 permit 204.92.114.203 permit
204.92.114.204/31 permit 204.92.114.204/31 permit
204.141.32.0/23 permit
204.141.42.0/23 permit
204.216.164.202 permit 204.216.164.202 permit
204.220.160.0/21 permit 204.220.90.0/23 permit
204.220.92.0/22 permit
204.220.160.0/20 permit
204.220.168.0/21 permit 204.220.168.0/21 permit
204.220.176.0/20 permit 204.220.176.0/20 permit
204.220.181.105 permit 204.220.181.105 permit
@@ -1878,23 +1976,13 @@
206.165.246.80/29 permit 206.165.246.80/29 permit
206.191.224.0/19 permit 206.191.224.0/19 permit
206.246.157.1 permit 206.246.157.1 permit
207.46.4.128/25 permit
207.46.22.35 permit 207.46.22.35 permit
207.46.50.72 permit 207.46.50.72 permit
207.46.50.82 permit 207.46.50.82 permit
207.46.50.192/26 permit
207.46.50.224 permit
207.46.52.71 permit 207.46.52.71 permit
207.46.52.79 permit 207.46.52.79 permit
207.46.58.128/25 permit
207.46.116.128/29 permit
207.46.132.128/27 permit
207.46.198.0/25 permit
207.46.200.0/27 permit
207.67.38.0/24 permit 207.67.38.0/24 permit
207.67.98.192/27 permit 207.67.98.192/27 permit
207.68.176.0/26 permit
207.68.176.96/27 permit
207.97.204.96/29 permit 207.97.204.96/29 permit
207.126.144.0/20 permit 207.126.144.0/20 permit
207.171.160.0/19 permit 207.171.160.0/19 permit
@@ -1902,6 +1990,9 @@
207.211.30.128/25 permit 207.211.30.128/25 permit
207.211.31.0/25 permit 207.211.31.0/25 permit
207.211.41.113 permit 207.211.41.113 permit
207.211.132.0/25 permit
207.211.147.87 permit
207.211.159.110 permit
207.218.90.0/24 permit 207.218.90.0/24 permit
207.218.90.122 permit 207.218.90.122 permit
207.250.68.0/24 permit 207.250.68.0/24 permit
@@ -1909,6 +2000,8 @@
208.43.21.28/30 permit 208.43.21.28/30 permit
208.43.21.64/29 permit 208.43.21.64/29 permit
208.43.21.72/30 permit 208.43.21.72/30 permit
208.56.9.224 permit
208.56.13.196 permit
208.64.132.0/22 permit 208.64.132.0/22 permit
208.71.40.63 permit 208.71.40.63 permit
208.71.40.64/31 permit 208.71.40.64/31 permit
@@ -1935,6 +2028,7 @@
208.71.42.214 permit 208.71.42.214 permit
208.72.249.240/29 permit 208.72.249.240/29 permit
208.75.120.0/22 permit 208.75.120.0/22 permit
208.76.62.0/23 permit
208.76.62.0/24 permit 208.76.62.0/24 permit
208.76.63.0/24 permit 208.76.63.0/24 permit
208.82.237.96/29 permit 208.82.237.96/29 permit
@@ -1948,7 +2042,6 @@
209.43.22.0/28 permit 209.43.22.0/28 permit
209.46.117.168 permit 209.46.117.168 permit
209.46.117.179 permit 209.46.117.179 permit
209.61.151.0/24 permit
209.61.151.236 permit 209.61.151.236 permit
209.61.151.249 permit 209.61.151.249 permit
209.61.151.251 permit 209.61.151.251 permit
@@ -1993,11 +2086,19 @@
212.82.111.228/31 permit 212.82.111.228/31 permit
212.82.111.230 permit 212.82.111.230 permit
212.123.28.40 permit 212.123.28.40 permit
212.227.15.3 permit
212.227.15.4 permit
212.227.15.5 permit
212.227.15.6 permit
212.227.15.7 permit 212.227.15.7 permit
212.227.15.8 permit 212.227.15.8 permit
212.227.15.14 permit
212.227.15.15 permit 212.227.15.15 permit
212.227.15.18 permit 212.227.15.18 permit
212.227.15.19 permit 212.227.15.19 permit
212.227.15.25 permit
212.227.15.26 permit
212.227.15.29 permit
212.227.15.44 permit 212.227.15.44 permit
212.227.15.45 permit 212.227.15.45 permit
212.227.15.46 permit 212.227.15.46 permit
@@ -2005,11 +2106,17 @@
212.227.15.50 permit 212.227.15.50 permit
212.227.15.52 permit 212.227.15.52 permit
212.227.15.53 permit 212.227.15.53 permit
212.227.15.54 permit
212.227.15.55 permit
212.227.17.1 permit 212.227.17.1 permit
212.227.17.2 permit 212.227.17.2 permit
212.227.17.7 permit 212.227.17.7 permit
212.227.17.11 permit
212.227.17.12 permit
212.227.17.16 permit 212.227.17.16 permit
212.227.17.17 permit 212.227.17.17 permit
212.227.17.18 permit
212.227.17.19 permit
212.227.17.20 permit 212.227.17.20 permit
212.227.17.21 permit 212.227.17.21 permit
212.227.17.22 permit 212.227.17.22 permit
@@ -2029,14 +2136,10 @@
212.227.126.225 permit 212.227.126.225 permit
212.227.126.226 permit 212.227.126.226 permit
212.227.126.227 permit 212.227.126.227 permit
213.95.19.64/27 permit
213.95.135.4 permit
213.199.128.139 permit 213.199.128.139 permit
213.199.128.145 permit 213.199.128.145 permit
213.199.138.181 permit 213.199.138.181 permit
213.199.138.191 permit 213.199.138.191 permit
213.199.161.128/27 permit
213.199.177.0/26 permit
216.17.150.242 permit 216.17.150.242 permit
216.17.150.251 permit 216.17.150.251 permit
216.24.224.0/20 permit 216.24.224.0/20 permit
@@ -2064,7 +2167,6 @@
216.39.62.60/31 permit 216.39.62.60/31 permit
216.39.62.136/29 permit 216.39.62.136/29 permit
216.39.62.144/31 permit 216.39.62.144/31 permit
216.58.192.0/19 permit
216.66.217.240/29 permit 216.66.217.240/29 permit
216.71.138.33 permit 216.71.138.33 permit
216.71.152.207 permit 216.71.152.207 permit
@@ -2086,6 +2188,7 @@
216.136.168.80/28 permit 216.136.168.80/28 permit
216.139.64.0/19 permit 216.139.64.0/19 permit
216.145.221.0/24 permit 216.145.221.0/24 permit
216.146.32.0/23 permit
216.146.32.0/24 permit 216.146.32.0/24 permit
216.146.33.0/24 permit 216.146.33.0/24 permit
216.198.0.0/18 permit 216.198.0.0/18 permit
@@ -2094,6 +2197,8 @@
216.205.24.0/24 permit 216.205.24.0/24 permit
216.221.160.0/19 permit 216.221.160.0/19 permit
216.239.32.0/19 permit 216.239.32.0/19 permit
217.72.192.77 permit
217.72.192.78 permit
217.77.141.52 permit 217.77.141.52 permit
217.77.141.59 permit 217.77.141.59 permit
217.175.194.0/24 permit 217.175.194.0/24 permit
@@ -2104,6 +2209,7 @@
223.165.120.0/23 permit 223.165.120.0/23 permit
2001:0868:0100:0600::/64 permit 2001:0868:0100:0600::/64 permit
2001:4860:4000::/36 permit 2001:4860:4000::/36 permit
2001:4860:4864::/56 permit
2001:748:100:40::2:0/112 permit 2001:748:100:40::2:0/112 permit
2001:748:400:1300::3 permit 2001:748:400:1300::3 permit
2001:748:400:1300::4 permit 2001:748:400:1300::4 permit
@@ -2121,12 +2227,13 @@
2001:748:400:3301::3 permit 2001:748:400:3301::3 permit
2001:748:400:3301::4 permit 2001:748:400:3301::4 permit
2404:6800:4000::/36 permit 2404:6800:4000::/36 permit
2603:1010:3:3::5b permit 2404:6800:4864::/56 permit
2603:1020:201:10::10f permit 2603:1061:14:1d0::1 permit
2603:1030:20e:3::23c permit 2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
2603:1030:b:3::152 permit 2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
2603:1030:c02:8::14 permit 2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
2607:f8b0:4000::/36 permit 2607:f8b0:4000::/36 permit
2607:f8b0:4864::/56 permit
2620:109:c003:104::/64 permit 2620:109:c003:104::/64 permit
2620:109:c003:104::215 permit 2620:109:c003:104::215 permit
2620:109:c006:104::/64 permit 2620:109:c006:104::/64 permit
@@ -2139,5 +2246,6 @@
2620:119:50c0:207::/64 permit 2620:119:50c0:207::/64 permit
2620:119:50c0:207::215 permit 2620:119:50c0:207::215 permit
2800:3f0:4000::/36 permit 2800:3f0:4000::/36 permit
2800:3f0:4864::/56 permit
49.12.4.251 permit # checks.mailcow.email 49.12.4.251 permit # checks.mailcow.email
2a01:4f8:c17:7906::10 permit # checks.mailcow.email 2a01:4f8:c17:7906::10 permit # checks.mailcow.email
@@ -3,8 +3,7 @@ rules {
backend = "http"; backend = "http";
url = "http://nginx:9081/pipe.php"; url = "http://nginx:9081/pipe.php";
selector = "reject_no_global_bl"; selector = "reject_no_global_bl";
formatter = "default"; formatter = "multipart";
meta_headers = true;
} }
RLINFO { RLINFO {
backend = "http"; backend = "http";
@@ -16,8 +15,7 @@ rules {
backend = "http"; backend = "http";
url = "http://nginx:9081/pushover.php"; url = "http://nginx:9081/pushover.php";
selector = "mailcow_rcpt"; selector = "mailcow_rcpt";
formatter = "json"; formatter = "multipart";
meta_headers = true;
} }
} }
+13 -12
View File
@@ -392,6 +392,7 @@ rspamd_config:register_symbol({
local rspamd_http = require "rspamd_http" local rspamd_http = require "rspamd_http"
local rcpts = task:get_recipients('smtp') local rcpts = task:get_recipients('smtp')
local lua_util = require "lua_util" local lua_util = require "lua_util"
local tagged_rcpt = task:get_symbol("TAGGED_RCPT")
local function remove_moo_tag() local function remove_moo_tag()
local moo_tag_header = task:get_header('X-Moo-Tag', false) local moo_tag_header = task:get_header('X-Moo-Tag', false)
@@ -416,12 +417,9 @@ rspamd_config:register_symbol({
-- Check if recipient has a tag (contains '+') -- Check if recipient has a tag (contains '+')
local tag = nil local tag = nil
if rcpt_user:find('%+') then if tagged_rcpt ~= nil then
local base_user, tag_part = rcpt_user:match('^(.-)%+(.+)$') tag = tagged_rcpt
if base_user and tag_part then rspamd_logger.infox("TAG_MOO: found tag in recipient: %s (base: %s, tag: %s)", rcpt_addr, base_user, tag)
tag = tag_part
rspamd_logger.infox("TAG_MOO: found tag in recipient: %s (base: %s, tag: %s)", rcpt_addr, base_user, tag)
end
end end
if not tag then if not tag then
@@ -500,7 +498,8 @@ rspamd_config:register_symbol({
else else
rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail") rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail")
local sbj = task:get_header('Subject') or '' local sbj = task:get_header('Subject') or ''
new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?=' local tag_value = tag[1] and tag[1].options and tag[1].options[1] or ''
new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag_value .. '] ' .. sbj)) .. '?='
task:set_milter_reply({ task:set_milter_reply({
remove_headers = { remove_headers = {
['Subject'] = 1, ['Subject'] = 1,
@@ -857,6 +856,8 @@ rspamd_config:register_symbol({
local seen_cte local seen_cte
local newline_s = newline(task) local newline_s = newline(task)
-- use the CTE add_text_footer actually applied
local new_cte = rewrite.new_cte or 'quoted-printable'
local function rewrite_ct_cb(name, hdr) local function rewrite_ct_cb(name, hdr)
if rewrite.need_rewrite_ct then if rewrite.need_rewrite_ct then
@@ -878,13 +879,13 @@ rspamd_config:register_symbol({
return return
elseif name:lower() == 'content-transfer-encoding' then elseif name:lower() == 'content-transfer-encoding' then
out[#out + 1] = string.format('%s: %s', out[#out + 1] = string.format('%s: %s',
'Content-Transfer-Encoding', 'quoted-printable') 'Content-Transfer-Encoding', new_cte)
-- update Content-Transfer-Encoding header -- update Content-Transfer-Encoding header
task:set_milter_reply({ task:set_milter_reply({
remove_headers = {['Content-Transfer-Encoding'] = 0}, remove_headers = {['Content-Transfer-Encoding'] = 0},
}) })
task:set_milter_reply({ task:set_milter_reply({
add_headers = {['Content-Transfer-Encoding'] = 'quoted-printable'} add_headers = {['Content-Transfer-Encoding'] = new_cte}
}) })
seen_cte = true seen_cte = true
return return
@@ -896,7 +897,7 @@ rspamd_config:register_symbol({
task:headers_foreach(rewrite_ct_cb, {full = true}) task:headers_foreach(rewrite_ct_cb, {full = true})
if not seen_cte and rewrite.need_rewrite_ct then if not seen_cte and rewrite.need_rewrite_ct then
out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', 'quoted-printable') out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', new_cte)
end end
-- End of headers -- End of headers
@@ -915,7 +916,7 @@ rspamd_config:register_symbol({
out_parts[#out_parts + 1] = o out_parts[#out_parts + 1] = o
out_parts[#out_parts + 1] = newline_s out_parts[#out_parts + 1] = newline_s
else else
local removePrefix = "--\x0D\x0AContent-Type" local removePrefix = "--\x0D\x0AContent-"
if string.lower(string.sub(tostring(o[1]), 1, string.len(removePrefix))) == string.lower(removePrefix) then if string.lower(string.sub(tostring(o[1]), 1, string.len(removePrefix))) == string.lower(removePrefix) then
o[1] = string.sub(tostring(o[1]), string.len("--\x0D\x0A") + 1) o[1] = string.sub(tostring(o[1]), string.len("--\x0D\x0A") + 1)
end end
@@ -945,4 +946,4 @@ rspamd_config:register_symbol({
return true return true
end, end,
priority = 1 priority = 1
}) })
+34 -39
View File
@@ -32,47 +32,42 @@ function parse_email($email) {
$a = strrpos($email, '@'); $a = strrpos($email, '@');
return array('local' => substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1)); return array('local' => substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1));
} }
if (!function_exists('getallheaders')) { // rspamd metadata_exporter (multipart formatter):
function getallheaders() { // - $_POST['metadata'] JSON with the rspamd metadata
if (!is_array($_SERVER)) { // - $_FILES['message'] raw RFC822 message
return array(); if (empty($_POST['metadata']) || !isset($_FILES['message']) || $_FILES['message']['error'] !== UPLOAD_ERR_OK) {
} error_log("QUARANTINE: missing multipart parts from rspamd" . PHP_EOL);
$headers = array(); http_response_code(400);
foreach ($_SERVER as $name => $value) { exit;
if (substr($name, 0, 5) == 'HTTP_') {
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
}
}
return $headers;
}
} }
$raw_data_content = file_get_contents('php://input'); $meta = json_decode($_POST['metadata'], true);
if (!is_array($meta)) {
error_log("QUARANTINE: cannot decode metadata JSON" . PHP_EOL);
http_response_code(400);
exit;
}
$raw_data_content = file_get_contents($_FILES['message']['tmp_name']);
$raw_data = mb_convert_encoding($raw_data_content, 'HTML-ENTITIES', "UTF-8"); $raw_data = mb_convert_encoding($raw_data_content, 'HTML-ENTITIES', "UTF-8");
$headers = getallheaders(); $raw_size = (int)$_FILES['message']['size'];
$qid = $headers['X-Rspamd-Qid']; $qid = $meta['qid'] ?? 'unknown';
$fuzzy = $headers['X-Rspamd-Fuzzy']; $subject = $meta['subject'] ?? '';
$subject = iconv_mime_decode($headers['X-Rspamd-Subject']); $score = $meta['score'] ?? 0;
$score = $headers['X-Rspamd-Score']; $rcpts = $meta['rcpt'] ?? array();
$rcpts = $headers['X-Rspamd-Rcpt']; $user = $meta['user'] ?? 'unknown';
$user = $headers['X-Rspamd-User']; $ip = $meta['ip'] ?? 'unknown';
$ip = $headers['X-Rspamd-Ip']; $action = $meta['action'] ?? 'no action';
$action = $headers['X-Rspamd-Action']; $sender = $meta['from'] ?? '';
$sender = $headers['X-Rspamd-From']; $symbols = json_encode($meta['symbols'] ?? array());
$symbols = $headers['X-Rspamd-Symbols']; $fuzzy = json_encode(is_array($meta['fuzzy'] ?? null) ? $meta['fuzzy'] : array());
$raw_size = (int)$_SERVER['CONTENT_LENGTH'];
if (empty($sender)) { if (empty($sender)) {
error_log("QUARANTINE: Unknown sender, assuming empty-env-from@localhost" . PHP_EOL); error_log("QUARANTINE: Unknown sender, assuming empty-env-from@localhost" . PHP_EOL);
$sender = 'empty-env-from@localhost'; $sender = 'empty-env-from@localhost';
} }
if ($fuzzy == 'unknown') {
$fuzzy = '[]';
}
try { try {
$max_size = (int)$redis->Get('Q_MAX_SIZE'); $max_size = (int)$redis->Get('Q_MAX_SIZE');
if (($max_size * 1048576) < $raw_size) { if (($max_size * 1048576) < $raw_size) {
@@ -94,7 +89,7 @@ catch (RedisException $e) {
$rcpt_final_mailboxes = array(); $rcpt_final_mailboxes = array();
// Loop through all rcpts // Loop through all rcpts
foreach (json_decode($rcpts, true) as $rcpt) { foreach ($rcpts as $rcpt) {
// Remove tag // Remove tag
$rcpt = preg_replace('/^(.*?)\+.*(@.*)$/', '$1$2', $rcpt); $rcpt = preg_replace('/^(.*?)\+.*(@.*)$/', '$1$2', $rcpt);
@@ -158,12 +153,12 @@ foreach (json_decode($rcpts, true) as $rcpt) {
// Loop through all found gotos // Loop through all found gotos
foreach ($gotos_array as $index => &$goto) { foreach ($gotos_array as $index => &$goto) {
error_log("RCPT RESOVLER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL); error_log("RCPT RESOLVER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
$stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :goto AND (`active`= '1' OR `active`= '2');"); $stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :goto AND (`active`= '1' OR `active`= '2');");
$stmt->execute(array(':goto' => $goto)); $stmt->execute(array(':goto' => $goto));
$username = $stmt->fetch(PDO::FETCH_ASSOC)['username']; $username = $stmt->fetch(PDO::FETCH_ASSOC)['username'];
if (!empty($username)) { if (!empty($username)) {
error_log("RCPT RESOVLER: http pipe: mailbox found: " . $username . PHP_EOL); error_log("RCPT RESOLVER: http pipe: mailbox found: " . $username . PHP_EOL);
// Current goto is a mailbox, save to rcpt_final_mailboxes if not a duplicate // Current goto is a mailbox, save to rcpt_final_mailboxes if not a duplicate
if (!in_array($username, $rcpt_final_mailboxes)) { if (!in_array($username, $rcpt_final_mailboxes)) {
$rcpt_final_mailboxes[] = $username; $rcpt_final_mailboxes[] = $username;
@@ -172,21 +167,21 @@ foreach (json_decode($rcpts, true) as $rcpt) {
else { else {
$parsed_goto = parse_email($goto); $parsed_goto = parse_email($goto);
if (!$redis->hGet('DOMAIN_MAP', $parsed_goto['domain'])) { if (!$redis->hGet('DOMAIN_MAP', $parsed_goto['domain'])) {
error_log("RCPT RESOVLER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL); error_log("RCPT RESOLVER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
} }
else { else {
$stmt = $pdo->prepare("SELECT `goto` FROM `alias` WHERE `address` = :goto AND `active` = '1'"); $stmt = $pdo->prepare("SELECT `goto` FROM `alias` WHERE `address` = :goto AND `active` = '1'");
$stmt->execute(array(':goto' => $goto)); $stmt->execute(array(':goto' => $goto));
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['goto']; $goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['goto'];
if ($goto_branch) { if ($goto_branch) {
error_log("RCPT RESOVLER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL); error_log("RCPT RESOLVER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
$goto_branch_array = explode(',', $goto_branch); $goto_branch_array = explode(',', $goto_branch);
} else { } else {
$stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'"); $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
$stmt->execute(array(':domain' => $parsed_goto['domain'])); $stmt->execute(array(':domain' => $parsed_goto['domain']));
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain']; $goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
if ($goto_branch) { if ($goto_branch) {
error_log("RCPT RESOVLER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL); error_log("RCPT RESOLVER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
$goto_branch_array = array($parsed_goto['local'] . '@' . $goto_branch); $goto_branch_array = array($parsed_goto['local'] . '@' . $goto_branch);
} }
} }
@@ -208,11 +203,11 @@ foreach (json_decode($rcpts, true) as $rcpt) {
// Force exit if loop cannot be solved // Force exit if loop cannot be solved
// Postfix does not allow for alias loops, so this should never happen. // Postfix does not allow for alias loops, so this should never happen.
$loop_c++; $loop_c++;
error_log("RCPT RESOVLER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL); error_log("RCPT RESOLVER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
} }
} }
catch (PDOException $e) { catch (PDOException $e) {
error_log("RCPT RESOVLER: " . $e->getMessage() . PHP_EOL); error_log("RCPT RESOLVER: " . $e->getMessage() . PHP_EOL);
http_response_code(502); http_response_code(502);
exit; exit;
} }
+29 -33
View File
@@ -32,50 +32,46 @@ function parse_email($email) {
$a = strrpos($email, '@'); $a = strrpos($email, '@');
return array('local' => substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1)); return array('local' => substr($email, 0, $a), 'domain' => substr(substr($email, $a), 1));
} }
if (!function_exists('getallheaders')) { // rspamd metadata_exporter (multipart formatter): metadata JSON arrives as $_POST['metadata'].
function getallheaders() { if (empty($_POST['metadata'])) {
if (!is_array($_SERVER)) { error_log("NOTIFY: missing metadata part from rspamd" . PHP_EOL);
return array(); http_response_code(400);
} exit;
$headers = array();
foreach ($_SERVER as $name => $value) {
if (substr($name, 0, 5) == 'HTTP_') {
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
}
}
return $headers;
}
} }
$headers = getallheaders(); $meta = json_decode($_POST['metadata'], true);
$json_body = json_decode(file_get_contents('php://input')); if (!is_array($meta)) {
error_log("NOTIFY: cannot decode metadata JSON" . PHP_EOL);
http_response_code(400);
exit;
}
$qid = $headers['X-Rspamd-Qid']; $qid = $meta['qid'] ?? 'unknown';
$rcpts = $headers['X-Rspamd-Rcpt']; $rcpts = $meta['rcpt'] ?? array();
$sender = $headers['X-Rspamd-From']; $sender = $meta['from'] ?? '';
$ip = $headers['X-Rspamd-Ip']; $ip = $meta['ip'] ?? 'unknown';
$subject = iconv_mime_decode($headers['X-Rspamd-Subject']); $subject = $meta['subject'] ?? '';
$messageid= $json_body->message_id; $messageid= $meta['message_id'] ?? '';
$priority = 0; $priority = 0;
$symbols_array = json_decode($headers['X-Rspamd-Symbols'], true); $symbols_array = $meta['symbols'] ?? array();
if (is_array($symbols_array)) { if (is_array($symbols_array)) {
foreach ($symbols_array as $symbol) { foreach ($symbols_array as $symbol) {
if ($symbol['name'] == 'HAS_X_PRIO_ONE') { if (($symbol['name'] ?? null) == 'HAS_X_PRIO_ONE') {
$priority = 1; $priority = 1;
break; break;
} }
} }
} }
$sender_address = $json_body->header_from[0]; $sender_address = $meta['header_from'][0] ?? '';
$sender_name = '-'; $sender_name = '-';
if (preg_match('/(?<name>.*?)<(?<address>.*?)>/i', $sender_address, $matches)) { if (preg_match('/(?<name>.*?)<(?<address>.*?)>/i', $sender_address, $matches)) {
$sender_address = $matches['address']; $sender_address = $matches['address'];
$sender_name = trim($matches['name'], '"\' '); $sender_name = trim($matches['name'], '"\' ');
} }
$to_address = $json_body->header_to[0]; $to_address = $meta['header_to'][0] ?? '';
$to_name = '-'; $to_name = '-';
if (preg_match('/(?<name>.*?)<(?<address>.*?)>/i', $to_address, $matches)) { if (preg_match('/(?<name>.*?)<(?<address>.*?)>/i', $to_address, $matches)) {
$to_address = $matches['address']; $to_address = $matches['address'];
@@ -85,7 +81,7 @@ if (preg_match('/(?<name>.*?)<(?<address>.*?)>/i', $to_address, $matches)) {
$rcpt_final_mailboxes = array(); $rcpt_final_mailboxes = array();
// Loop through all rcpts // Loop through all rcpts
foreach (json_decode($rcpts, true) as $rcpt) { foreach ($rcpts as $rcpt) {
// Remove tag // Remove tag
$rcpt = preg_replace('/^(.*?)\+.*(@.*)$/', '$1$2', $rcpt); $rcpt = preg_replace('/^(.*?)\+.*(@.*)$/', '$1$2', $rcpt);
@@ -143,12 +139,12 @@ foreach (json_decode($rcpts, true) as $rcpt) {
// Loop through all found gotos // Loop through all found gotos
foreach ($gotos_array as $index => &$goto) { foreach ($gotos_array as $index => &$goto) {
error_log("RCPT RESOVLER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL); error_log("RCPT RESOLVER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
$stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :goto AND (`active`= '1' OR `active`= '2');"); $stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :goto AND (`active`= '1' OR `active`= '2');");
$stmt->execute(array(':goto' => $goto)); $stmt->execute(array(':goto' => $goto));
$username = $stmt->fetch(PDO::FETCH_ASSOC)['username']; $username = $stmt->fetch(PDO::FETCH_ASSOC)['username'];
if (!empty($username)) { if (!empty($username)) {
error_log("RCPT RESOVLER: http pipe: mailbox found: " . $username . PHP_EOL); error_log("RCPT RESOLVER: http pipe: mailbox found: " . $username . PHP_EOL);
// Current goto is a mailbox, save to rcpt_final_mailboxes if not a duplicate // Current goto is a mailbox, save to rcpt_final_mailboxes if not a duplicate
if (!in_array($username, $rcpt_final_mailboxes)) { if (!in_array($username, $rcpt_final_mailboxes)) {
$rcpt_final_mailboxes[] = $username; $rcpt_final_mailboxes[] = $username;
@@ -157,21 +153,21 @@ foreach (json_decode($rcpts, true) as $rcpt) {
else { else {
$parsed_goto = parse_email($goto); $parsed_goto = parse_email($goto);
if (!$redis->hGet('DOMAIN_MAP', $parsed_goto['domain'])) { if (!$redis->hGet('DOMAIN_MAP', $parsed_goto['domain'])) {
error_log("RCPT RESOVLER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL); error_log("RCPT RESOLVER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
} }
else { else {
$stmt = $pdo->prepare("SELECT `goto` FROM `alias` WHERE `address` = :goto AND `active` = '1'"); $stmt = $pdo->prepare("SELECT `goto` FROM `alias` WHERE `address` = :goto AND `active` = '1'");
$stmt->execute(array(':goto' => $goto)); $stmt->execute(array(':goto' => $goto));
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['goto']; $goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['goto'];
if ($goto_branch) { if ($goto_branch) {
error_log("RCPT RESOVLER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL); error_log("RCPT RESOLVER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
$goto_branch_array = explode(',', $goto_branch); $goto_branch_array = explode(',', $goto_branch);
} else { } else {
$stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'"); $stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
$stmt->execute(array(':domain' => $parsed_goto['domain'])); $stmt->execute(array(':domain' => $parsed_goto['domain']));
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain']; $goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
if ($goto_branch) { if ($goto_branch) {
error_log("RCPT RESOVLER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL); error_log("RCPT RESOLVER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
$goto_branch_array = array($parsed_goto['local'] . '@' . $goto_branch); $goto_branch_array = array($parsed_goto['local'] . '@' . $goto_branch);
} }
} }
@@ -193,11 +189,11 @@ foreach (json_decode($rcpts, true) as $rcpt) {
// Force exit if loop cannot be solved // Force exit if loop cannot be solved
// Postfix does not allow for alias loops, so this should never happen. // Postfix does not allow for alias loops, so this should never happen.
$loop_c++; $loop_c++;
error_log("RCPT RESOVLER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL); error_log("RCPT RESOLVER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
} }
} }
catch (PDOException $e) { catch (PDOException $e) {
error_log("RCPT RESOVLER: " . $e->getMessage() . PHP_EOL); error_log("RCPT RESOLVER: " . $e->getMessage() . PHP_EOL);
http_response_code(502); http_response_code(502);
exit; exit;
} }
+1 -12
View File
@@ -2,18 +2,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') { protect_route(['admin']);
header('Location: /domainadmin/mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
header('Location: /admin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
+5 -2
View File
@@ -3,8 +3,11 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') { if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'admin') {
header('Location: /admin/dashboard'); // Only redirect to dashboard if NO pending actions
exit(); if (empty($_SESSION['pending_tfa_setup']) && empty($_SESSION['pending_pw_update'])) {
header('Location: /admin/dashboard');
exit();
}
} }
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') { elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') {
header('Location: /domainadmin/mailbox'); header('Location: /domainadmin/mailbox');
+5 -12
View File
@@ -2,24 +2,15 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') { protect_route(['admin']);
header('Location: /domainadmin/mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
header('Location: /admin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
$js_minifier->add('/web/js/site/imapsync.js');
$js_minifier->add('/web/js/site/groups.js');
$js_minifier->add('/web/js/site/mailbox.js'); $js_minifier->add('/web/js/site/mailbox.js');
$js_minifier->add('/web/js/presets/sieveMailbox.js'); $js_minifier->add('/web/js/presets/sieveMailbox.js');
$js_minifier->add('/web/js/site/pwgen.js'); $js_minifier->add('/web/js/site/pwgen.js');
@@ -49,10 +40,12 @@ $template_data = [
'global_filters' => mailbox('get', 'global_filter_details'), 'global_filters' => mailbox('get', 'global_filter_details'),
'domains' => $domains, 'domains' => $domains,
'mailboxes' => $mailboxes, 'mailboxes' => $mailboxes,
'mbox_templates' => mailbox('get', 'mailbox_templates'),
'lang_mailbox' => json_encode($lang['mailbox']), 'lang_mailbox' => json_encode($lang['mailbox']),
'lang_rl' => json_encode($lang['ratelimit']), 'lang_rl' => json_encode($lang['ratelimit']),
'lang_edit' => json_encode($lang['edit']), 'lang_edit' => json_encode($lang['edit']),
'lang_datatables' => json_encode($lang['datatables']), 'lang_datatables' => json_encode($lang['datatables']),
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
]; ];
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
+1 -13
View File
@@ -2,19 +2,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') { protect_route(['admin']);
header('Location: /domainadmin/mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
header('Location: /admin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$js_minifier->add('/web/js/site/queue.js'); $js_minifier->add('/web/js/site/queue.js');
+5 -16
View File
@@ -2,18 +2,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.admin.inc.php';
if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'domainadmin') { protect_route(['admin']);
header('Location: /domainadmin/mailbox');
exit();
}
elseif (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == 'user') {
header('Location: /user');
exit();
}
elseif (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != "admin") {
header('Location: /admin');
exit();
}
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/header.inc.php';
$_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
@@ -23,6 +12,7 @@ $fido2_data = fido2(array("action" => "get_friendly_names"));
$js_minifier->add('/web/js/site/admin.js'); $js_minifier->add('/web/js/site/admin.js');
$js_minifier->add('/web/js/presets/rspamd.js'); $js_minifier->add('/web/js/presets/rspamd.js');
$js_minifier->add('/web/js/site/pwgen.js'); $js_minifier->add('/web/js/site/pwgen.js');
$js_minifier->add('/web/js/site/identity.js');
// all domains // all domains
$domains = mailbox('get', 'domains'); $domains = mailbox('get', 'domains');
@@ -104,10 +94,6 @@ $template_data = [
'tfa_id' => @$_SESSION['tfa_id'], 'tfa_id' => @$_SESSION['tfa_id'],
'fido2_cid' => @$_SESSION['fido2_cid'], 'fido2_cid' => @$_SESSION['fido2_cid'],
'fido2_data' => $fido2_data, 'fido2_data' => $fido2_data,
'api' => [
'ro' => admin_api('ro', 'get'),
'rw' => admin_api('rw', 'get'),
],
'dkim_domains' => $dkim_domains, 'dkim_domains' => $dkim_domains,
'dkim_domains_with_keys' => $dkim_domains_with_keys, 'dkim_domains_with_keys' => $dkim_domains_with_keys,
'dkim_blind_domains' => $dkim_blind_domains, 'dkim_blind_domains' => $dkim_blind_domains,
@@ -126,7 +112,10 @@ $template_data = [
'logo_dark_specs' => customize('get', 'main_logo_dark_specs'), 'logo_dark_specs' => customize('get', 'main_logo_dark_specs'),
'ip_check' => customize('get', 'ip_check'), 'ip_check' => customize('get', 'ip_check'),
'custom_login' => customize('get', 'custom_login'), 'custom_login' => customize('get', 'custom_login'),
'ui_themes' => customize('get', 'ui_themes'),
'ui_theme' => customize('get', 'ui_theme'),
'password_complexity' => password_complexity('get'), 'password_complexity' => password_complexity('get'),
'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(),
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'], 'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
'cors_settings' => $cors_settings, 'cors_settings' => $cors_settings,
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', 'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
+2074 -161
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -29,8 +29,8 @@ header('Content-Type: application/xml');
<clientConfig version="1.1"> <clientConfig version="1.1">
<emailProvider id="<?=$mailcow_hostname; ?>"> <emailProvider id="<?=$mailcow_hostname; ?>">
<domain>%EMAILDOMAIN%</domain> <domain>%EMAILDOMAIN%</domain>
<displayName>A mailcow mail server</displayName> <displayName><?=$autodiscover_config['displayName']; ?></displayName>
<displayShortName>mail server</displayShortName> <displayShortName><?=$autodiscover_config['displayShortName']; ?></displayShortName>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname><?=$autodiscover_config['imap']['server']; ?></hostname> <hostname><?=$autodiscover_config['imap']['server']; ?></hostname>
+148 -85
View File
@@ -60,101 +60,31 @@ $pdo = new PDO($dsn, $database_user, $database_pass, $opt);
$iam_provider = identity_provider('init'); $iam_provider = identity_provider('init');
$iam_settings = identity_provider('get'); $iam_settings = identity_provider('get');
$login_user = strtolower(trim($_SERVER['PHP_AUTH_USER'])); // Passwordless autodiscover - no authentication required
$login_pass = trim(htmlspecialchars_decode($_SERVER['PHP_AUTH_PW'])); // Email will be extracted from the request body
$login_user = null;
$login_role = null;
if (empty($_SERVER['PHP_AUTH_USER']) || empty($_SERVER['PHP_AUTH_PW'])) { header("Content-Type: application/xml");
$json = json_encode( echo '<?xml version="1.0" encoding="utf-8" ?>' . PHP_EOL;
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: must be authenticated"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
header('WWW-Authenticate: Basic realm="' . $_SERVER['HTTP_HOST'] . '"');
header('HTTP/1.0 401 Unauthorized');
exit(0);
}
$login_role = check_login($login_user, $login_pass, array('eas' => TRUE));
if ($login_role === "user") {
header("Content-Type: application/xml");
echo '<?xml version="1.0" encoding="utf-8" ?>' . PHP_EOL;
?> ?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006"> <Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<?php <?php
if(!$data) { if(!$data) {
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $_SERVER['PHP_AUTH_USER'],
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: invalid or missing request data"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'msg' => 'Redis: '.$e
);
return false;
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="2477272013">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
try {
$discover = new SimpleXMLElement($data);
$email = $discover->Request->EMailAddress;
} catch (Exception $e) {
$email = $_SERVER['PHP_AUTH_USER'];
}
$username = trim($email);
try {
$stmt = $pdo->prepare("SELECT `name` FROM `mailbox` WHERE `username`= :username");
$stmt->execute(array(':username' => $username));
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
}
catch(PDOException $e) {
die("Failed to determine name from SQL");
}
if (!empty($MailboxData['name'])) {
$displayname = $MailboxData['name'];
}
else {
$displayname = $email;
}
try { try {
$json = json_encode( $json = json_encode(
array( array(
"time" => time(), "time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'], "ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $_SERVER['PHP_AUTH_USER'], "user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'], "ip" => $_SERVER['REMOTE_ADDR'],
"service" => $autodiscover_config['autodiscoverType'] "service" => "Error: invalid or missing request data"
) )
); );
$redis->lPush('AUTODISCOVER_LOG', $json); $redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100); $redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid request by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Invalid request by " . $_SERVER['REMOTE_ADDR']);
} }
catch (RedisException $e) { catch (RedisException $e) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
@@ -163,7 +93,143 @@ if ($login_role === "user") {
); );
return false; return false;
} }
if ($autodiscover_config['autodiscoverType'] == 'imap') { list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
try {
$discover = new SimpleXMLElement($data);
$email = $discover->Request->EMailAddress;
} catch (Exception $e) {
// If parsing fails, return error
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: could not parse email from request"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$redis->publish("F2B_CHANNEL", "Autodiscover: Malformed XML by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Malformed XML by " . $_SERVER['REMOTE_ADDR']);
}
catch (RedisException $e) {
// Silently fail
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
$username = trim((string)$email);
try {
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`username` = :username
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'");
$stmt->execute(array(':username' => $username));
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
}
catch(PDOException $e) {
// Database error - return error response with complete XML
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>500</ErrorCode>
<Message>Database Error</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
// Mailbox not found or not active - return generic error to prevent user enumeration
if (empty($MailboxData)) {
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: mailbox not found or inactive"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
}
catch (RedisException $e) {
// Silently fail
}
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>600</ErrorCode>
<Message>Invalid Request</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
if (!empty($MailboxData['name'])) {
$displayname = $MailboxData['name'];
}
else {
$displayname = $email;
}
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => $autodiscover_config['autodiscoverType']
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'msg' => 'Redis: '.$e
);
return false;
}
if ($autodiscover_config['autodiscoverType'] == 'imap') {
?> ?>
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a"> <Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
<User> <User>
@@ -238,6 +304,3 @@ if ($login_role === "user") {
} }
?> ?>
</Autodiscover> </Autodiscover>
<?php
}
?>
+8 -9
View File
@@ -1,10 +1,10 @@
/*! /*!
* Bootstrap-select v1.14.0-beta2 (https://developer.snapappointments.com/bootstrap-select) * Bootstrap-select v1.14.0-beta3 (https://developer.snapappointments.com/bootstrap-select)
* *
* Copyright 2012-2021 SnapAppointments, LLC * Copyright 2012-2022 SnapAppointments, LLC
* Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE) * Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE)
*/ */
@-webkit-keyframes bs-notify-fadeOut { @-webkit-keyframes bs-notify-fadeOut {
0% { 0% {
opacity: 0.9; opacity: 0.9;
@@ -35,8 +35,7 @@ select.selectpicker {
display: none !important; display: none !important;
} }
.bootstrap-select { .bootstrap-select {
width: 220px \0; width: 220px;
/*IE9 and below*/
vertical-align: middle; vertical-align: middle;
} }
.bootstrap-select > .dropdown-toggle { .bootstrap-select > .dropdown-toggle {
+5 -5
View File
@@ -17,15 +17,15 @@ table.dataTable>tbody>tr.child ul.dtr-details>li {
} }
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover, table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover { table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover {
background-color: #5e5e5e; background-color: var(--mc-dtr-hover-bg);
} }
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before,
table.dataTable td.dt-control:before { table.dataTable td.dt-control:before {
background-color: #979797 !important; background-color: var(--mc-dtr-bg) !important;
border: 1.5px solid #616161 !important; border: 1.5px solid var(--mc-dtr-border) !important;
border-radius: 2px !important; border-radius: 2px !important;
color: #fff; color: var(--mc-dtr-color);
height: 1em; height: 1em;
width: 1em; width: 1em;
line-height: 1.25em; line-height: 1.25em;
@@ -37,7 +37,7 @@ table.dataTable td.dt-control:before {
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before,
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before,
table.dataTable td.dt-control:before { table.dataTable td.dt-control:before {
background-color: #979797 !important; background-color: var(--mc-dtr-active-bg) !important;
} }
table.dataTable.table-striped>tbody>tr>td { table.dataTable.table-striped>tbody>tr>td {
vertical-align: middle; vertical-align: middle;
+82 -32
View File
@@ -34,11 +34,44 @@
url('/fonts/noto-sans-v12-latin_greek_cyrillic-700italic.woff') format('woff'); url('/fonts/noto-sans-v12-latin_greek_cyrillic-700italic.woff') format('woff');
} }
/* theme tokens, override in css/themes/<name>/theme.css */
:root {
--mc-font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
--mc-font-size: 10.5pt;
--mc-body-bg: #fbfbfb;
--mc-primary: #0F688D;
--mc-primary-border: #0d526d;
--mc-info: #148DBC;
--mc-info-border: #127ea8;
--mc-border: #dfdfdf;
--mc-surface: #fff;
--mc-surface-alt: #f7f7f7;
--mc-muted: #959595;
--mc-hr: #ccc;
--mc-progress-bg: #d5d5d5;
--mc-placeholder: rgb(137 137 137);
--mc-btn-outline-color: #000000;
--mc-btn-outline-border: #cfcfcf;
--mc-btn-outline-hover-bg: #f0f0f0;
--mc-btn-outline-active-bg: #f0f0f0;
--mc-btn-light-active-color: #fff;
--mc-btn-light-active-bg: #555;
--mc-btn-light-active-border: #4d4d4d;
--mc-dtr-bg: #979797;
--mc-dtr-border: #616161;
--mc-dtr-color: #fff;
--mc-dtr-hover-bg: #5e5e5e;
--mc-dtr-active-bg: #979797;
--mc-login-user: #28b62c;
--mc-login-domainadmin: #ffc107;
--mc-login-admin: #ff4136;
}
body { body {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #fbfbfb; background-color: var(--mc-body-bg);
} }
#maxmsgsize { min-width: 80px; } #maxmsgsize { min-width: 80px; }
#slider1 .slider-selection { #slider1 .slider-selection {
@@ -86,17 +119,17 @@ body {
max-height: 1000px max-height: 1000px
} }
.nav-tabs .nav-link, .nav-tabs .nav-link.disabled, .nav-tabs .nav-link.disabled:hover, .nav-tabs .nav-link.disabled:focus { .nav-tabs .nav-link, .nav-tabs .nav-link.disabled, .nav-tabs .nav-link.disabled:hover, .nav-tabs .nav-link.disabled:focus {
border-color: #dfdfdf; border-color: var(--mc-border);
} }
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { .nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link {
border-color: #dfdfdf; border-color: var(--mc-border);
border-bottom: 1px solid #ffffff; border-bottom: 1px solid var(--mc-surface);
} }
.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus { .nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {
border-color: #dfdfdf; border-color: var(--mc-border);
} }
.nav-tabs { .nav-tabs {
border-bottom: 1px solid #dfdfdf; border-bottom: 1px solid var(--mc-border);
} }
.bi { .bi {
display: inline-block; display: inline-block;
@@ -164,13 +197,13 @@ body.modal-open {
padding-right: inherit !important; padding-right: inherit !important;
} }
body { body {
font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif; font-family: var(--mc-font-family);
font-size: 10.5pt; font-size: var(--mc-font-size);
line-height: 1.5; line-height: 1.5;
} }
html { html {
font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif; font-family: var(--mc-font-family);
font-size: 10.5pt; font-size: var(--mc-font-size);
line-height: 1.5; line-height: 1.5;
} }
#mailcow-alert { #mailcow-alert {
@@ -206,7 +239,7 @@ legend {
cursor: not-allowed; cursor: not-allowed;
} }
.overlay { .overlay {
background: #fff; background: var(--mc-surface);
position: absolute; position: absolute;
z-index: 10000; z-index: 10000;
top: 0; right: 0; bottom: 0; left: 0; top: 0; right: 0; bottom: 0; left: 0;
@@ -215,9 +248,6 @@ legend {
.bootstrap-select.btn-group .no-results { .bootstrap-select.btn-group .no-results {
display: none; display: none;
} }
.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
color: rgb(197, 197, 197) !important;
}
.haveibeenpwned { .haveibeenpwned {
cursor: pointer; cursor: pointer;
-webkit-user-select: none; -webkit-user-select: none;
@@ -239,7 +269,7 @@ legend {
.footer { .footer {
margin-top: 27px; margin-top: 27px;
margin-bottom: 20px; margin-bottom: 20px;
color: #959595; color: var(--mc-muted);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -337,7 +367,7 @@ code {
overflow-x: auto!important; overflow-x: auto!important;
} }
.well { .well {
border: 1px solid #dfdfdf; border: 1px solid var(--mc-border);
background-color: #f9f9f9; background-color: #f9f9f9;
padding: 10px; padding: 10px;
} }
@@ -356,47 +386,47 @@ button[aria-expanded='true'] > .caret {
} }
.list-group-details { .list-group-details {
background: #fff; background: var(--mc-surface);
} }
.list-group-header { .list-group-header {
background: #f7f7f7; background: var(--mc-surface-alt);
} }
.bg-primary, .alert-primary, .btn-primary { .bg-primary, .alert-primary, .btn-primary {
background-color: #0F688D !important; background-color: var(--mc-primary) !important;
border-color: #0d526d !important; border-color: var(--mc-primary-border) !important;
} }
.bg-info, .alert-info, .btn-info { .bg-info, .alert-info, .btn-info {
background-color: #148DBC !important; background-color: var(--mc-info) !important;
border-color: #127ea8 !important; border-color: var(--mc-info-border) !important;
} }
.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary { .bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
color: rgb(137 137 137)!important; color: var(--mc-placeholder) !important;
} }
.progress { .progress {
height: 16px; height: 16px;
background-color: #d5d5d5; background-color: var(--mc-progress-bg);
} }
.btn-outline-secondary:hover { .btn-outline-secondary:hover {
background-color: #f0f0f0; background-color: var(--mc-btn-outline-hover-bg);
} }
.btn.btn-outline-secondary { .btn.btn-outline-secondary {
color: #000000 !important; color: var(--mc-btn-outline-color) !important;
border-color: #cfcfcf !important; border-color: var(--mc-btn-outline-border) !important;
} }
.btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show { .btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show {
background-color: #f0f0f0 !important; background-color: var(--mc-btn-outline-active-bg) !important;
} }
.btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle { .btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle {
color: #fff; color: var(--mc-btn-light-active-color);
background-color: #555; background-color: var(--mc-btn-light-active-bg);
background-image: none; background-image: none;
border-color: #4d4d4d; border-color: var(--mc-btn-light-active-border);
} }
.btn-check:checked+.btn-light:focus, .btn-check:active+.btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, .show>.btn-light.dropdown-toggle:focus, .btn-check:checked+.btn-light:focus, .btn-check:active+.btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, .show>.btn-light.dropdown-toggle:focus,
.btn-check:focus+.btn-light, .btn-light:focus { .btn-check:focus+.btn-light, .btn-light:focus {
@@ -412,6 +442,26 @@ button[aria-expanded='true'] > .caret {
text-decoration: none; text-decoration: none;
} }
/* role marker on login pages, header icons differ too (color blindness) */
.login-card::before {
content: "";
display: block;
height: 5px;
background: var(--mc-login-color);
border-radius: inherit;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.login-user {
--mc-login-color: var(--mc-login-user);
}
.login-domainadmin {
--mc-login-color: var(--mc-login-domainadmin);
}
.login-admin {
--mc-login-color: var(--mc-login-admin);
}
.hr-title { .hr-title {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -423,7 +473,7 @@ button[aria-expanded='true'] > .caret {
.hr-title::after { .hr-title::after {
content: ""; content: "";
flex: 1; flex: 1;
border-bottom: 1px solid #ccc; border-bottom: 1px solid var(--mc-hr);
} }
.hr-title:not(:empty)::before { .hr-title:not(:empty)::before {
File diff suppressed because it is too large. Load diff
+155 -114
View File
@@ -1,62 +1,103 @@
/* theme tokens, override in css/themes/<name>/dark.css */
:root {
--mc-body-bg: #1c1c1e;
--mc-body-color: #f2f2f7;
--mc-surface: #2c2c2e;
--mc-surface-alt: #282828;
--mc-text: #ccc;
--mc-text-soft: #bbb;
--mc-muted: #8e8e93;
--mc-border: #555;
--mc-tab-border: #444444;
--mc-link: #6fc7e9;
--mc-link-hover: #3daedb;
--mc-btn-bg: #5e5e5e;
--mc-btn-border: #4c4c4e;
--mc-input-bg: #424242;
--mc-input-color: #e2e2e2;
--mc-input-border: #999;
--mc-dropdown-bg: #424242;
--mc-table-bg: #3a3a3a;
--mc-table-stripe: #282828;
--mc-table-stripe-row: #424242;
--mc-table-hover: #343434;
--mc-table-border: #5c5c5c;
--mc-progress-bg: #555;
--mc-placeholder: #d4d4d4;
--mc-btn-outline-color: #e0e0e0;
--mc-btn-outline-border: #7a7a7a;
--mc-btn-outline-hover-bg: #5c5c5c;
--mc-btn-outline-active-bg: #7a7a7a;
--mc-btn-light-active-color: #f2f2f7;
--mc-btn-light-active-bg: #242424;
--mc-btn-light-active-border: #1c1c1e;
--mc-btn-dark-bg: #242424;
--mc-input-disabled-bg: #6e6e6e;
--mc-input-disabled-color: #a8a8a8;
--mc-list-header-bg: #444;
--mc-list-details-bg: #555;
--mc-chip-bg: #444;
--mc-table-child-bg: #414141;
--mc-dtr-bg: #7a7a7a;
--mc-dtr-border: #5c5c5c;
--mc-dtr-color: #e0e0e0;
--mc-dtr-hover-bg: #7a7a7a;
--mc-dtr-active-bg: #949494;
}
body { body {
background-color: #1c1c1e; background-color: var(--mc-body-bg);
color: #f2f2f7; color: var(--mc-body-color);
} }
.card { .card {
border: 1px solid #2c2c2e; border: 1px solid var(--mc-surface);
background-color: #2c2c2e; background-color: var(--mc-surface);
} }
legend { legend {
color: #f2f2f7; color: var(--mc-body-color);
} }
.card-header { .card-header {
color: #8e8e93; color: var(--mc-muted);
background-color: #1c1c1e; background-color: var(--mc-body-bg);
border-color: transparent; border-color: transparent;
} }
.card-body { .card-body {
--bs-card-color: #bbb; --bs-card-color: var(--mc-text-soft);
} }
.btn-secondary, .paginate_button, .page-link, .btn-light { .btn-secondary, .paginate_button, .page-link, .btn-light {
color: #f2f2f7 !important; color: var(--mc-body-color) !important;
background-color: #5e5e5e !important; background-color: var(--mc-btn-bg) !important;
border-color: #4c4c4e !important; border-color: var(--mc-btn-border) !important;
} }
.btn-dark { .btn-dark {
color: #f2f2f7 !important; color: var(--mc-body-color) !important;
background-color: #242424 !important; background-color: var(--mc-btn-dark-bg) !important;
border-color: #1c1c1e !important; border-color: var(--mc-body-bg) !important;
}
.btn-secondary:focus, .btn-secondary:hover, .btn-group.open .dropdown-toggle.btn-secondary {
background-color: #444444;
border-color: #4c4c4e !important;
color: #f2f2f7;
} }
.btn-check:checked+.btn-secondary, .btn-check:active+.btn-secondary, .btn-secondary:active, .btn-secondary.active, .show>.btn-secondary.dropdown-toggle { .btn-check:checked+.btn-secondary, .btn-check:active+.btn-secondary, .btn-secondary:active, .btn-secondary.active, .show>.btn-secondary.dropdown-toggle {
border-color: #5e5e5e !important; border-color: var(--mc-btn-bg) !important;
} }
.alert-secondary { .alert-secondary {
color: #f2f2f7 !important; color: var(--mc-body-color) !important;
background-color: #5e5e5e !important; background-color: var(--mc-btn-bg) !important;
border-color: #4c4c4e !important; border-color: var(--mc-btn-border) !important;
} }
.bg-secondary { .bg-secondary {
color: #f2f2f7 !important; color: var(--mc-body-color) !important;
background-color: #5e5e5e !important; background-color: var(--mc-btn-bg) !important;
} }
.alert-secondary, .alert-secondary a, .alert-secondary .alert-link { .alert-secondary, .alert-secondary a, .alert-secondary .alert-link {
color: #f2f2f7; color: var(--mc-body-color);
} }
.page-item.active .page-link { .page-item.active .page-link {
@@ -65,18 +106,18 @@ legend {
} }
.btn-secondary:focus, .btn-secondary:hover, .btn-group.open .dropdown-toggle.btn-secondary { .btn-secondary:focus, .btn-secondary:hover, .btn-group.open .dropdown-toggle.btn-secondary {
background-color: #5e5e5e; background-color: var(--mc-btn-bg);
border-color: #4c4c4e !important; border-color: var(--mc-btn-border) !important;
color: #f2f2f7; color: var(--mc-body-color);
} }
.btn-secondary:disabled, .btn-secondary.disabled { .btn-secondary:disabled, .btn-secondary.disabled {
border-color: #5e5e5e !important; border-color: var(--mc-btn-bg) !important;
} }
.modal-content { .modal-content {
--bs-modal-color: #bbb; --bs-modal-color: var(--mc-text-soft);
background-color: #2c2c2e; background-color: var(--mc-surface);
} }
.modal-header { .modal-header {
@@ -84,7 +125,7 @@ legend {
} }
.modal-title { .modal-title {
color: #bbb; color: var(--mc-text-soft);
} }
.modal .btn-close { .modal .btn-close {
@@ -92,12 +133,12 @@ legend {
} }
.navbar.bg-light { .navbar.bg-light {
background-color: #1c1c1e !important; background-color: var(--mc-body-bg) !important;
border-color: #2c2c2e; border-color: var(--mc-surface);
} }
.nav-link { .nav-link {
color: #8e8e93 !important; color: var(--mc-muted) !important;
} }
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link { .nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link {
@@ -105,23 +146,23 @@ legend {
} }
.nav-tabs, .nav-tabs .nav-link { .nav-tabs, .nav-tabs .nav-link {
border-color: #444444 !important; border-color: var(--mc-tab-border) !important;
} }
.nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus, .nav-tabs .nav-link.active { .nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus, .nav-tabs .nav-link.active {
border-bottom-color: #1c1c1e !important; border-bottom-color: var(--mc-body-bg) !important;
} }
.card .nav-tabs .nav-link:not(.disabled):hover, .card .nav-tabs .nav-link:not(.disabled):focus, .card .nav-tabs .nav-link.active { .card .nav-tabs .nav-link:not(.disabled):hover, .card .nav-tabs .nav-link:not(.disabled):focus, .card .nav-tabs .nav-link.active {
border-bottom-color: #2c2c2e !important; border-bottom-color: var(--mc-surface) !important;
} }
.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr { .table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
color: #f2f2f7 !important; color: var(--mc-body-color) !important;
} }
.dropdown-menu { .dropdown-menu {
background-color: #424242; background-color: var(--mc-dropdown-bg);
border: 1px solid #282828; border: 1px solid #282828;
} }
.dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover { .dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover {
@@ -132,79 +173,79 @@ legend {
color: #fff; color: #fff;
} }
.bootstrap-select>.dropdown-toggle.bs-placeholder, .bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary { .bootstrap-select>.dropdown-toggle.bs-placeholder, .bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
color: #d4d4d4 !important; color: var(--mc-placeholder) !important;
} }
tbody tr { tbody tr {
color: #ccc; color: var(--mc-text);
} }
.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:focus, .navbar-default .navbar-nav>.open>a:hover { .navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:focus, .navbar-default .navbar-nav>.open>a:hover {
color: #ccc; color: var(--mc-text);
} }
.navbar-default .navbar-nav>.active>a, .navbar-default .navbar-nav>.active>a:focus, .navbar-default .navbar-nav>.active>a:hover { .navbar-default .navbar-nav>.active>a, .navbar-default .navbar-nav>.active>a:focus, .navbar-default .navbar-nav>.active>a:hover {
color: #ccc; color: var(--mc-text);
} }
.list-group-item { .list-group-item {
background-color: #282828; background-color: var(--mc-surface-alt);
border: 1px solid #555; border: 1px solid var(--mc-border);
} }
.table-striped>tbody>tr:nth-of-type(odd) { .table-striped>tbody>tr:nth-of-type(odd) {
background-color: #424242; background-color: var(--mc-table-stripe-row);
} }
table.dataTable>tbody>tr.child ul.dtr-details>li { table.dataTable>tbody>tr.child ul.dtr-details>li {
border-bottom: 1px solid rgba(255, 255, 255, 0.13); border-bottom: 1px solid rgba(255, 255, 255, 0.13);
} }
.label.label-last-login { .label.label-last-login {
color: #ccc !important; color: var(--mc-text) !important;
background-color: #555 !important; background-color: #555 !important;
} }
.progress { .progress {
background-color: #555; background-color: var(--mc-progress-bg);
} }
.h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 { .h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 {
color: #ccc; color: var(--mc-text);
} }
div.numberedtextarea-number { div.numberedtextarea-number {
color: #999; color: #999;
} }
.well { .well {
border: 1px solid #555; border: 1px solid var(--mc-border);
background-color: #282828; background-color: var(--mc-surface-alt);
} }
pre { pre {
color: #ccc; color: var(--mc-text);
background-color: #282828; background-color: var(--mc-surface-alt);
border: 1px solid #555; border: 1px solid var(--mc-border);
} }
.form-control { .form-control {
background-color: transparent; background-color: transparent;
} }
input.form-control, textarea.form-control { input.form-control, textarea.form-control {
color: #e2e2e2 !important; color: var(--mc-input-color) !important;
background-color: #424242 !important; background-color: var(--mc-input-bg) !important;
border: 1px solid #999; border: 1px solid var(--mc-input-border);
} }
input.form-control:focus, textarea.form-control { input.form-control:focus, textarea.form-control {
background-color: #424242 !important; background-color: var(--mc-input-bg) !important;
} }
input.form-control:disabled, textarea.form-disabled { input.form-control:disabled, textarea.form-disabled {
color: #a8a8a8 !important; color: var(--mc-input-disabled-color) !important;
background-color: #6e6e6e !important; background-color: var(--mc-input-disabled-bg) !important;
} }
.input-group-addon { .input-group-addon {
color: #ccc; color: var(--mc-text);
background-color: #424242 !important; background-color: var(--mc-input-bg) !important;
border: 1px solid #999; border: 1px solid var(--mc-input-border);
} }
.input-group-text { .input-group-text {
color: #ccc; color: var(--mc-text);
background-color: #1c1c1c; background-color: #1c1c1c;
} }
.list-group-item { .list-group-item {
color: #ccc; color: var(--mc-text);
} }
.dropdown-item { .dropdown-item {
color: #ccc; color: var(--mc-text);
} }
.dropdown-item:hover { .dropdown-item:hover {
color: #616161 !important; color: #616161 !important;
@@ -214,9 +255,9 @@ input.form-control:disabled, textarea.form-disabled {
background-color: #007aff; background-color: #007aff;
} }
.form-select { .form-select {
color: #e2e2e2!important; color: var(--mc-input-color)!important;
background-color: #424242!important; background-color: var(--mc-input-bg)!important;
border: 1px solid #999; border: 1px solid var(--mc-input-border);
} }
.responsive-tabs .card-header button[data-bs-toggle="collapse"] { .responsive-tabs .card-header button[data-bs-toggle="collapse"] {
@@ -240,15 +281,15 @@ input.form-control:disabled, textarea.form-disabled {
a { a {
color: #6fc7e9; color: var(--mc-link);
text-decoration: underline; text-decoration: underline;
} }
a:hover { a:hover {
color: #3daedb; color: var(--mc-link-hover);
} }
.breadcrumb-item.active { .breadcrumb-item.active {
color: #ccc; color: var(--mc-text);
} }
@@ -273,11 +314,11 @@ a:hover {
background-color: #3a3a3a; background-color: #3a3a3a;
} }
.accordion-button { .accordion-button {
color: #bbb; color: var(--mc-text-soft);
background-color: #2c2c2c; background-color: #2c2c2c;
} }
.accordion-button:not(.collapsed) { .accordion-button:not(.collapsed) {
color: #6fc7e9; color: var(--mc-link);
background-color: #2c2c2c; background-color: #2c2c2c;
} }
.accordion-button::after { .accordion-button::after {
@@ -300,15 +341,15 @@ a:hover {
} }
.tag-box { .tag-box {
background-color: #282828; background-color: var(--mc-surface-alt);
border: 1px solid #555; border: 1px solid var(--mc-border);
} }
.tag-input { .tag-input {
color: #fff; color: #fff;
background-color: #282828; background-color: var(--mc-surface-alt);
} }
.tag-add { .tag-add {
color: #ccc; color: var(--mc-text);
} }
.tag-add:hover { .tag-add:hover {
color: #d1d1d1; color: #d1d1d1;
@@ -318,19 +359,19 @@ a:hover {
color: #fff; color: #fff;
} }
.btn-outline-secondary:hover { .btn-outline-secondary:hover {
background-color: #5c5c5c; background-color: var(--mc-btn-outline-hover-bg);
} }
.btn.btn-outline-secondary { .btn.btn-outline-secondary {
color: #e0e0e0 !important; color: var(--mc-btn-outline-color) !important;
border-color: #7a7a7a !important; border-color: var(--mc-btn-outline-border) !important;
} }
.btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show { .btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show {
background-color: #7a7a7a !important; background-color: var(--mc-btn-outline-active-bg) !important;
} }
.btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle { .btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle {
color: #f2f2f7 !important; color: var(--mc-btn-light-active-color) !important;
background-color: #242424 !important; background-color: var(--mc-btn-light-active-bg) !important;
border-color: #1c1c1e !important; border-color: var(--mc-btn-light-active-border) !important;
} }
.btn-input-missing, .btn-input-missing,
.btn-input-missing:hover, .btn-input-missing:hover,
@@ -348,16 +389,16 @@ a:hover {
} }
.list-group-details { .list-group-details {
background: #555; background: var(--mc-list-details-bg);
} }
.list-group-header { .list-group-header {
background: #444; background: var(--mc-list-header-bg);
} }
span.mail-address-item { span.mail-address-item {
background-color: #444; background-color: var(--mc-chip-bg);
border-radius: 4px; border-radius: 4px;
border: 1px solid #555; border: 1px solid var(--mc-border);
padding: 2px 7px; padding: 2px 7px;
display: inline-block; display: inline-block;
margin: 2px 6px 2px 0; margin: 2px 6px 2px 0;
@@ -365,52 +406,52 @@ span.mail-address-item {
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover, table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover { table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover {
background-color: #7a7a7a !important; background-color: var(--mc-dtr-hover-bg) !important;
} }
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before { table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before {
background-color: #7a7a7a !important; background-color: var(--mc-dtr-bg) !important;
border: 1.5px solid #5c5c5c !important; border: 1.5px solid var(--mc-dtr-border) !important;
color: #e0e0e0 !important; color: var(--mc-dtr-color) !important;
} }
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before, table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before,
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before { table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before {
background-color: #949494; background-color: var(--mc-dtr-active-bg);
} }
table.dataTable.dtr-inline.collapsed>tbody>tr>td.child, table.dataTable.dtr-inline.collapsed>tbody>tr>td.child,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.child, table.dataTable.dtr-inline.collapsed>tbody>tr>th.child,
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dataTables_empty { table.dataTable.dtr-inline.collapsed>tbody>tr>td.dataTables_empty {
background-color: #414141; background-color: var(--mc-table-child-bg);
} }
table.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr { table.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
color: #ccc !important; color: var(--mc-text) !important;
} }
.table-secondary { .table-secondary {
--bs-table-bg: #282828; --bs-table-bg: #282828;
--bs-table-striped-bg: #343434; --bs-table-striped-bg: #343434;
--bs-table-striped-color: #f2f2f7; --bs-table-striped-color: var(--mc-body-color);
--bs-table-active-bg: #4c4c4c; --bs-table-active-bg: #4c4c4c;
--bs-table-active-color: #f2f2f7; --bs-table-active-color: var(--mc-body-color);
--bs-table-hover-bg: #3a3a3a; --bs-table-hover-bg: #3a3a3a;
--bs-table-hover-color: #f2f2f7; --bs-table-hover-color: var(--mc-body-color);
color: #ccc; color: var(--mc-text);
border-color: #3a3a3a; border-color: #3a3a3a;
} }
.table-light { .table-light {
--bs-table-bg: #3a3a3a; --bs-table-bg: #3a3a3a;
--bs-table-striped-bg: #444444; --bs-table-striped-bg: #444444;
--bs-table-striped-color: #f2f2f7; --bs-table-striped-color: var(--mc-body-color);
--bs-table-active-bg: #5c5c5c; --bs-table-active-bg: #5c5c5c;
--bs-table-active-color: #f2f2f7; --bs-table-active-color: var(--mc-body-color);
--bs-table-hover-bg: #4c4c4c; --bs-table-hover-bg: #4c4c4c;
--bs-table-hover-color: #f2f2f7; --bs-table-hover-color: var(--mc-body-color);
color: #ccc; color: var(--mc-text);
border-color: #4c4c4c; border-color: #4c4c4c;
} }
@@ -430,23 +471,23 @@ table.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
.table-striped>tbody>tr:nth-of-type(odd)>td, .table-striped>tbody>tr:nth-of-type(odd)>td,
.table-striped>tbody>tr:nth-of-type(odd)>th { .table-striped>tbody>tr:nth-of-type(odd)>th {
background-color: #282828; background-color: var(--mc-table-stripe);
} }
.table-hover>tbody>tr:hover { .table-hover>tbody>tr:hover {
background-color: #343434; background-color: var(--mc-table-hover);
} }
.table>:not(caption)>*>* { .table>:not(caption)>*>* {
border-color: #5c5c5c; border-color: var(--mc-table-border);
--bs-table-color-state:#bbb; --bs-table-color-state:var(--mc-text-soft);
--bs-table-bg: #3a3a3a; --bs-table-bg: var(--mc-table-bg);
} }
.text-muted { .text-muted {
--bs-secondary-color: #8e8e93; --bs-secondary-color: var(--mc-muted);
} }
input::placeholder { input::placeholder {
color: #8e8e93 !important; color: var(--mc-muted) !important;
} }
.form-select { .form-select {
+212
View File
@@ -0,0 +1,212 @@
/* servercow theme, dark mode, bundled after mailcow core dark mode */
:root {
--sc-brown-hover: #806b55;
--sc-brown-active: #8a745c;
--sc-brown-soft: rgba(201, 180, 154, .18);
--sc-brown-tint: rgba(201, 180, 154, .08);
--sc-link: #c9b49a;
--sc-danger-text: #ff8a8a;
--sc-ink: #ece7e1;
--sc-muted: #a89f94;
--sc-placeholder: #8a8177;
--sc-bg: #171513;
--sc-surface: #211e1b;
--sc-cap: #2a2622;
--sc-addon: #2a2622;
--sc-line: #463f38;
--sc-divider: #34302b;
--sc-cap-line: #3d3732;
--sc-stripe: rgba(255, 255, 255, .025);
--sc-hover: rgba(201, 180, 154, .08);
--sc-focus-border: #8a7864;
--sc-focus: rgba(201, 180, 154, .25);
--sc-shadow: 0 0 10px 0 rgba(0, 0, 0, .5);
--bs-body-color: #ece7e1;
--bs-body-bg: #171513;
/* core dark tokens, see css/themes/mailcow-darkmode.css */
--mc-body-bg: var(--sc-bg);
--mc-body-color: var(--sc-ink);
--mc-surface: var(--sc-surface);
--mc-surface-alt: var(--sc-cap);
--mc-text: var(--sc-ink);
--mc-text-soft: var(--sc-ink);
--mc-muted: var(--sc-muted);
--mc-border: var(--sc-divider);
--mc-tab-border: var(--sc-divider);
--mc-link: var(--sc-link);
--mc-link-hover: #e0cfba;
--mc-btn-bg: var(--sc-cap);
--mc-btn-border: var(--sc-line);
--mc-input-bg: var(--sc-bg);
--mc-input-color: var(--sc-ink);
--mc-input-border: var(--sc-line);
--mc-dropdown-bg: var(--sc-surface);
--mc-table-bg: transparent;
--mc-table-stripe: var(--sc-stripe);
--mc-table-stripe-row: transparent;
--mc-table-hover: var(--sc-hover);
--mc-table-border: var(--sc-divider);
--mc-progress-bg: var(--sc-cap);
--mc-placeholder: var(--sc-placeholder);
--mc-hr: var(--sc-divider);
--mc-btn-outline-color: var(--sc-ink);
--mc-btn-outline-border: var(--sc-line);
--mc-btn-outline-hover-bg: var(--sc-brown-tint);
--mc-btn-outline-active-bg: var(--sc-brown);
--mc-btn-light-active-color: #fff;
--mc-btn-light-active-bg: var(--sc-brown);
--mc-btn-light-active-border: var(--sc-brown);
--mc-btn-dark-bg: var(--sc-brown);
--mc-input-disabled-bg: var(--sc-cap);
--mc-input-disabled-color: var(--sc-muted);
--mc-list-header-bg: var(--sc-cap);
--mc-list-details-bg: var(--sc-surface);
--mc-chip-bg: var(--sc-cap);
--mc-table-child-bg: var(--sc-surface);
--mc-dtr-bg: var(--sc-cap);
--mc-dtr-border: var(--sc-line);
--mc-dtr-color: var(--sc-link);
--mc-dtr-hover-bg: var(--sc-hover);
--mc-dtr-active-bg: var(--sc-brown-soft);
}
/* navbar */
.navbar.bg-light {
background-color: var(--sc-surface) !important;
border-bottom-color: var(--sc-green);
}
.navbar-light .navbar-nav .nav-link {
color: var(--sc-ink) !important;
}
.navbar-light .navbar-nav .nav-link:hover,
.navbar-light .navbar-nav .nav-link:focus,
.navbar-light .navbar-nav .nav-link.active,
.navbar-light .navbar-nav .show > .nav-link {
color: var(--sc-link) !important;
}
.dropdown-menu {
border-color: var(--sc-cap-line);
}
.dropdown-item:hover, .dropdown-item:focus {
color: var(--sc-link) !important;
}
/* cards and modals */
.card-header {
color: var(--sc-ink);
background-color: var(--sc-cap);
border-color: var(--sc-cap-line);
}
.modal-content {
background-color: var(--sc-surface);
}
.modal-header {
border-color: var(--sc-cap-line);
}
.input-group-text {
background-color: var(--sc-addon);
}
.form-control::file-selector-button {
color: var(--sc-ink);
background-color: var(--sc-cap);
border-color: var(--sc-line);
}
.form-control:hover:not(:disabled):not([readonly])::file-selector-button {
color: var(--sc-link);
background-color: var(--sc-line);
}
.form-check-input:not(:checked) {
background-color: var(--sc-bg);
border-color: var(--sc-line);
}
kbd {
color: var(--sc-ink);
background-color: var(--sc-cap);
}
/* buttons */
.btn-secondary:hover, .btn-secondary:focus, .btn-light:hover, .btn-light:focus, .page-link:hover {
color: var(--sc-link) !important;
background-color: var(--sc-line) !important;
}
.btn-check:checked + .btn-secondary, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle,
.page-item.active .page-link {
color: #fff !important;
background-color: var(--sc-brown) !important;
border-color: var(--sc-brown) !important;
}
/* tabs */
.nav-tabs .nav-link {
color: var(--sc-ink) !important;
border-bottom-color: transparent !important;
}
.nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus,
.card .nav-tabs .nav-link:not(.disabled):hover, .card .nav-tabs .nav-link:not(.disabled):focus {
color: var(--sc-link) !important;
border-bottom-color: transparent !important;
}
.nav-tabs .nav-link.active, .card .nav-tabs .nav-link.active {
color: var(--sc-link) !important;
border-bottom-color: var(--sc-link) !important;
}
/* semantic text */
.text-success {
color: var(--sc-green) !important;
}
.text-warning {
color: var(--sc-warning) !important;
}
/* alerts, opaque dark tints (also used for floating notifications) */
.alert-primary {
color: #e6d9c9 !important;
background-color: #3c342b !important;
border-color: #5e4f3e !important;
}
.alert-info {
color: #cfe9f6 !important;
background-color: #2f3334 !important;
border-color: #44535a !important;
}
.alert-success {
color: #cdeea0;
background-color: #2e3419;
border-color: #405515;
}
.alert-danger {
color: #f9c4c4;
background-color: #402a28;
border-color: #693a38;
}
.alert-warning {
color: #f5d496;
background-color: #3c2f19;
border-color: #654815;
}
.alert-secondary, .alert-light {
color: var(--sc-ink) !important;
background-color: var(--sc-cap) !important;
border-color: var(--sc-divider) !important;
}
.alert .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* slider */
.noUi-target {
background: var(--sc-bg);
border-color: var(--sc-line);
box-shadow: none;
}
.noUi-handle {
background: var(--sc-cap);
border-color: var(--sc-line);
box-shadow: none;
}
.noUi-handle::before, .noUi-handle::after {
background: var(--sc-line);
}
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,93 @@
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Loaded 100 of 1944 files, more files were not shown because too many files have changed in this diff. Show more