mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-06-12 17:40:25 +00:00
Compare commits
1096 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 689d753264 | |||
| 4ddcee28e4 | |||
| b0d16bbcee | |||
| 9175e5f086 | |||
| ff3d571054 | |||
| a8e945f3da | |||
| 33547d1d73 | |||
| 539c32d99c | |||
| d42c3823c4 | |||
| 886dbcc419 | |||
| dc15994d40 | |||
| ec24825280 | |||
| 5a00b5124b | |||
| 8c039f694f | |||
| 95bf46c1e4 | |||
| edde35156d | |||
| 84e3c32f13 | |||
| ecb848493b | |||
| 8a65b9d1c6 | |||
| ed9264fd2a | |||
| 7817dda43f | |||
| 018e292854 | |||
| 127fb1e8f5 | |||
| 09f09cb850 | |||
| d4bf377a96 | |||
| abd6fe8c79 | |||
| 5f8382ef44 | |||
| 03eccd4e42 | |||
| 1da8d1c894 | |||
| d1feebf164 | |||
| 293b885a85 | |||
| 1e0850193a | |||
| 33acf56526 | |||
| bea9ad7e8f | |||
| e7ea3aa608 | |||
| 5888e248c3 | |||
| 2e176339ba | |||
| 8f883f3d37 | |||
| 709117fe19 | |||
| 82ea418423 | |||
| fd24163c6e | |||
| 3caef45a12 | |||
| 8760e7e5db | |||
| e848226062 | |||
| efaeb77e13 | |||
| 569b4cf985 | |||
| 6c857243ec | |||
| 905e93627c | |||
| 598ea21827 | |||
| 6012cf4486 | |||
| fe71f84c82 | |||
| 763ecbc93e | |||
| 97312c1a9d | |||
| baf0e7e658 | |||
| a4ca5f9363 | |||
| dcbea71e67 | |||
| 524f19ff77 | |||
| 4e33c7143f | |||
| 4c184b25f4 | |||
| ddc2309f1e | |||
| bccfcefd17 | |||
| d40252ccce | |||
| 4abb5cbfab | |||
| b695936273 | |||
| d9463c7950 | |||
| 579542381a | |||
| ce5659f300 | |||
| 1967cb642f | |||
| ba0eb04ebe | |||
| 609ce6b0d6 | |||
| af61e2d303 | |||
| c6e3f517e1 | |||
| 6eaa8d43e9 | |||
| c033cd6254 | |||
| 7562578b74 | |||
| 43f570e761 | |||
| 5e478a32df | |||
| 22c94a4d2f | |||
| 99dc0f6616 | |||
| 4732f568fa | |||
| 96d4802cb2 | |||
| ad5b94af5e | |||
| 7fce984cac | |||
| 404e2f0190 | |||
| 1c52eaa3a4 | |||
| c7e04b4146 | |||
| 075959aea9 | |||
| 885ba2510e | |||
| 87563249f1 | |||
| fb1686065d | |||
| 0428f5c9bd | |||
| a70c23065c | |||
| caaa4a414d | |||
| c3d841340c | |||
| 2e8897c2cf | |||
| b8cd00111f | |||
| 81cda80651 | |||
| c1d4f04c22 | |||
| 82276cd1ca | |||
| 56ea4302ed | |||
| c06112b26e | |||
| aa5a4f0998 | |||
| bf4f471cfd | |||
| 978bff9dbc | |||
| 869d9af7dd | |||
| af10499ecb | |||
| a1a4d8ff98 | |||
| 95d61e8aa2 | |||
| ec8dd1a54f | |||
| 382ee34d0e | |||
| 0999c9e9ab | |||
| c485968e7f | |||
| e727620bd3 | |||
| 71fa3ecebc | |||
| 70101d1187 | |||
| c060c205d3 | |||
| 5ca900749c | |||
| b005803fe0 | |||
| ec77406dba | |||
| ee15721550 | |||
| 038b2efb75 | |||
| 1fe4cd03e9 | |||
| 12e02e67ff | |||
| b6f57dfb78 | |||
| 3ebf2c2d2d | |||
| 1bac6f1ee7 | |||
| 67e7acd6bd | |||
| 910ce573d6 | |||
| 689336b3e1 | |||
| 01cf72cdef | |||
| 4cdb97c699 | |||
| 1bd795a9c6 | |||
| 39f29e6c30 | |||
| 1ab6af21e3 | |||
| 5d95c48e0d | |||
| dbb9e474b0 | |||
| f8eed8c786 | |||
| ef010aa39c | |||
| 79171ea6f5 | |||
| 4e3294b273 | |||
| 32a6ecddb6 | |||
| f3d9833ecf | |||
| 930ca76ea7 | |||
| 9a2887cf46 | |||
| 9950914086 | |||
| 470cfb0026 | |||
| 6c106b4e4d | |||
| 3d6253a2b2 | |||
| b873812588 | |||
| 514fefd2ed | |||
| 6f9ee2d151 | |||
| 9832006141 | |||
| 890295bbfc | |||
| 0413d26855 | |||
| 7b29c1f304 | |||
| ae3ef391ee | |||
| 7313f996d3 | |||
| 62d16c9e56 | |||
| a52e977b89 | |||
| 674b41ce08 | |||
| 1b833be760 | |||
| 88adb1adf5 | |||
| ec472f13cf | |||
| 2e1d98cc7c | |||
| 07d7e3dc30 | |||
| b0f5aee628 | |||
| d3065612fd | |||
| 9912e41f78 | |||
| 04200c99a4 | |||
| 45666d2c4e | |||
| 9a806e64ce | |||
| 95e0608749 | |||
| 22a09b9795 | |||
| 04d5c43550 | |||
| fbcb8cbeb9 | |||
| 0338a36ecf | |||
| 23fb5e2fca | |||
| 3507ff2773 | |||
| a4970397f1 | |||
| 4132f6bd48 | |||
| 586b3a2ed1 | |||
| 6af2addf3c | |||
| f6eed6c441 | |||
| b85837c803 | |||
| 653fc40d4c | |||
| c17d80a6fd | |||
| 980bfa3aa0 | |||
| 664a954393 | |||
| d5a27c4ccb | |||
| 6a8a2e2136 | |||
| b859a52b8e | |||
| 10e0c42eff | |||
| f47df263d7 | |||
| 2642d9109e | |||
| 6708b94ebb | |||
| 79cf0abc6e | |||
| 7de70322d6 | |||
| 417835dea8 | |||
| 3dcacc4187 | |||
| 69f0552d4f | |||
| c443a9400a | |||
| 5c9f387d94 | |||
| e9414d17e4 | |||
| 6bfa58611e | |||
| df4d3bb6e0 | |||
| e31b6d9a07 | |||
| 455ef084b4 | |||
| c2948735f2 | |||
| 24c62b2f09 | |||
| 1ef0149076 | |||
| 922d173540 | |||
| fd088cb504 | |||
| 721ee2394e | |||
| c217be06c6 | |||
| 871c422ec1 | |||
| 3cc28af607 | |||
| 796e131c3a | |||
| dd160cd508 | |||
| 732b321962 | |||
| c51a769aec | |||
| 45a61755a5 | |||
| 769c57c355 | |||
| 2e7eb7c0fd | |||
| 4c83147d01 | |||
| ca0bec4fc2 | |||
| 6f50dd17da | |||
| 4a331929d0 | |||
| 748bc893b6 | |||
| e462602ddc | |||
| 4e0f435d12 | |||
| 46f0581936 | |||
| 20f04ecf6b | |||
| ff43799763 | |||
| 85ca197615 | |||
| d06d23bbaf | |||
| 702ed85dfd | |||
| 8abe74a562 | |||
| 2f8a181281 | |||
| 5c5287ca21 | |||
| 83ba8d5840 | |||
| ce219668cf | |||
| 5b1b49a418 | |||
| 8978a9ad79 | |||
| 5f4a4fd759 | |||
| 171c591da4 | |||
| 9133b9899c | |||
| 701c9fb1b4 | |||
| eabd22188b | |||
| 7028619742 | |||
| c915bf2ee2 | |||
| 011edd5ac9 | |||
| 7ba3de4ced | |||
| 8ead77083f | |||
| b2774fb50b | |||
| 4440bd46ad | |||
| 28985973eb | |||
| f2c4697ca3 | |||
| 383b5affb5 | |||
| ed4dcff63b | |||
| caca32bbba | |||
| d31e74c778 | |||
| 6c00e29276 | |||
| 9940c503a2 | |||
| 4b2862cb3c | |||
| a36485f0f1 | |||
| 78168ee80a | |||
| 610609378f | |||
| 260906e350 | |||
| 2891bbf82a | |||
| eb26bcbc94 | |||
| ef0f366d1c | |||
| 84e230de8f | |||
| f67a12d157 | |||
| 34b48eedfc | |||
| 0d900d4fc8 | |||
| 642ac6d02c | |||
| 4db1569c93 | |||
| 94c1a6c4e1 | |||
| 7ce3b0faed | |||
| 262fe04286 | |||
| b1c088a57f | |||
| 1c438330c6 | |||
| 8cb25709ae | |||
| 221f2989b0 | |||
| 3d05207bc7 | |||
| 8c8497d885 | |||
| 56d083ced4 | |||
| a90b3544a7 | |||
| 08aea7fb26 | |||
| 13f7f9830b | |||
| 2f75039194 | |||
| 1e192e14f4 | |||
| 9cd1f931fc | |||
| 8d7235b535 | |||
| 8446abd484 | |||
| f67c0530f5 | |||
| 06db1d6a72 | |||
| 81775ab4d5 | |||
| 34877ecf9c | |||
| dbde144014 | |||
| 5361a4a4ee | |||
| 0997548d7f | |||
| 921de02a2b | |||
| 48e90a72dc | |||
| c0b7a98e6c | |||
| 6dc90186f9 | |||
| 0b0a65a3f3 | |||
| 6c5d82c4df | |||
| 5e66ffa366 | |||
| 4d88e19106 | |||
| 29e28b47ed | |||
| 1cb38bacdb | |||
| 169aafec50 | |||
| 3826c4b5be | |||
| e1410baaeb | |||
| c39712af67 | |||
| 53c35493a5 | |||
| af871fdacb | |||
| 2b93b59cdd | |||
| 2b2da1679e | |||
| 8cdb0b869e | |||
| 1e42b8dd21 | |||
| 842cb235b6 | |||
| e91d678bd1 | |||
| ef5739c32f | |||
| 88bf9b02e1 | |||
| 3803b5d351 | |||
| 14d58c8163 | |||
| 728fcdb375 | |||
| 1fc36263dc | |||
| 69420113f7 | |||
| 360fe03497 | |||
| 7557802933 | |||
| 2e9ba1e9b3 | |||
| 795bcdc5d2 | |||
| ad9b328ed5 | |||
| 3d5b57889a | |||
| 6b8e981bdc | |||
| 2f1eb4b004 | |||
| 3ee3d7d969 | |||
| 95eb350f15 | |||
| 1e5fcfe392 | |||
| 527f27d249 | |||
| 02557b2098 | |||
| 4c7a9ed195 | |||
| d5b30a7a08 | |||
| b7acef4d9d | |||
| fc43c26c48 | |||
| b12ce1eacd | |||
| ec6dbb099a | |||
| 2fbbbbe9a9 | |||
| 1e4f3c55d8 | |||
| a0f5454c2a | |||
| 4e7adacda9 | |||
| 4c64cf18a6 | |||
| 8a89f5c685 | |||
| cc0e4fee9d | |||
| 5861c9af29 | |||
| dd475c0ab3 | |||
| 407e9d3584 | |||
| d4f899b091 | |||
| 372923ae2f | |||
| 3bd01190bf | |||
| 1994b9895b | |||
| 03d979c089 | |||
| 798e6a4c00 | |||
| ffa2933873 | |||
| 7f47a3f00e | |||
| 1bcab9a9a5 | |||
| 1b2f424edc | |||
| 486b297409 | |||
| 75d7f06b25 | |||
| ea0944d743 | |||
| cb6ffe65c8 | |||
| 580dabd276 | |||
| 846862aa80 | |||
| e7a1f24c78 | |||
| 8ff0e029f0 | |||
| 0680b21938 | |||
| 0c8e7bfeca | |||
| badcd27b93 | |||
| 7d3ef3d67f | |||
| 5b89e253a6 | |||
| a90f4c2a2e | |||
| db7b917944 | |||
| 401b744808 | |||
| 0c83255573 | |||
| d55f0fc366 | |||
| 06b3ba91a0 | |||
| aa4125fe62 | |||
| d8c6ed9191 | |||
| cb47fa406f | |||
| c4d0f35008 | |||
| 0d3e8dd738 | |||
| 692355a08a | |||
| a370499aaa | |||
| 84f67d6608 | |||
| 4ac839cf49 | |||
| b96a5b1efd | |||
| 766c5e8580 | |||
| 3f493e043d | |||
| 3ddad9dee8 | |||
| 2c10c39bc4 | |||
| 0eb8f38792 | |||
| 402bf53a5c | |||
| 428a59dd3f | |||
| 153890b283 | |||
| a741c2ba4a | |||
| 741e5c719f | |||
| 34e4f93db9 | |||
| 3758135dc3 | |||
| 6794e6ff43 | |||
| 62f816e64a | |||
| e65478076b | |||
| ceeabded73 | |||
| 805634f9a9 | |||
| a92832d115 | |||
| 4c5f485587 | |||
| db3a577ae3 | |||
| e452917de9 | |||
| f37961b7d0 | |||
| 0157cbddaf | |||
| 65d872cc14 | |||
| 4ad2422810 | |||
| 2c47145dee | |||
| 9b41b24522 | |||
| 1c9d80f554 | |||
| 7172cad257 | |||
| b550c6f88e | |||
| 5baf9eb375 | |||
| 4eb89f67ed | |||
| efdc798238 | |||
| 8408b82e9c | |||
| 65fb4c2aa8 | |||
| a5ca3353da | |||
| 95aa35e133 | |||
| 21b11ed999 | |||
| 348107dae8 | |||
| fcb1b29c89 | |||
| 05fc4f7aba | |||
| cd3b1ab828 | |||
| c3c68360dc | |||
| d584dd387e | |||
| 986b0afbfa | |||
| 59d139bc63 | |||
| ad5f07f077 | |||
| cf2d3c1b4e | |||
| 91c82e8a67 | |||
| ba7437a8f3 | |||
| 684256b66e | |||
| 70ba361583 | |||
| 94d4817ecb | |||
| 72ced70e33 | |||
| 887b7114a8 | |||
| ceebc56e62 | |||
| 8910135f02 | |||
| 463e3ab78c | |||
| 2a15914324 | |||
| e21696ff27 | |||
| 5a7275843a | |||
| c93106f9d6 | |||
| 43c1597051 | |||
| c3aa4f7418 | |||
| cb08132a74 | |||
| 2596b9d386 | |||
| 062539b7d7 | |||
| 18acbc7a4c | |||
| 2f93f1d0c5 | |||
| 0860a7503e | |||
| 86df78255d | |||
| aac0a900ce | |||
| 03565df48d | |||
| 5f15475b55 | |||
| 25d34b5acf | |||
| 6b165887d8 | |||
| 82eb3c64cd | |||
| bc21e7fe50 | |||
| 6f9c8deab7 | |||
| 8761d8fc47 | |||
| 0435766c17 | |||
| 79f4cf4021 | |||
| 81803836f0 | |||
| 4bd267515a | |||
| 70190e5230 | |||
| a632980871 | |||
| 5296085189 | |||
| a4c2cf4c67 | |||
| 2d1ef41d32 | |||
| 3c9d0c9d57 | |||
| 35a6f81d0d | |||
| 4b31c04e3e | |||
| 3d9cc2f6dd | |||
| 704dd50262 | |||
| 8d0c03b2fc | |||
| c4a0e370b7 | |||
| b77ff2f51c | |||
| 787fa49d0c | |||
| a6c38590ca | |||
| e52323bf1d | |||
| f15ee39b63 | |||
| fcebe98557 | |||
| 6ec5e88793 | |||
| 7d35646342 | |||
| 321965adee | |||
| 7bce5d836b | |||
| 351f4ce787 | |||
| a567d5dc31 | |||
| 4ac541f671 | |||
| f6dc0b463f | |||
| 16e22e23dc | |||
| d8afa6f393 | |||
| 836e3f15b7 | |||
| aaa7e4a184 | |||
| 3912341b32 | |||
| 735d5f0e56 | |||
| f375794fb7 | |||
| 4ed3017a02 | |||
| ef2f5f7be0 | |||
| 54728bf780 | |||
| 743e88fd67 | |||
| ac2f0c7db1 | |||
| f64c6aa1d4 | |||
| e2cf22ff9e | |||
| 55dcae4a01 | |||
| f0016eeecd | |||
| 120366fec7 | |||
| 3544a2246e | |||
| 97890b71f1 | |||
| e645f931dc | |||
| bbdec0960a | |||
| 41ba7d97fa | |||
| 83fc2c6387 | |||
| aac4c6b5f4 | |||
| 3c0f775e2f | |||
| 3a81b84cf7 | |||
| a2e87e0880 | |||
| 2407aa7895 | |||
| 244d4b8c4c | |||
| 0ad327bbe5 | |||
| f92ddd86c5 | |||
| 1a087bb2c8 | |||
| 65bc581fab | |||
| 60a2270d1e | |||
| cb5cae3e44 | |||
| 8ed51e500f | |||
| aca01c8aa2 | |||
| 45d14254f2 | |||
| de6bd222fc | |||
| 04116982a5 | |||
| 36d4fcbf39 | |||
| ba0349a911 | |||
| 04058ab06e | |||
| 9d791d0c4f | |||
| 8caf09cd80 | |||
| da02e26172 | |||
| 43f945fe01 | |||
| e76c0ba9a6 | |||
| d83111568e | |||
| 1b578caabb | |||
| 1e77f8d8a1 | |||
| 5f45f8ae34 | |||
| 1dac8f1f66 | |||
| 5a04942d89 | |||
| a30f6696a3 | |||
| d430b595c1 | |||
| 1fca328266 | |||
| 7bcd61ecb5 | |||
| ee7a8624fc | |||
| 4708b1398b | |||
| 746915cbdd | |||
| 36db68677c | |||
| 08599c1960 | |||
| 31e001ebee | |||
| 1e70a20188 | |||
| 8048e0a53c | |||
| 8fea9fc21f | |||
| 2f1884e94b | |||
| 24b3d8f850 | |||
| d280025b51 | |||
| abd789f629 | |||
| 69f6a82905 | |||
| 10328981b6 | |||
| 150b2bbd9d | |||
| 40a8bc808a | |||
| d92aa4b15d | |||
| 2d2dacb70e | |||
| ade20d79d4 | |||
| 65bc8f0972 | |||
| c6f6eda0bf | |||
| 357a4d7fb3 | |||
| 1c6684a539 | |||
| de80c120c9 | |||
| 3e8bb06a37 | |||
| b087ac9e27 | |||
| d09e4ff020 | |||
| f065842402 | |||
| 3875e8377a | |||
| 7c8e5c10ca | |||
| 1a8e1a2677 | |||
| 0d635e2658 | |||
| 60ca25026d | |||
| 69b03791a2 | |||
| ed2837edd8 | |||
| fa3b789fbb | |||
| 49e05f5120 | |||
| 24453993f3 | |||
| 8853e2c44a | |||
| c9dd102741 | |||
| d1af52b4e7 | |||
| bbddfc3eab | |||
| a41bb55c83 | |||
| b6174fae23 | |||
| 1d6513ffba | |||
| 6e8e13cebc | |||
| 896a9638d6 | |||
| 83e53eb524 | |||
| f36184df64 | |||
| 6fa1c9f63d | |||
| f3060b37a6 | |||
| 59c68f2603 | |||
| ccc8595665 | |||
| 45c13c687b | |||
| d61a08c2a9 | |||
| c8c4cfd939 | |||
| ec4b9b088c | |||
| b2db8e6b31 | |||
| 05e4bd7602 | |||
| 31185e3de1 | |||
| 4dbfd3abad | |||
| b4e6002bcf | |||
| 6af907cff0 | |||
| ba282233ea | |||
| 6f4c2b3361 | |||
| d08b9aec32 | |||
| bb310600b2 | |||
| fe7211f27f | |||
| 8e9a9364a8 | |||
| 6831f94fdb | |||
| b0de756a7c | |||
| 922f8777b0 | |||
| c1903f121d | |||
| 89fb1322c6 | |||
| 852d944cfb | |||
| bca4e1a03d | |||
| 326a446f8b | |||
| 70ca5fde95 | |||
| 5ad4ab5b60 | |||
| bd9f4ba0a5 | |||
| d10d64dd92 | |||
| 6d1f7482ed | |||
| b9f52df3f1 | |||
| dc379267a9 | |||
| 4d688c5500 | |||
| b90375b6e5 | |||
| 9542698e95 | |||
| afe0ba74d2 | |||
| dc5a28111d | |||
| 52f3f93aee | |||
| 6550f0a3e8 | |||
| 0a58aa293a | |||
| be79f320d2 | |||
| 6ec1e357c3 | |||
| 8b2f71f97e | |||
| 93cf99cc9e | |||
| d8c8e4ab1b | |||
| 2d76ffc88c | |||
| 672bb345fd | |||
| 5c88030b5a | |||
| b106945c73 | |||
| 502a7100ca | |||
| ee2791d93a | |||
| 399630cf34 | |||
| fce93609dd | |||
| 38907b5032 | |||
| 5a0f20b9ea | |||
| 8dcaffe925 | |||
| c53bf85480 | |||
| 982e823c71 | |||
| 382056ec18 | |||
| 4c9690e87c | |||
| 9a58e5e35a | |||
| 932cf453de | |||
| 1538fda71c | |||
| 54a0d53deb | |||
| fda95301ba | |||
| f9304dcd9b | |||
| 1528e8766a | |||
| 0b9b8c9060 | |||
| 220fdbb168 | |||
| fe3d08515e | |||
| 22f7f61ac9 | |||
| 0d2046baeb | |||
| 29d8cfe2ba | |||
| f2e35dff68 | |||
| b1368d29d1 | |||
| 0d704a57f5 | |||
| 462137ede7 | |||
| bb6f405841 | |||
| 8b2d67169b | |||
| 710cec996c | |||
| 0129f84a32 | |||
| ae3653a925 | |||
| 82fcddb177 | |||
| 320bd31d37 | |||
| b307e0a0d5 | |||
| af0c61b90a | |||
| 7203735532 | |||
| ef238e5332 | |||
| 4f9e37c0c3 | |||
| d21c1bfa72 | |||
| 822d9a7de6 | |||
| 37beed6ad9 | |||
| 0066040bdc | |||
| 75f18df143 | |||
| 8e7b27aae4 | |||
| c62b467ac4 | |||
| be5a181be5 | |||
| dbf87e99fc | |||
| 10dfd0a443 | |||
| cc5138da13 | |||
| 89398c4726 | |||
| aeeac63e1f | |||
| 8971b11c49 | |||
| bb7fd483f7 | |||
| 439a936fd8 | |||
| ffcd242048 | |||
| 567ebbc324 | |||
| f9a7712025 | |||
| 3d62869664 | |||
| e21157c10d | |||
| b70bcd36fb | |||
| cb50d08605 | |||
| f3da8bb85f | |||
| 12e4d639f0 | |||
| eb3f88fc91 | |||
| 9a729d89bf | |||
| fa3c453d6e | |||
| 962ac39e4a | |||
| 74b4097ee0 | |||
| e00d0d5f8d | |||
| c5e399ebc2 | |||
| cb9ca772b1 | |||
| ebc8e6b838 | |||
| 162f05ccda | |||
| 6c97c4f372 | |||
| 1fc964d72e | |||
| 5571d80ae6 | |||
| 6d4fcacd83 | |||
| 1994f706c0 | |||
| e34afd3fdd | |||
| a6f71faf46 | |||
| 3396e1b427 | |||
| b26ccc2019 | |||
| 58a5a4578c | |||
| b1c1e403d2 | |||
| 519d95cb8b | |||
| 092d3cd80b | |||
| c034f4bd27 | |||
| 8753ea2be6 | |||
| 9fee568082 | |||
| 73d60eb085 | |||
| b39b7c24a5 | |||
| 294a406b91 | |||
| 8b933f1967 | |||
| d0ecb72e08 | |||
| 824a473fea | |||
| 7f790c5360 | |||
| 52431a3942 | |||
| 8017394e9d | |||
| 772d5c51fd | |||
| 76194be7dd | |||
| 3b23afa0ff | |||
| 6e00d653ce | |||
| b6c036496d | |||
| 5d7c9b20bc | |||
| 4b400eadb1 | |||
| ab2abda8cc | |||
| 2fe21e9641 | |||
| b7ed6982d8 | |||
| fd927853cb | |||
| c48f4f4ab8 | |||
| a4c006828e | |||
| b56291f62b | |||
| 0cdf7647c4 | |||
| 8fe1cc4961 | |||
| bf050f17c4 | |||
| edd85dea8d | |||
| 3bf90c1f73 | |||
| 292306b191 | |||
| b3e0a66222 | |||
| e994cf4d05 | |||
| cc0dc2eae0 | |||
| a001a0584f | |||
| 926af87cfb | |||
| b0339372b5 | |||
| e398cb91e9 | |||
| 6ee0303b0f | |||
| 68616c2d57 | |||
| f8de520d29 | |||
| 10077ece31 | |||
| c918726143 | |||
| 3885b07a99 | |||
| fcf27d640d | |||
| 82fde23cc1 | |||
| 9b86ff764e | |||
| cbca306fc1 | |||
| 6a8986fe4f | |||
| ff34eb12e2 | |||
| 57bc03b878 | |||
| fbecd60e56 | |||
| c37bf0bb32 | |||
| 2208d7e6fb | |||
| e426c3a7e7 | |||
| 03fccb28e9 | |||
| 8fbfd99dd6 | |||
| 7f7a869678 | |||
| 73257151c4 | |||
| efb2572f0f | |||
| 66aa28b5de | |||
| 987a027339 | |||
| eea81e21f6 | |||
| a689109f44 | |||
| 58c0a46459 | |||
| 2dbe8bf4ca | |||
| ef7ec06947 | |||
| fc7ea7a247 | |||
| 9b478b3859 | |||
| 384e5a2e64 | |||
| aadeeb0df3 | |||
| f33d82ffc1 | |||
| ffeeb179e1 | |||
| 8e2d3a6db5 | |||
| 70126e1f0c | |||
| b9ae174a6a | |||
| 9715c57314 | |||
| b9f8959d92 | |||
| 9c814cc182 | |||
| cf6594220c | |||
| 2cf952eb36 | |||
| 6fc86dd7d3 | |||
| bf13af9691 | |||
| 1af9c21a50 | |||
| 443941e687 | |||
| 527577b438 | |||
| 9daf2d80c0 | |||
| 38b0641742 | |||
| f675af5bb0 | |||
| 533c4e7956 | |||
| 1b2c2c0037 | |||
| 97768494e1 | |||
| 4a052da289 | |||
| 18d7a55b15 | |||
| 9ca2fb7ccf | |||
| b4e8355827 | |||
| e0bde1c459 | |||
| 27c007ebd3 | |||
| f7ae2a6162 | |||
| 8f3ea09732 | |||
| af626d98d3 | |||
| 34b0574e56 | |||
| 49d738809b | |||
| 2fa3a22eca | |||
| dc5eb6f92e | |||
| ba8902f0b1 | |||
| 3080a70287 | |||
| 11e9a77840 | |||
| 64cd7e74c5 | |||
| cac65d081e | |||
| e5ada994be | |||
| 6ba2459645 | |||
| 58f63aad08 | |||
| 8a8687a63c | |||
| f7f93c360d | |||
| c160e1f68e | |||
| 47c08ab8d2 | |||
| cd83ffbaa2 | |||
| e12981a821 | |||
| 47fd1bb894 | |||
| 20582b6353 | |||
| caee770e36 | |||
| 95d6eeb37a | |||
| eadf70d809 | |||
| c8ff5387c0 | |||
| 7cb138d515 | |||
| 3dd4c45fab | |||
| 549539bec9 | |||
| e449cac464 | |||
| 62e458f39b | |||
| b37caaf9e5 | |||
| 7660ca89ae | |||
| 6e9c3e2687 | |||
| cf2fda66e2 | |||
| cd24057f1a | |||
| c68a436a22 | |||
| 36b5cccd18 | |||
| 9decfa9c31 | |||
| 3aee2b6cf5 | |||
| 17d797cee4 | |||
| 75550eeea3 | |||
| 0d09c86c12 | |||
| 2db8f482db | |||
| 00d4b32a1b | |||
| 8a82bab1f3 | |||
| 237a25e6b0 | |||
| 5dc836671d | |||
| 26be1cb602 | |||
| dc7a48cbf9 | |||
| 52455be815 | |||
| 5c851f2935 | |||
| bbbdcfb625 | |||
| b054a57e16 | |||
| fd73b3ad88 | |||
| 0807c122f6 | |||
| e0bda6ca6a | |||
| 2ba64e93f9 | |||
| e1c3ad9fe8 | |||
| 8c0637b556 | |||
| 914a8204d4 | |||
| d92ffe8fc7 | |||
| e0eb3a4f13 | |||
| 1fb0060a73 | |||
| d7430bf516 | |||
| 35f039a119 | |||
| ffbf1758e0 | |||
| a3af2d8392 | |||
| 39a4b115ed | |||
| 881c2d6e02 | |||
| d237157c0b | |||
| 6928eb632e | |||
| 79432a40d7 | |||
| 010d898786 | |||
| 766c270b1f | |||
| 916d0fd46a | |||
| 9561526f33 | |||
| 45811bc2dc | |||
| 132e37bfec | |||
| b3e26e14ef | |||
| a3bb889def | |||
| 3a1dcb3aaf | |||
| d22cafacc8 | |||
| 78e7266368 | |||
| a06c78362a | |||
| d479d18507 | |||
| 98cdb95bc0 | |||
| 02a55ce9db | |||
| 6f4720e1ea | |||
| 6a807b7799 | |||
| 8d4ef147d2 | |||
| 8ed6217d1c | |||
| 7dae4a976d | |||
| 3b83949ba3 | |||
| d8baadb991 | |||
| 7d3f9fa407 | |||
| 705d144a85 | |||
| ff05cff36c | |||
| 861fa7b145 | |||
| d65a0bba44 | |||
| dac1bd88dc | |||
| 288dbfa37c | |||
| a0e55cb9b1 | |||
| 86ba019ca0 | |||
| 19deda31bc | |||
| 4f47534824 | |||
| 3cb9c2ece5 | |||
| 1787c53d98 | |||
| 8ae762a8c8 | |||
| 63426c3cd0 | |||
| e184713c67 | |||
| 40146839ef | |||
| 448f85abe8 | |||
| 9a4b79a629 | |||
| 058b79ed5c | |||
| 216398355b | |||
| 1cda16523d | |||
| 2f1e1438e9 | |||
| 9039ab4e12 | |||
| db47696ba7 | |||
| eb9e3b8391 | |||
| ba32f1131e | |||
| 27ef04baa0 | |||
| b3a94e79e3 | |||
| 3a4c0c84a3 | |||
| 73a044ec14 | |||
| 389eb99c10 | |||
| 597d98e1d7 | |||
| 981307a1c6 | |||
| 2d51881ae3 | |||
| 788f03e993 | |||
| 81024b8c12 | |||
| 89c5064213 | |||
| 4b18a99e55 | |||
| 92d2cca7c3 | |||
| 466e36ecbb | |||
| 7ec7bd21cb | |||
| 38db7226a8 | |||
| 60f9412bb8 | |||
| 737c0502ac | |||
| 6da41b1027 | |||
| 2bd46ae0fd | |||
| c15ab10b1b | |||
| ddaeebc822 | |||
| e64293c82f | |||
| ccc17e4a20 | |||
| a53ef2ed7a | |||
| 185c36cdfe | |||
| 9beb47c067 | |||
| 3d486678ae | |||
| 04e2494af8 | |||
| 7b47159478 | |||
| 17b6ac3313 | |||
| 43600cd127 | |||
| 6d3a32c1d9 | |||
| 21fa3c8458 | |||
| 6df663825a | |||
| 8ce4600562 | |||
| 3179c0e712 | |||
| 37254738e2 | |||
| a4cce147aa | |||
| b176585a9c | |||
| f8647bb15e | |||
| 85368971fd | |||
| e4284b8e19 | |||
| 5545d8a56c | |||
| 4dc3222f03 | |||
| 7cf6a9d808 | |||
| 95a15d18a7 | |||
| cee771a3fb | |||
| a805d3b2e3 | |||
| b251c58b23 | |||
| cc7516685f | |||
| ad19ff5429 | |||
| e784c98a5a | |||
| 28679eb916 | |||
| c8fec24da3 | |||
| 0c1e2ed6f2 | |||
| 90476ae057 | |||
| 3b6a1d50bd | |||
| 1ab1505c88 | |||
| 593e581cf3 | |||
| e202d00beb | |||
| dca5f1baab | |||
| f0689e08d9 | |||
| 5bbb12b53e | |||
| c6a56e0748 | |||
| 3c62a7fd9f | |||
| 61ab17d8a1 | |||
| d4ae616460 | |||
| b7a18255fe | |||
| 1c73a16ca0 | |||
| 1aeb36d40e | |||
| f251c9826e | |||
| 204063819c | |||
| 13f8882616 | |||
| eba1d469c8 | |||
| 6e9980bf0f | |||
| 67c9c5b8ed | |||
| cd3660a96d | |||
| 9d8c1a01ac | |||
| 0a77cad2dd | |||
| f6869da3a0 | |||
| 6adad79e5c | |||
| 50d4d59626 | |||
| 56a9f1a411 | |||
| 84ff6ff2c5 | |||
| 6e35574c72 | |||
| 415c1d0574 | |||
| cfce7086a5 | |||
| c90d637a48 | |||
| 1926625297 | |||
| 63bb8e8cef | |||
| 583c5b48a0 | |||
| d08ccbce78 | |||
| 5a9702771c | |||
| eb91d9905b | |||
| 38cc85fa4c | |||
| 77e6ef218c | |||
| 464b6f2e93 | |||
| 20c90642f9 | |||
| 57e67ea8f7 | |||
| c9e9628383 | |||
| 909f07939e | |||
| a310493485 | |||
| 1e09df20b6 | |||
| 087481ac12 | |||
| c941e802d4 | |||
| 39589bd441 | |||
| 2e57325dde | |||
| 2072301d89 | |||
| b236fd3ac6 | |||
| b968695e31 | |||
| 694f1d1623 | |||
| 93e4d58606 | |||
| cc77caad67 | |||
| f74573f5d0 | |||
| deb6f0babc | |||
| 6dc0bdbfa3 |
@@ -1 +1,2 @@
|
||||
github: mailcow
|
||||
custom: ["https://www.servercow.de/mailcow?lang=en#sal"]
|
||||
|
||||
@@ -11,22 +11,35 @@ body:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: I've found a bug and checked that ...
|
||||
description: Prior to placing the issue, please check following:** *(fill out each checkbox with an `X` once done)*
|
||||
label: Checklist prior issue creation
|
||||
description: Prior to creating the issue...
|
||||
options:
|
||||
- label: ... I understand that not following the below instructions will result in immediate closure and/or deletion of my issue.
|
||||
- label: I understand that failure to follow below instructions may cause this issue to be closed.
|
||||
required: true
|
||||
- label: ... I have understood that this bug report is dedicated for bugs, and not for support-related inquiries.
|
||||
- label: I understand that vague, incomplete or inaccurate information may cause this issue to be closed.
|
||||
required: true
|
||||
- label: ... I have understood that answers are voluntary and community-driven, and not commercial support.
|
||||
- label: I understand that this form is intended solely for reporting software bugs and not for support-related inquiries.
|
||||
required: true
|
||||
- label: ... I have verified that my issue has not been already answered in the past. I also checked previous [issues](https://github.com/mailcow/mailcow-dockerized/issues).
|
||||
- label: I understand that all responses are voluntary and community-driven, and do not constitute commercial support.
|
||||
required: true
|
||||
- label: I confirm that I have reviewed previous [issues](https://github.com/mailcow/mailcow-dockerized/issues) to ensure this matter has not already been addressed.
|
||||
required: true
|
||||
- label: I confirm that my environment meets all [prerequisite requirements](https://docs.mailcow.email/getstarted/prerequisite-system/) as specified in the official documentation.
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: Please provide a brief description of the bug in 1-2 sentences. If applicable, add screenshots to help explain your problem. Very useful for bugs in mailcow UI.
|
||||
render: plain text
|
||||
description: Please provide a brief description of the bug. If applicable, add screenshots to help explain your problem. (Very useful for bugs in mailcow UI.)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Steps to reproduce:"
|
||||
description: "Please describe the steps to reproduce the bug. Screenshots can be added, if helpful."
|
||||
placeholder: |-
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -36,35 +49,36 @@ body:
|
||||
render: plain text
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Steps to reproduce:"
|
||||
description: "Please describe the steps to reproduce the bug. Screenshots can be added, if helpful."
|
||||
render: plain text
|
||||
placeholder: |-
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## System information
|
||||
### In this stage we would kindly ask you to attach general system information about your setup.
|
||||
In this stage we would kindly ask you to attach general system information about your setup.
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: "Which branch are you using?"
|
||||
description: "#### `git rev-parse --abbrev-ref HEAD`"
|
||||
description: "#### Run: `git rev-parse --abbrev-ref HEAD`"
|
||||
multiple: false
|
||||
options:
|
||||
- master
|
||||
- master (stable)
|
||||
- staging
|
||||
- nightly
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: "Which architecture are you using?"
|
||||
description: "#### Run: `uname -m`"
|
||||
multiple: false
|
||||
options:
|
||||
- x86_64
|
||||
- ARM64 (aarch64)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Operating System:"
|
||||
description: "#### Run: `lsb_release -ds`"
|
||||
placeholder: "e.g. Ubuntu 22.04 LTS"
|
||||
validations:
|
||||
required: true
|
||||
@@ -83,43 +97,44 @@ body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Virtualization technology:"
|
||||
placeholder: "KVM, VMware, Xen, etc - **LXC and OpenVZ are not supported**"
|
||||
description: "LXC and OpenVZ are not supported!"
|
||||
placeholder: "KVM, VMware ESXi, Xen, etc"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Docker version:"
|
||||
description: "#### `docker version`"
|
||||
description: "#### Run: `docker version`"
|
||||
placeholder: "20.10.21"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "docker-compose version or docker compose version:"
|
||||
description: "#### `docker-compose version` or `docker compose version`"
|
||||
description: "#### Run: `docker-compose version` or `docker compose version`"
|
||||
placeholder: "v2.12.2"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "mailcow version:"
|
||||
description: "#### ```git describe --tags `git rev-list --tags --max-count=1` ```"
|
||||
placeholder: "2022-08"
|
||||
description: "#### Run: ```git describe --tags `git rev-list --tags --max-count=1` ```"
|
||||
placeholder: "2022-08x"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Reverse proxy:"
|
||||
placeholder: "e.g. Nginx/Traefik"
|
||||
placeholder: "e.g. nginx/Traefik, or none"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Logs of git diff:"
|
||||
description: "#### Output of `git diff origin/master`, any other changes to the code? If so, **please post them**:"
|
||||
description: "#### Output of `git diff origin/master`, any other changes to the code? Sanitize if needed. If so, **please post them**:"
|
||||
render: plain text
|
||||
validations:
|
||||
required: true
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Logs of iptables -L -vn:"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: ❓ Community-driven support (Free)
|
||||
url: https://docs.mailcow.email/#get-support
|
||||
url: https://docs.mailcow.email/#community-support-and-chat
|
||||
about: Please use the community forum for questions or assistance
|
||||
- name: 🔥 Premium Support (Paid)
|
||||
url: https://www.servercow.de/mailcow?lang=en#support
|
||||
|
||||
@@ -1,13 +1,3 @@
|
||||
## :memo: Brief description
|
||||
<!-- Diff summary - START -->
|
||||
<!-- Diff summary - END -->
|
||||
|
||||
|
||||
## :computer: Commits
|
||||
<!-- Diff commits - START -->
|
||||
<!-- Diff commits - END -->
|
||||
|
||||
|
||||
## :file_folder: Modified files
|
||||
<!-- Diff files - START -->
|
||||
<!-- Diff files - END -->
|
||||
<!-- Diff files - END -->
|
||||
@@ -0,0 +1,38 @@
|
||||
<!-- _Please make sure to review and check all of these items, otherwise we might refuse your PR:_ -->
|
||||
|
||||
## Contribution Guidelines
|
||||
|
||||
* [ ] I've read the [contribution guidelines](https://github.com/mailcow/mailcow-dockerized/blob/master/CONTRIBUTING.md) and wholeheartedly agree them
|
||||
|
||||
<!-- _NOTE: this tickbox is needed to fullfil on order to get your PR reviewed._ -->
|
||||
|
||||
## What does this PR include?
|
||||
|
||||
### Short Description
|
||||
|
||||
<!-- Please write a short description, what your PR does here. -->
|
||||
|
||||
### Affected Containers
|
||||
|
||||
<!-- Please list all affected Docker containers here, which you commited changes to -->
|
||||
|
||||
<!--
|
||||
|
||||
Please list them like this:
|
||||
|
||||
- container1
|
||||
- container2
|
||||
- container3
|
||||
etc.
|
||||
|
||||
-->
|
||||
|
||||
## Did you run tests?
|
||||
|
||||
### What did you tested?
|
||||
|
||||
<!-- Please write shortly, what you've tested (which components etc.). -->
|
||||
|
||||
### What were the final results? (Awaited, got)
|
||||
|
||||
<!-- Please write shortly, what your final tests results were. What did you awaited? Was the outcome the awaited one? -->
|
||||
@@ -15,12 +15,6 @@
|
||||
"data\/web\/inc\/lib\/vendor\/**"
|
||||
],
|
||||
"regexManagers": [
|
||||
{
|
||||
"fileMatch": ["^helper-scripts\/nextcloud.sh$"],
|
||||
"matchStrings": [
|
||||
"#\\srenovate:\\sdatasource=(?<datasource>.*?) depName=(?<depName>.*?)( versioning=(?<versioning>.*?))?( extractVersion=(?<extractVersion>.*?))?\\s.*?_VERSION=(?<currentValue>.*)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"fileMatch": ["(^|/)Dockerfile[^/]*$"],
|
||||
"matchStrings": [
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Check if labeled support, if so send message and close issue
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
jobs:
|
||||
add-comment:
|
||||
if: github.event.label.name == 'support'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add comment
|
||||
run: gh issue comment "$NUMBER" --body "$BODY"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SUPPORTISSUES_ACTION_PAT }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
BODY: |
|
||||
**THIS IS A AUTOMATED MESSAGE!**
|
||||
|
||||
It seems your issue is not a bug.
|
||||
Therefore we highly advise you to get support!
|
||||
|
||||
You can get support either by:
|
||||
- ordering a paid [support contract at Servercow](https://www.servercow.de/mailcow?lang=en#support/) (Directly from the developers) or
|
||||
- using the [community forum](https://community.mailcow.email) (**Based on volunteers! NO guaranteed answer**) or
|
||||
- using the [Telegram support channel](https://t.me/mailcow) (**Based on volunteers! NO guaranteed answer**)
|
||||
|
||||
This issue will be closed. If you think your reported issue is not a support case feel free to comment above and if so the issue will reopened.
|
||||
|
||||
- name: Close issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SUPPORTISSUES_ACTION_PAT }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
NUMBER: ${{ github.event.issue.number }}
|
||||
run: gh issue close "$NUMBER" -r "not planned"
|
||||
@@ -10,9 +10,9 @@ jobs:
|
||||
if: github.event.pull_request.base.ref != 'staging' #check if the target branch is not staging
|
||||
steps:
|
||||
- name: Send message
|
||||
uses: thollander/actions-comment-pull-request@v2.4.3
|
||||
uses: thollander/actions-comment-pull-request@v3.0.1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.CHECKIFPRISSTAGING_ACTION_PAT }}
|
||||
github-token: ${{ secrets.CHECKIFPRISSTAGING_ACTION_PAT }}
|
||||
message: |
|
||||
Thanks for contributing!
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Mark/Close Stale Issues and Pull Requests 🗑️
|
||||
uses: actions/stale@v9.0.0
|
||||
uses: actions/stale@v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.STALE_ACTION_PAT }}
|
||||
days-before-stale: 60
|
||||
|
||||
@@ -23,12 +23,11 @@ jobs:
|
||||
- "postfix-mailcow"
|
||||
- "rspamd-mailcow"
|
||||
- "sogo-mailcow"
|
||||
- "solr-mailcow"
|
||||
- "unbound-mailcow"
|
||||
- "watchdog-mailcow"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Setup Docker
|
||||
run: |
|
||||
curl -sSL https://get.docker.com/ | CHANNEL=stable sudo sh
|
||||
|
||||
@@ -8,11 +8,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Run the Action
|
||||
uses: devops-infra/action-pull-request@v0.5.5
|
||||
uses: devops-infra/action-pull-request@v1.0.2
|
||||
with:
|
||||
github_token: ${{ secrets.PRTONIGHTLY_ACTION_PAT }}
|
||||
title: Automatic PR to nightly from ${{ github.event.repository.updated_at}}
|
||||
|
||||
@@ -9,27 +9,31 @@ on:
|
||||
jobs:
|
||||
docker_image_build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
- name: Login to GHCR
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.BACKUPIMAGEBUILD_ACTION_DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.BACKUPIMAGEBUILD_ACTION_DOCKERHUB_TOKEN }}
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: data/Dockerfiles/backup/Dockerfile
|
||||
push: true
|
||||
tags: mailcow/backup:latest
|
||||
tags: ghcr.io/mailcow/backup:latest
|
||||
@@ -15,14 +15,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Generate postscreen_access.cidr
|
||||
run: |
|
||||
bash helper-scripts/update_postscreen_whitelist.sh
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
token: ${{ secrets.mailcow_action_Update_postscreen_access_cidr_pat }}
|
||||
commit-message: update postscreen_access.cidr
|
||||
|
||||
+10
-1
@@ -13,6 +13,7 @@ data/conf/dovecot/acl_anyone
|
||||
data/conf/dovecot/dovecot-master.passwd
|
||||
data/conf/dovecot/dovecot-master.userdb
|
||||
data/conf/dovecot/extra.conf
|
||||
data/conf/dovecot/mail_replica.conf
|
||||
data/conf/dovecot/global_sieve_*
|
||||
data/conf/dovecot/last_login
|
||||
data/conf/dovecot/lua
|
||||
@@ -22,6 +23,7 @@ data/conf/dovecot/sni.conf
|
||||
data/conf/dovecot/sogo-sso.conf
|
||||
data/conf/dovecot/sogo_trusted_ip.conf
|
||||
data/conf/dovecot/sql
|
||||
data/conf/dovecot/conf.d/fts.conf
|
||||
data/conf/nextcloud-*.bak
|
||||
data/conf/nginx/*.active
|
||||
data/conf/nginx/*.bak
|
||||
@@ -43,8 +45,13 @@ data/conf/rspamd/local.d/*
|
||||
data/conf/rspamd/override.d/*
|
||||
data/conf/sogo/custom-theme.js
|
||||
data/conf/sogo/plist_ldap
|
||||
data/conf/sogo/plist_ldap.sh
|
||||
data/conf/sogo/sieve.creds
|
||||
data/conf/sogo/sogo-full.svg
|
||||
data/conf/sogo/cron.creds
|
||||
data/conf/sogo/custom-fulllogo.svg
|
||||
data/conf/sogo/custom-shortlogo.svg
|
||||
data/conf/sogo/custom-fulllogo.png
|
||||
data/conf/acme/dns-01.conf
|
||||
data/gitea/
|
||||
data/gogs/
|
||||
data/hooks/dovecot/*
|
||||
@@ -68,3 +75,5 @@ rebuild-images.sh
|
||||
refresh_images.sh
|
||||
update_diffs/
|
||||
create_cold_standby.sh
|
||||
!data/conf/nginx/mailcow_auth.conf
|
||||
data/conf/postfix/postfix-tlspol
|
||||
+38
-15
@@ -1,33 +1,56 @@
|
||||
# Contribution Guidelines (Last modified on 18th December 2023)
|
||||
# Contribution Guidelines
|
||||
**_Last modified on 12th November 2025_**
|
||||
|
||||
First of all, thank you for wanting to provide a bugfix or a new feature for the mailcow community, it's because of your help that the project can continue to grow!
|
||||
|
||||
## Pull Requests (Last modified on 18th December 2023)
|
||||
As we want to keep mailcow's development structured we setup these Guidelines which helps you to create your issue/pull request accordingly.
|
||||
|
||||
**PLEASE NOTE, THAT WE WILL CLOSE ISSUES/PULL REQUESTS IF THEY DON'T FULFILL OUR WRITTEN GUIDELINES WRITTEN INSIDE THIS DOCUMENT**. So please check this guidelines before you propose a Issue/Pull Request.
|
||||
|
||||
## Topics
|
||||
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Issue Reporting](#issue-reporting)
|
||||
- [Guidelines](#issue-reporting-guidelines)
|
||||
- [Issue Report Guide](#issue-report-guide)
|
||||
|
||||
## Pull Requests
|
||||
**_Last modified on 15th August 2024_**
|
||||
|
||||
However, please note the following regarding pull requests:
|
||||
|
||||
1. **ALWAYS** create your PR using the staging branch of your locally cloned mailcow instance, as the pull request will end up in said staging branch of mailcow once approved. Ideally, you should simply create a new branch for your pull request that is named after the type of your PR (e.g. `feat/` for function updates or `fix/` for bug fixes) and the actual content (e.g. `sogo-6.0.0` for an update from SOGo to version 6 or `html-escape` for a fix that includes escaping HTML in mailcow).
|
||||
2. Please **keep** this pull request branch **clean** and free of commits that have nothing to do with the changes you have made (e.g. commits from other users from other branches). *If you make changes to the `update.sh` script or other scripts that trigger a commit, there is usually a developer mode for clean working in this case.
|
||||
3. **Test your changes before you commit them as a pull request.** <ins>If possible</ins>, write a small **test log** or demonstrate the functionality with a **screenshot or GIF**. *We will of course also test your pull request ourselves, but proof from you will save us the question of whether you have tested your own changes yourself.*
|
||||
4. Please **ALWAYS** create the actual pull request against the staging branch and **NEVER** directly against the master branch. *If you forget to do this, our moobot will remind you to switch the branch to staging.*
|
||||
5. Wait for a merge commit: It may happen that we do not accept your pull request immediately or sometimes not at all for various reasons. Please do not be disappointed if this is the case. We always endeavor to incorporate any meaningful changes from the community into the mailcow project.
|
||||
6. If you are planning larger and therefore more complex pull requests, it would be advisable to first announce this in a separate issue and then start implementing it after the idea has been accepted in order to avoid unnecessary frustration and effort!
|
||||
2. **ALWAYS** report/request issues/features in the english language, even though mailcow is a german based company. This is done to allow other GitHub users to reply to your issues/requests too which did not speak german or other languages besides english.
|
||||
3. Please **keep** this pull request branch **clean** and free of commits that have nothing to do with the changes you have made (e.g. commits from other users from other branches). *If you make changes to the `update.sh` script or other scripts that trigger a commit, there is usually a developer mode for clean working in this case.*
|
||||
4. **Test your changes before you commit them as a pull request.** <ins>If possible</ins>, write a small **test log** or demonstrate the functionality with a **screenshot or GIF**. *We will of course also test your pull request ourselves, but proof from you will save us the question of whether you have tested your own changes yourself.*
|
||||
5. **Please use** the pull request template we provide once creating a pull request. *HINT: During editing you encounter comments which looks like: `<!-- CONTENT -->`. These can be removed or kept, as they will not rendered later on GitHub! Please only create actual content without the said comments.*
|
||||
6. Please **ALWAYS** create the actual pull request against the staging branch and **NEVER** directly against the master branch. *If you forget to do this, our moobot will remind you to switch the branch to staging.*
|
||||
7. Wait for a merge commit: It may happen that we do not accept your pull request immediately or sometimes not at all for various reasons. Please do not be disappointed if this is the case. We always endeavor to incorporate any meaningful changes from the community into the mailcow project.
|
||||
8. If you are planning larger and therefore more complex pull requests, it would be advisable to first announce this in a separate issue and then start implementing it after the idea has been accepted in order to avoid unnecessary frustration and effort!
|
||||
9. If your PR requires a Docker image rebuild (changes to Dockerfiles or files in data/Dockerfiles/), update the image tag in docker-compose.yml. Use the base-image versioning (e.g. ghcr.io/mailcow/sogo:5.12.4 → :5.12.5 for version bumps; append a letter for patch fixes, e.g. :5.12.4a). Follow this scheme.
|
||||
|
||||
---
|
||||
|
||||
## Issue Reporting (Last modified on 18th December 2023)
|
||||
## Issue Reporting
|
||||
**_Last modified on 12th November 2025_**
|
||||
|
||||
If you plan to report a issue within mailcow please read and understand the following rules:
|
||||
|
||||
### Security disclosures / Security-related fixes
|
||||
- Security vulnerabilities and security fixes must always be reported confidentially first to the contact address specified in SECURITY.md before they are integrated, published, or publicly disclosed in issues/PRs. Please wait for a response from the specified contact to ensure coordinated and responsible disclosure.
|
||||
|
||||
### Issue Reporting Guidelines
|
||||
|
||||
1. **ONLY** use the issue tracker for bug reports or improvement requests and NOT for support questions. For support questions you can either contact the [mailcow community on Telegram](https://docs.mailcow.email/#community-support-and-chat) or the mailcow team directly in exchange for a [support fee](https://docs.mailcow.email/#commercial-support).
|
||||
2. **ONLY** report an error if you have the **necessary know-how (at least the basics)** for the administration of an e-mail server and the usage of Docker. mailcow is a complex and fully-fledged e-mail server including groupware components on a Docker basement and it requires a bit of technical know-how for debugging and operating.
|
||||
3. **ONLY** report bugs that are contained in the latest mailcow release series. *The definition of the latest release series includes the last major patch (e.g. 2023-12) and all minor patches (revisions) below it (e.g. 2023-12a, b, c etc.).* New issue reports published starting from January 1, 2024 must meet this criterion, as versions below the latest releases are no longer supported by us.
|
||||
4. When reporting a problem, please be as detailed as possible and include even the smallest changes to your mailcow installation. Simply fill out the corresponding bug report form in detail and accurately to minimize possible questions.
|
||||
5. **Before you open an issue/feature request**, please first check whether a similar request already exists in the mailcow tracker on GitHub. If so, please include yourself in this request.
|
||||
6. When you create a issue/feature request: Please note that the creation does <ins>**not guarantee an instant implementation or fix by the mailcow team or the community**</ins>.
|
||||
7. Please **ALWAYS** anonymize any sensitive information in your bug report or feature request before submitting it.
|
||||
3. **ALWAYS** report/request issues/features in the english language, even though mailcow is a german based company. This is done to allow other GitHub users to reply to your issues/requests too which did not speak german or other languages besides english.
|
||||
4. **ONLY** report bugs that are contained in the latest mailcow release series. *The definition of the latest release series includes the last major patch (e.g. 2023-12) and all minor patches (revisions) below it (e.g. 2023-12a, b, c etc.).* New issue reports published starting from January 1, 2024 must meet this criterion, as versions below the latest releases are no longer supported by us.
|
||||
5. When reporting a problem, please be as detailed as possible and include even the smallest changes to your mailcow installation. Simply fill out the corresponding bug report form in detail and accurately to minimize possible questions.
|
||||
6. **Before you open an issue/feature request**, please first check whether a similar request already exists in the mailcow tracker on GitHub. If so, please include yourself in this request.
|
||||
7. When you create a issue/feature request: Please note that the creation does <ins>**not guarantee an instant implementation or fix by the mailcow team or the community**</ins>.
|
||||
8. Please **ALWAYS** anonymize any sensitive information in your bug report or feature request before submitting it.
|
||||
|
||||
### Quick guide to reporting problems:
|
||||
### Issue Report Guide
|
||||
1. Read your logs; follow them to see what the reason for your problem is.
|
||||
2. Follow the leads given to you in your logfiles and start investigating.
|
||||
3. Restarting the troubled service or the whole stack to see if the problem persists.
|
||||
@@ -36,4 +59,4 @@ If you plan to report a issue within mailcow please read and understand the foll
|
||||
6. [Create an issue](https://github.com/mailcow/mailcow-dockerized/issues/new/choose) over at our GitHub repository if you think your problem might be a bug or a missing feature you badly need. But please make sure, that you include **all the logs** and a full description to your problem.
|
||||
7. Ask your questions in our community-driven [support channels](https://docs.mailcow.email/#community-support-and-chat).
|
||||
|
||||
## When creating an issue/feature request or a pull request, you will be asked to confirm these guidelines.
|
||||
## When creating an issue/feature request or a pull request, you will be asked to confirm these guidelines.
|
||||
|
||||
@@ -13,6 +13,22 @@ You can also [get a SAL](https://www.servercow.de/mailcow?lang=en#sal) which is
|
||||
|
||||
Or just spread the word: moo.
|
||||
|
||||
## Many thanks to our GitHub Sponsors ❤️
|
||||
A big thank you to everyone supporting us on GitHub Sponsors—your contributions mean the world to us! Special thanks to the following amazing supporters:
|
||||
|
||||
### 100$/Month Sponsors
|
||||
<a href="https://www.colba.net/" target=_blank><img
|
||||
src="https://avatars.githubusercontent.com/u/204464723" height="58"
|
||||
/></a>
|
||||
<a href="https://www.maehdros.com/" target=_blank><img
|
||||
src="https://avatars.githubusercontent.com/u/173894712" height="58"
|
||||
/></a>
|
||||
|
||||
### 50$/Month Sponsors
|
||||
<a href="https://github.com/vnukhr" target=_blank><img
|
||||
src="https://avatars.githubusercontent.com/u/7805987?s=52&v=4" height="58"
|
||||
/></a>
|
||||
|
||||
## Info, documentation and support
|
||||
|
||||
Please see [the official documentation](https://docs.mailcow.email/) for installation and support instructions. 🐄
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env bash
|
||||
# _modules/scripts/core.sh
|
||||
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
|
||||
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
|
||||
|
||||
# ANSI color for red errors
|
||||
RED='\e[31m'
|
||||
GREEN='\e[32m'
|
||||
YELLOW='\e[33m'
|
||||
BLUE='\e[34m'
|
||||
MAGENTA='\e[35m'
|
||||
LIGHT_RED='\e[91m'
|
||||
LIGHT_GREEN='\e[92m'
|
||||
NC='\e[0m'
|
||||
|
||||
caller="${BASH_SOURCE[1]##*/}"
|
||||
|
||||
get_installed_tools(){
|
||||
for bin in openssl curl docker git awk sha1sum grep cut jq; do
|
||||
if [[ -z $(command -v ${bin}) ]]; then
|
||||
echo "Error: Cannot find command '${bin}'. Cannot proceed."
|
||||
echo "Solution: Please review system requirements and install requirements. Then, re-run the script."
|
||||
echo "See System Requirements: https://docs.mailcow.email/getstarted/install/"
|
||||
echo "Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox grep detected, please install gnu grep, \"apk add --no-cache --upgrade grep\"${NC}"; exit 1; fi
|
||||
# This will also cover sort
|
||||
if cp --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox cp detected, please install coreutils, \"apk add --no-cache --upgrade coreutils\"${NC}"; exit 1; fi
|
||||
if sed --help 2>&1 | head -n 1 | grep -q -i "busybox"; then echo -e "${LIGHT_RED}BusyBox sed detected, please install gnu sed, \"apk add --no-cache --upgrade sed\"${NC}"; exit 1; fi
|
||||
}
|
||||
|
||||
get_docker_version(){
|
||||
# Check Docker Version (need at least 24.X)
|
||||
docker_version=$(docker version --format '{{.Server.Version}}' | cut -d '.' -f 1)
|
||||
}
|
||||
|
||||
get_compose_type(){
|
||||
if docker compose > /dev/null 2>&1; then
|
||||
if docker compose version --short | grep -e "^[2-9]\." -e "^v[2-9]\." -e "^[1-9][0-9]\." -e "^v[1-9][0-9]\." > /dev/null 2>&1; then
|
||||
COMPOSE_VERSION=native
|
||||
COMPOSE_COMMAND="docker compose"
|
||||
if [[ "$caller" == "update.sh" ]]; then
|
||||
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=native/' "$SCRIPT_DIR/mailcow.conf"
|
||||
fi
|
||||
echo -e "\e[33mFound Docker Compose Plugin (native).\e[0m"
|
||||
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to native\e[0m"
|
||||
sleep 2
|
||||
echo -e "\e[33mNotice: You'll have to update this Compose Version via your Package Manager manually!\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease update/install it manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
elif docker-compose > /dev/null 2>&1; then
|
||||
if ! [[ $(alias docker-compose 2> /dev/null) ]] ; then
|
||||
if docker-compose version --short | grep -e "^[2-9]\." -e "^[1-9][0-9]\." > /dev/null 2>&1; then
|
||||
COMPOSE_VERSION=standalone
|
||||
COMPOSE_COMMAND="docker-compose"
|
||||
if [[ "$caller" == "update.sh" ]]; then
|
||||
sed -i 's/^DOCKER_COMPOSE_VERSION=.*/DOCKER_COMPOSE_VERSION=standalone/' "$SCRIPT_DIR/mailcow.conf"
|
||||
fi
|
||||
echo -e "\e[33mFound Docker Compose Standalone.\e[0m"
|
||||
echo -e "\e[33mSetting the DOCKER_COMPOSE_VERSION Variable to standalone\e[0m"
|
||||
sleep 2
|
||||
echo -e "\e[33mNotice: For an automatic update of docker-compose please use the update_compose.sh scripts located at the helper-scripts folder.\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose with a Version Higher than 2.X.X.\e[0m"
|
||||
echo -e "\e[31mPlease update/install manually regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "\e[31mCannot find Docker Compose.\e[0m"
|
||||
echo -e "\e[31mPlease install it regarding to this doc site: https://docs.mailcow.email/install/\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_bad_asn() {
|
||||
echo -e "\e[33mDetecting if your IP is listed on Spamhaus Bad ASN List...\e[0m"
|
||||
response=$(curl --connect-timeout 15 --max-time 30 -s -o /dev/null -w "%{http_code}" "https://asn-check.mailcow.email")
|
||||
if [ "$response" -eq 503 ]; then
|
||||
if [ -z "$SPAMHAUS_DQS_KEY" ]; then
|
||||
echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
|
||||
echo -e "\e[33mmailcow did not detected a value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf!\e[0m"
|
||||
sleep 2
|
||||
echo ""
|
||||
echo -e "\e[33mTo use the Spamhaus DNS Blocklists again, you will need to create a FREE account for their Data Query Service (DQS) at: https://www.spamhaus.com/free-trial/sign-up-for-a-free-data-query-service-account\e[0m"
|
||||
echo -e "\e[33mOnce done, enter your DQS API key in mailcow.conf and mailcow will do the rest for you!\e[0m"
|
||||
echo ""
|
||||
sleep 2
|
||||
else
|
||||
echo -e "\e[33mYour server's public IP uses an AS that is blocked by Spamhaus to use their DNS public blocklists for Postfix.\e[0m"
|
||||
echo -e "\e[32mmailcow detected a Value for the variable SPAMHAUS_DQS_KEY inside mailcow.conf. Postfix will use DQS with the given API key...\e[0m"
|
||||
fi
|
||||
elif [ "$response" -eq 200 ]; then
|
||||
echo -e "\e[33mCheck completed! Your IP is \e[32mclean\e[0m"
|
||||
elif [ "$response" -eq 429 ]; then
|
||||
echo -e "\e[33mCheck completed! \e[31mYour IP seems to be rate limited on the ASN Check service... please try again later!\e[0m"
|
||||
else
|
||||
echo -e "\e[31mCheck failed! \e[0mMaybe a DNS or Network problem?\e[0m"
|
||||
fi
|
||||
}
|
||||
|
||||
check_online_status() {
|
||||
CHECK_ONLINE_DOMAINS=('https://github.com' 'https://hub.docker.com')
|
||||
for domain in "${CHECK_ONLINE_DOMAINS[@]}"; do
|
||||
if timeout 6 curl --head --silent --output /dev/null ${domain}; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
prefetch_images() {
|
||||
[[ -z ${BRANCH} ]] && { echo -e "\e[33m\nUnknown branch...\e[0m"; exit 1; }
|
||||
git fetch origin #${BRANCH}
|
||||
while read image; do
|
||||
RET_C=0
|
||||
until docker pull "${image}"; do
|
||||
RET_C=$((RET_C + 1))
|
||||
echo -e "\e[33m\nError pulling $image, retrying...\e[0m"
|
||||
[ ${RET_C} -gt 3 ] && { echo -e "\e[31m\nToo many failed retries, exiting\e[0m"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
done < <(git show "origin/${BRANCH}:docker-compose.yml" | grep "image:" | awk '{ gsub("image:","", $3); print $2 }')
|
||||
}
|
||||
|
||||
docker_garbage() {
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../.." && pwd )"
|
||||
IMGS_TO_DELETE=()
|
||||
|
||||
declare -A IMAGES_INFO
|
||||
COMPOSE_IMAGES=($(grep -oP "image: \K(ghcr\.io/)?mailcow.+" "${SCRIPT_DIR}/docker-compose.yml"))
|
||||
|
||||
for existing_image in $(docker images --format "{{.ID}}:{{.Repository}}:{{.Tag}}" | grep -E '(mailcow/|ghcr\.io/mailcow/)'); do
|
||||
ID=$(echo "$existing_image" | cut -d ':' -f 1)
|
||||
REPOSITORY=$(echo "$existing_image" | cut -d ':' -f 2)
|
||||
TAG=$(echo "$existing_image" | cut -d ':' -f 3)
|
||||
|
||||
if [[ "$REPOSITORY" == "mailcow/backup" || "$REPOSITORY" == "ghcr.io/mailcow/backup" ]]; then
|
||||
if [[ "$TAG" != "<none>" ]]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ " ${COMPOSE_IMAGES[@]} " =~ " ${REPOSITORY}:${TAG} " ]]; then
|
||||
continue
|
||||
else
|
||||
IMGS_TO_DELETE+=("$ID")
|
||||
IMAGES_INFO["$ID"]="$REPOSITORY:$TAG"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ! -z ${IMGS_TO_DELETE[*]} ]]; then
|
||||
echo "The following unused mailcow images were found:"
|
||||
for id in "${IMGS_TO_DELETE[@]}"; do
|
||||
echo " ${IMAGES_INFO[$id]} ($id)"
|
||||
done
|
||||
|
||||
if [ -z "$FORCE" ]; then
|
||||
read -r -p "Do you want to delete them to free up some space? [y/N] " response
|
||||
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
docker rmi ${IMGS_TO_DELETE[*]}
|
||||
else
|
||||
echo "OK, skipped."
|
||||
fi
|
||||
else
|
||||
echo "Running in forced mode! Force removing old mailcow images..."
|
||||
docker rmi ${IMGS_TO_DELETE[*]}
|
||||
fi
|
||||
echo -e "\e[32mFurther cleanup...\e[0m"
|
||||
echo "If you want to cleanup further garbage collected by Docker, please make sure all containers are up and running before cleaning your system by executing \"docker system prune\""
|
||||
fi
|
||||
}
|
||||
|
||||
in_array() {
|
||||
local e match="$1"
|
||||
shift
|
||||
for e; do [[ "$e" == "$match" ]] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
detect_major_update() {
|
||||
if [ ${BRANCH} == "master" ]; then
|
||||
# Array with major versions
|
||||
# Add major versions here
|
||||
MAJOR_VERSIONS=(
|
||||
"2025-02"
|
||||
"2025-03"
|
||||
"2025-09"
|
||||
)
|
||||
|
||||
current_version=""
|
||||
if [[ -f "${SCRIPT_DIR}/data/web/inc/app_info.inc.php" ]]; then
|
||||
current_version=$(grep 'MAILCOW_GIT_VERSION' ${SCRIPT_DIR}/data/web/inc/app_info.inc.php | sed -E 's/.*MAILCOW_GIT_VERSION="([^"]+)".*/\1/')
|
||||
fi
|
||||
if [[ -z "$current_version" ]]; then
|
||||
return 1
|
||||
fi
|
||||
release_url="https://github.com/mailcow/mailcow-dockerized/releases/tag"
|
||||
|
||||
updates_to_apply=()
|
||||
|
||||
for version in "${MAJOR_VERSIONS[@]}"; do
|
||||
if [[ "$current_version" < "$version" ]]; then
|
||||
updates_to_apply+=("$version")
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ ${#updates_to_apply[@]} -gt 0 ]]; then
|
||||
echo -e "\e[33m\nMAJOR UPDATES to be applied:\e[0m"
|
||||
for update in "${updates_to_apply[@]}"; do
|
||||
echo "$update - $release_url/$update"
|
||||
done
|
||||
|
||||
echo -e "\nPlease read the release notes before proceeding."
|
||||
read -p "Do you want to proceed with the update? [y/n] " response
|
||||
if [[ "${response}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Proceeding with the update..."
|
||||
else
|
||||
echo "Update canceled. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
# _modules/scripts/ipv6_controller.sh
|
||||
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
|
||||
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
|
||||
|
||||
# 1) Check if the host supports IPv6
|
||||
get_ipv6_support() {
|
||||
# ---- helper: probe external IPv6 connectivity without DNS ----
|
||||
_probe_ipv6_connectivity() {
|
||||
# Use literal, always-on IPv6 echo responders (no DNS required)
|
||||
local PROBE_IPS=("2001:4860:4860::8888" "2606:4700:4700::1111")
|
||||
local ip rc=1
|
||||
|
||||
for ip in "${PROBE_IPS[@]}"; do
|
||||
if command -v ping6 &>/dev/null; then
|
||||
ping6 -c1 -W2 "$ip" &>/dev/null || ping6 -c1 -w2 "$ip" &>/dev/null
|
||||
rc=$?
|
||||
elif command -v ping &>/dev/null; then
|
||||
ping -6 -c1 -W2 "$ip" &>/dev/null || ping -6 -c1 -w2 "$ip" &>/dev/null
|
||||
rc=$?
|
||||
else
|
||||
rc=1
|
||||
fi
|
||||
[[ $rc -eq 0 ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ ! -f /proc/net/if_inet6 ]] || grep -qs '^1' /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null; then
|
||||
DETECTED_IPV6=false
|
||||
echo -e "${YELLOW}IPv6 not detected on host – ${LIGHT_RED}IPv6 is administratively disabled${YELLOW}.${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
if ip -6 route show default 2>/dev/null | grep -qE '^default'; then
|
||||
echo -e "${YELLOW}Default IPv6 route found – testing external IPv6 connectivity...${NC}"
|
||||
if _probe_ipv6_connectivity; then
|
||||
DETECTED_IPV6=true
|
||||
echo -e "IPv6 detected on host – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
|
||||
else
|
||||
DETECTED_IPV6=false
|
||||
echo -e "${YELLOW}Default IPv6 route present but external IPv6 connectivity failed – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
if ip -6 addr show scope global 2>/dev/null | grep -q 'inet6'; then
|
||||
DETECTED_IPV6=false
|
||||
echo -e "${YELLOW}Global IPv6 address present but no default route – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
|
||||
return
|
||||
fi
|
||||
|
||||
if ip -6 addr show scope link 2>/dev/null | grep -q 'inet6'; then
|
||||
echo -e "${YELLOW}Only link-local IPv6 addresses found – testing external IPv6 connectivity...${NC}"
|
||||
if _probe_ipv6_connectivity; then
|
||||
DETECTED_IPV6=true
|
||||
echo -e "External IPv6 connectivity available – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
|
||||
else
|
||||
DETECTED_IPV6=false
|
||||
echo -e "${YELLOW}Only link-local IPv6 present and no external connectivity – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
DETECTED_IPV6=false
|
||||
echo -e "${YELLOW}IPv6 not detected on host – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
|
||||
}
|
||||
|
||||
# 2) Ensure Docker daemon.json has (or create) the required IPv6 settings
|
||||
docker_daemon_edit(){
|
||||
DOCKER_DAEMON_CONFIG="/etc/docker/daemon.json"
|
||||
DOCKER_MAJOR=$(docker version --format '{{.Server.Version}}' 2>/dev/null | cut -d. -f1)
|
||||
MISSING=()
|
||||
|
||||
_has_kv() { grep -Eq "\"$1\"[[:space:]]*:[[:space:]]*$2" "$DOCKER_DAEMON_CONFIG" 2>/dev/null; }
|
||||
|
||||
if [[ -f "$DOCKER_DAEMON_CONFIG" ]]; then
|
||||
|
||||
# reject empty or whitespace-only file immediately
|
||||
if [[ ! -s "$DOCKER_DAEMON_CONFIG" ]] || ! grep -Eq '[{}]' "$DOCKER_DAEMON_CONFIG"; then
|
||||
echo -e "${RED}ERROR: $DOCKER_DAEMON_CONFIG exists but is empty or contains no JSON braces – please initialize it with valid JSON (e.g. {}).${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate JSON if jq is present
|
||||
if command -v jq &>/dev/null && ! jq empty "$DOCKER_DAEMON_CONFIG" &>/dev/null; then
|
||||
echo -e "${RED}ERROR: Invalid JSON in $DOCKER_DAEMON_CONFIG – please correct manually.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Gather missing keys
|
||||
! _has_kv ipv6 true && MISSING+=("ipv6: true")
|
||||
|
||||
# For Docker < 28, keep requiring fixed-cidr-v6 (default bridge needs it on old engines)
|
||||
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
|
||||
! grep -Eq '"fixed-cidr-v6"[[:space:]]*:[[:space:]]*".+"' "$DOCKER_DAEMON_CONFIG" \
|
||||
&& MISSING+=('fixed-cidr-v6: "fd00:dead:beef:c0::/80"')
|
||||
fi
|
||||
|
||||
# For Docker < 27, ip6tables needed and was tied to experimental in older releases
|
||||
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
|
||||
_has_kv ipv6 true && ! _has_kv ip6tables true && MISSING+=("ip6tables: true")
|
||||
! _has_kv experimental true && MISSING+=("experimental: true")
|
||||
fi
|
||||
|
||||
# Fix if needed
|
||||
if ((${#MISSING[@]}>0)); then
|
||||
echo -e "${MAGENTA}Your daemon.json is missing: ${YELLOW}${MISSING[*]}${NC}"
|
||||
if [[ -n "$FORCE" ]]; then
|
||||
ans=Y
|
||||
else
|
||||
read -p "Would you like to update $DOCKER_DAEMON_CONFIG now? [Y/n] " ans
|
||||
ans=${ans:-Y}
|
||||
fi
|
||||
|
||||
if [[ $ans =~ ^[Yy]$ ]]; then
|
||||
cp "$DOCKER_DAEMON_CONFIG" "${DOCKER_DAEMON_CONFIG}.bak"
|
||||
if command -v jq &>/dev/null; then
|
||||
TMP=$(mktemp)
|
||||
# Base filter: ensure ipv6 = true
|
||||
JQ_FILTER='.ipv6 = true'
|
||||
|
||||
# Add fixed-cidr-v6 only for Docker < 28
|
||||
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
|
||||
JQ_FILTER+=' | .["fixed-cidr-v6"] = (.["fixed-cidr-v6"] // "fd00:dead:beef:c0::/80")'
|
||||
fi
|
||||
|
||||
# Add ip6tables/experimental only for Docker < 27
|
||||
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
|
||||
JQ_FILTER+=' | .ip6tables = true | .experimental = true'
|
||||
fi
|
||||
|
||||
jq "$JQ_FILTER" "$DOCKER_DAEMON_CONFIG" >"$TMP" && mv "$TMP" "$DOCKER_DAEMON_CONFIG"
|
||||
echo -e "${LIGHT_GREEN}daemon.json updated. Restarting Docker...${NC}"
|
||||
(command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart
|
||||
echo -e "${YELLOW}Docker restarted.${NC}"
|
||||
else
|
||||
echo -e "${RED}Please install jq or manually update daemon.json and restart Docker.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}User declined Docker update – please insert these changes manually:${NC}"
|
||||
echo "${MISSING[*]}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
else
|
||||
# Create new daemon.json if missing
|
||||
if [[ -n "$FORCE" ]]; then
|
||||
ans=Y
|
||||
else
|
||||
read -p "$DOCKER_DAEMON_CONFIG not found. Create it with IPv6 settings? [Y/n] " ans
|
||||
ans=${ans:-Y}
|
||||
fi
|
||||
|
||||
if [[ $ans =~ ^[Yy]$ ]]; then
|
||||
mkdir -p "$(dirname "$DOCKER_DAEMON_CONFIG")"
|
||||
if [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 27 ]]; then
|
||||
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
|
||||
{
|
||||
"ipv6": true,
|
||||
"fixed-cidr-v6": "fd00:dead:beef:c0::/80",
|
||||
"ip6tables": true,
|
||||
"experimental": true
|
||||
}
|
||||
EOF
|
||||
elif [[ -n "$DOCKER_MAJOR" && "$DOCKER_MAJOR" -lt 28 ]]; then
|
||||
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
|
||||
{
|
||||
"ipv6": true,
|
||||
"fixed-cidr-v6": "fd00:dead:beef:c0::/80"
|
||||
}
|
||||
EOF
|
||||
else
|
||||
# Docker 28+: ipv6 works without fixed-cidr-v6
|
||||
cat > "$DOCKER_DAEMON_CONFIG" <<EOF
|
||||
{
|
||||
"ipv6": true
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
echo -e "${GREEN}Created $DOCKER_DAEMON_CONFIG with IPv6 settings.${NC}"
|
||||
echo "Restarting Docker..."
|
||||
(command -v systemctl &>/dev/null && systemctl restart docker) || service docker restart
|
||||
echo "Docker restarted."
|
||||
else
|
||||
echo "User declined to create daemon.json – please manually merge the docker daemon with these configs:"
|
||||
echo "${MISSING[*]}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 3) Main wrapper for generate_config.sh and update.sh
|
||||
configure_ipv6() {
|
||||
# detect manual override if mailcow.conf is present
|
||||
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]] && grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
|
||||
MANUAL_SETTING=$(grep '^ENABLE_IPV6=' "$MAILCOW_CONF" | cut -d= -f2)
|
||||
elif [[ -z "$MAILCOW_CONF" ]] && [[ -n "${ENABLE_IPV6:-}" ]]; then
|
||||
MANUAL_SETTING="$ENABLE_IPV6"
|
||||
else
|
||||
MANUAL_SETTING=""
|
||||
fi
|
||||
|
||||
get_ipv6_support
|
||||
|
||||
# if user manually set it, check for mismatch
|
||||
if [[ "$DETECTED_IPV6" != "true" ]]; then
|
||||
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
|
||||
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
|
||||
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF"
|
||||
else
|
||||
echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF"
|
||||
fi
|
||||
else
|
||||
export IPV6_BOOL=false
|
||||
fi
|
||||
echo "Skipping Docker IPv6 configuration because host does not support IPv6."
|
||||
echo "Make sure to check if your docker daemon.json does not include \"enable_ipv6\": true if you do not want IPv6."
|
||||
echo "IPv6 configuration complete: ENABLE_IPV6=false"
|
||||
sleep 2
|
||||
return
|
||||
fi
|
||||
|
||||
docker_daemon_edit
|
||||
|
||||
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
|
||||
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
|
||||
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=true/' "$MAILCOW_CONF"
|
||||
else
|
||||
echo "ENABLE_IPV6=true" >> "$MAILCOW_CONF"
|
||||
fi
|
||||
else
|
||||
export IPV6_BOOL=true
|
||||
fi
|
||||
|
||||
echo "IPv6 configuration complete: ENABLE_IPV6=true"
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env bash
|
||||
# _modules/scripts/migrate_options.sh
|
||||
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
|
||||
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
|
||||
|
||||
migrate_config_options() {
|
||||
|
||||
sed -i --follow-symlinks '$a\' mailcow.conf
|
||||
|
||||
KEYS=(
|
||||
SOLR_HEAP
|
||||
SKIP_SOLR
|
||||
SOLR_PORT
|
||||
FLATCURVE_EXPERIMENTAL
|
||||
DISABLE_IPv6
|
||||
ACME_CONTACT
|
||||
)
|
||||
|
||||
for key in "${KEYS[@]}"; do
|
||||
if grep -q "${key}" mailcow.conf; then
|
||||
case "${key}" in
|
||||
SOLR_HEAP)
|
||||
echo "Removing ${key} in mailcow.conf"
|
||||
sed -i '/# Solr heap size in MB\b/d' mailcow.conf
|
||||
sed -i '/# Solr is a prone to run\b/d' mailcow.conf
|
||||
sed -i '/SOLR_HEAP\b/d' mailcow.conf
|
||||
;;
|
||||
SKIP_SOLR)
|
||||
echo "Removing ${key} in mailcow.conf"
|
||||
sed -i '/\bSkip Solr on low-memory\b/d' mailcow.conf
|
||||
sed -i '/\bSolr is disabled by default\b/d' mailcow.conf
|
||||
sed -i '/\bDisable Solr or\b/d' mailcow.conf
|
||||
sed -i '/\bSKIP_SOLR\b/d' mailcow.conf
|
||||
;;
|
||||
SOLR_PORT)
|
||||
echo "Removing ${key} in mailcow.conf"
|
||||
sed -i '/\bSOLR_PORT\b/d' mailcow.conf
|
||||
;;
|
||||
FLATCURVE_EXPERIMENTAL)
|
||||
echo "Removing ${key} in mailcow.conf"
|
||||
sed -i '/\bFLATCURVE_EXPERIMENTAL\b/d' mailcow.conf
|
||||
;;
|
||||
DISABLE_IPv6)
|
||||
echo "Migrating ${key} to ENABLE_IPv6 in mailcow.conf"
|
||||
local old=$(grep '^DISABLE_IPv6=' "mailcow.conf" | cut -d'=' -f2)
|
||||
local new
|
||||
if [[ "$old" == "y" ]]; then
|
||||
new="false"
|
||||
else
|
||||
new="true"
|
||||
fi
|
||||
sed -i '/^DISABLE_IPv6=/d' "mailcow.conf"
|
||||
echo "ENABLE_IPV6=$new" >> "mailcow.conf"
|
||||
;;
|
||||
ACME_CONTACT)
|
||||
echo "Deleting obsoleted ${key} in mailcow.conf"
|
||||
sed -i '/^# Lets Encrypt registration contact information/d' mailcow.conf
|
||||
sed -i '/^# Optional: Leave empty for none/d' mailcow.conf
|
||||
sed -i '/^# This value is only used on first order!/d' mailcow.conf
|
||||
sed -i '/^# Setting it at a later point will require the following steps:/d' mailcow.conf
|
||||
sed -i '/^# https:\/\/docs.mailcow.email\/troubleshooting\/debug-reset_tls\//d' mailcow.conf
|
||||
sed -i '/^ACME_CONTACT=.*/d' mailcow.conf
|
||||
sed -i '/^#ACME_CONTACT=.*/d' mailcow.conf
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
solr_volume=$(docker volume ls -qf name=^${COMPOSE_PROJECT_NAME}_solr-vol-1)
|
||||
if [[ -n $solr_volume ]]; then
|
||||
echo -e "\e[34mSolr has been replaced within mailcow since 2025-01.\nThe volume $solr_volume is unused.\e[0m"
|
||||
sleep 1
|
||||
if [ ! "$FORCE" ]; then
|
||||
read -r -p "Remove $solr_volume? [y/N] " response
|
||||
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo -e "\e[33mRemoving $solr_volume...\e[0m"
|
||||
docker volume rm $solr_volume || echo -e "\e[31mFailed to remove. Remove it manually!\e[0m"
|
||||
echo -e "\e[32mSuccessfully removed $solr_volume!\e[0m"
|
||||
else
|
||||
echo -e "Not removing $solr_volume. Run \`docker volume rm $solr_volume\` manually if needed."
|
||||
fi
|
||||
else
|
||||
echo -e "\e[33mForce removing $solr_volume...\e[0m"
|
||||
docker volume rm $solr_volume || echo -e "\e[31mFailed to remove. Remove it manually!\e[0m"
|
||||
echo -e "\e[32mSuccessfully removed $solr_volume!\e[0m"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Delete old fts.conf before forced switch to flatcurve to ensure update is working properly
|
||||
FTS_CONF_PATH="${SCRIPT_DIR}/data/conf/dovecot/conf.d/fts.conf"
|
||||
if [[ -f "$FTS_CONF_PATH" ]]; then
|
||||
if grep -q "Autogenerated by mailcow" "$FTS_CONF_PATH"; then
|
||||
rm -rf $FTS_CONF_PATH
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env bash
|
||||
# _modules/scripts/new_options.sh
|
||||
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
|
||||
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
|
||||
|
||||
adapt_new_options() {
|
||||
|
||||
CONFIG_ARRAY=(
|
||||
"AUTODISCOVER_SAN"
|
||||
"SKIP_LETS_ENCRYPT"
|
||||
"SKIP_SOGO"
|
||||
"USE_WATCHDOG"
|
||||
"WATCHDOG_NOTIFY_EMAIL"
|
||||
"WATCHDOG_NOTIFY_WEBHOOK"
|
||||
"WATCHDOG_NOTIFY_WEBHOOK_BODY"
|
||||
"WATCHDOG_NOTIFY_BAN"
|
||||
"WATCHDOG_NOTIFY_START"
|
||||
"WATCHDOG_EXTERNAL_CHECKS"
|
||||
"WATCHDOG_SUBJECT"
|
||||
"SKIP_CLAMD"
|
||||
"SKIP_OLEFY"
|
||||
"SKIP_IP_CHECK"
|
||||
"ADDITIONAL_SAN"
|
||||
"DOVEADM_PORT"
|
||||
"IPV4_NETWORK"
|
||||
"IPV6_NETWORK"
|
||||
"LOG_LINES"
|
||||
"SNAT_TO_SOURCE"
|
||||
"SNAT6_TO_SOURCE"
|
||||
"COMPOSE_PROJECT_NAME"
|
||||
"DOCKER_COMPOSE_VERSION"
|
||||
"SQL_PORT"
|
||||
"API_KEY"
|
||||
"API_KEY_READ_ONLY"
|
||||
"API_ALLOW_FROM"
|
||||
"MAILDIR_GC_TIME"
|
||||
"MAILDIR_SUB"
|
||||
"ACL_ANYONE"
|
||||
"FTS_HEAP"
|
||||
"FTS_PROCS"
|
||||
"SKIP_FTS"
|
||||
"ENABLE_SSL_SNI"
|
||||
"ALLOW_ADMIN_EMAIL_LOGIN"
|
||||
"SKIP_HTTP_VERIFICATION"
|
||||
"SOGO_EXPIRE_SESSION"
|
||||
"SOGO_URL_ENCRYPTION_KEY"
|
||||
"REDIS_PORT"
|
||||
"REDISPASS"
|
||||
"DOVECOT_MASTER_USER"
|
||||
"DOVECOT_MASTER_PASS"
|
||||
"MAILCOW_PASS_SCHEME"
|
||||
"ADDITIONAL_SERVER_NAMES"
|
||||
"WATCHDOG_VERBOSE"
|
||||
"WEBAUTHN_ONLY_TRUSTED_VENDORS"
|
||||
"SPAMHAUS_DQS_KEY"
|
||||
"SKIP_UNBOUND_HEALTHCHECK"
|
||||
"DISABLE_NETFILTER_ISOLATION_RULE"
|
||||
"HTTP_REDIRECT"
|
||||
"ENABLE_IPV6"
|
||||
"ACME_DNS_CHALLENGE"
|
||||
"ACME_DNS_PROVIDER"
|
||||
"ACME_ACCOUNT_EMAIL"
|
||||
)
|
||||
|
||||
sed -i --follow-symlinks '$a\' mailcow.conf
|
||||
for option in ${CONFIG_ARRAY[@]}; do
|
||||
if grep -q "^#\?${option}=" mailcow.conf; then
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Adding new option \"${option}\" to mailcow.conf"
|
||||
|
||||
case "${option}" in
|
||||
AUTODISCOVER_SAN)
|
||||
echo '# Obtain certificates for autodiscover.* and autoconfig.* domains.' >> mailcow.conf
|
||||
echo '# This can be useful to switch off in case you are in a scenario where a reverse proxy already handles those.' >> mailcow.conf
|
||||
echo '# There are mixed scenarios where ports 80,443 are occupied and you do not want to share certs' >> mailcow.conf
|
||||
echo '# between services. So acme-mailcow obtains for maildomains and all web-things get handled' >> mailcow.conf
|
||||
echo '# in the reverse proxy.' >> mailcow.conf
|
||||
echo 'AUTODISCOVER_SAN=y' >> mailcow.conf
|
||||
;;
|
||||
|
||||
DOCKER_COMPOSE_VERSION)
|
||||
echo "# Used Docker Compose version" >> mailcow.conf
|
||||
echo "# Switch here between native (compose plugin) and standalone" >> mailcow.conf
|
||||
echo "# For more informations take a look at the mailcow docs regarding the configuration options." >> mailcow.conf
|
||||
echo "# Normally this should be untouched but if you decided to use either of those you can switch it manually here." >> mailcow.conf
|
||||
echo "# Please be aware that at least one of those variants should be installed on your machine or mailcow will fail." >> mailcow.conf
|
||||
echo "" >> mailcow.conf
|
||||
echo "DOCKER_COMPOSE_VERSION=${DOCKER_COMPOSE_VERSION}" >> mailcow.conf
|
||||
;;
|
||||
|
||||
DOVEADM_PORT)
|
||||
echo "DOVEADM_PORT=127.0.0.1:19991" >> mailcow.conf
|
||||
;;
|
||||
|
||||
LOG_LINES)
|
||||
echo '# Max log lines per service to keep in Redis logs' >> mailcow.conf
|
||||
echo "LOG_LINES=9999" >> mailcow.conf
|
||||
;;
|
||||
IPV4_NETWORK)
|
||||
echo '# Internal IPv4 /24 subnet, format n.n.n. (expands to n.n.n.0/24)' >> mailcow.conf
|
||||
echo "IPV4_NETWORK=172.22.1" >> mailcow.conf
|
||||
;;
|
||||
IPV6_NETWORK)
|
||||
echo '# Internal IPv6 subnet in fc00::/7' >> mailcow.conf
|
||||
echo "IPV6_NETWORK=fd4d:6169:6c63:6f77::/64" >> mailcow.conf
|
||||
;;
|
||||
SQL_PORT)
|
||||
echo '# Bind SQL to 127.0.0.1 on port 13306' >> mailcow.conf
|
||||
echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf
|
||||
;;
|
||||
API_KEY)
|
||||
echo '# Create or override API key for web UI' >> mailcow.conf
|
||||
echo "#API_KEY=" >> mailcow.conf
|
||||
;;
|
||||
API_KEY_READ_ONLY)
|
||||
echo '# Create or override read-only API key for web UI' >> mailcow.conf
|
||||
echo "#API_KEY_READ_ONLY=" >> mailcow.conf
|
||||
;;
|
||||
API_ALLOW_FROM)
|
||||
echo '# Must be set for API_KEY to be active' >> mailcow.conf
|
||||
echo '# IPs only, no networks (networks can be set via UI)' >> mailcow.conf
|
||||
echo "#API_ALLOW_FROM=" >> mailcow.conf
|
||||
;;
|
||||
SNAT_TO_SOURCE)
|
||||
echo '# Use this IPv4 for outgoing connections (SNAT)' >> mailcow.conf
|
||||
echo "#SNAT_TO_SOURCE=" >> mailcow.conf
|
||||
;;
|
||||
SNAT6_TO_SOURCE)
|
||||
echo '# Use this IPv6 for outgoing connections (SNAT)' >> mailcow.conf
|
||||
echo "#SNAT6_TO_SOURCE=" >> mailcow.conf
|
||||
;;
|
||||
MAILDIR_GC_TIME)
|
||||
echo '# Garbage collector cleanup' >> mailcow.conf
|
||||
echo '# Deleted domains and mailboxes are moved to /var/vmail/_garbage/timestamp_sanitizedstring' >> mailcow.conf
|
||||
echo '# How long should objects remain in the garbage until they are being deleted? (value in minutes)' >> mailcow.conf
|
||||
echo '# Check interval is hourly' >> mailcow.conf
|
||||
echo 'MAILDIR_GC_TIME=1440' >> mailcow.conf
|
||||
;;
|
||||
ACL_ANYONE)
|
||||
echo '# Set this to "allow" to enable the anyone pseudo user. Disabled by default.' >> mailcow.conf
|
||||
echo '# When enabled, ACL can be created, that apply to "All authenticated users"' >> mailcow.conf
|
||||
echo '# This should probably only be activated on mail hosts, that are used exclusively by one organisation.' >> mailcow.conf
|
||||
echo '# Otherwise a user might share data with too many other users.' >> mailcow.conf
|
||||
echo 'ACL_ANYONE=disallow' >> mailcow.conf
|
||||
;;
|
||||
FTS_HEAP)
|
||||
echo '# Dovecot Indexing (FTS) Process maximum heap size in MB, there is no recommendation, please see Dovecot docs.' >> mailcow.conf
|
||||
echo '# Flatcurve is used as FTS Engine. It is supposed to be pretty efficient in CPU and RAM consumption.' >> mailcow.conf
|
||||
echo '# Please always monitor your Resource consumption!' >> mailcow.conf
|
||||
echo "FTS_HEAP=128" >> mailcow.conf
|
||||
;;
|
||||
SKIP_FTS)
|
||||
echo '# Skip FTS (Fulltext Search) for Dovecot on low-memory, low-threaded systems or if you simply want to disable it.' >> mailcow.conf
|
||||
echo "# Dovecot inside mailcow use Flatcurve as FTS Backend." >> mailcow.conf
|
||||
echo "SKIP_FTS=y" >> mailcow.conf
|
||||
;;
|
||||
FTS_PROCS)
|
||||
echo '# Controls how many processes the Dovecot indexing process can spawn at max.' >> mailcow.conf
|
||||
echo '# Too many indexing processes can use a lot of CPU and Disk I/O' >> mailcow.conf
|
||||
echo '# Please visit: https://doc.dovecot.org/configuration_manual/service_configuration/#indexer-worker for more informations' >> mailcow.conf
|
||||
echo "FTS_PROCS=1" >> mailcow.conf
|
||||
;;
|
||||
ENABLE_SSL_SNI)
|
||||
echo '# Create seperate certificates for all domains - y/n' >> mailcow.conf
|
||||
echo '# this will allow adding more than 100 domains, but some email clients will not be able to connect with alternative hostnames' >> mailcow.conf
|
||||
echo '# see https://wiki.dovecot.org/SSL/SNIClientSupport' >> mailcow.conf
|
||||
echo "ENABLE_SSL_SNI=n" >> mailcow.conf
|
||||
;;
|
||||
SKIP_SOGO)
|
||||
echo '# Skip SOGo: Will disable SOGo integration and therefore webmail, DAV protocols and ActiveSync support (experimental, unsupported, not fully implemented) - y/n' >> mailcow.conf
|
||||
echo "SKIP_SOGO=n" >> mailcow.conf
|
||||
;;
|
||||
MAILDIR_SUB)
|
||||
echo '# MAILDIR_SUB defines a path in a users virtual home to keep the maildir in. Leave empty for updated setups.' >> mailcow.conf
|
||||
echo "#MAILDIR_SUB=Maildir" >> mailcow.conf
|
||||
echo "MAILDIR_SUB=" >> mailcow.conf
|
||||
;;
|
||||
WATCHDOG_NOTIFY_WEBHOOK)
|
||||
echo '# Send notifications to a webhook URL that receives a POST request with the content type "application/json".' >> mailcow.conf
|
||||
echo '# You can use this to send notifications to services like Discord, Slack and others.' >> mailcow.conf
|
||||
echo '#WATCHDOG_NOTIFY_WEBHOOK=https://discord.com/api/webhooks/XXXXXXXXXXXXXXXXXXX/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' >> mailcow.conf
|
||||
;;
|
||||
WATCHDOG_NOTIFY_WEBHOOK_BODY)
|
||||
echo '# JSON body included in the webhook POST request. Needs to be in single quotes.' >> mailcow.conf
|
||||
echo '# Following variables are available: SUBJECT, BODY' >> mailcow.conf
|
||||
WEBHOOK_BODY='{"username": "mailcow Watchdog", "content": "**${SUBJECT}**\n${BODY}"}'
|
||||
echo "#WATCHDOG_NOTIFY_WEBHOOK_BODY='${WEBHOOK_BODY}'" >> mailcow.conf
|
||||
;;
|
||||
WATCHDOG_NOTIFY_BAN)
|
||||
echo '# Notify about banned IP. Includes whois lookup.' >> mailcow.conf
|
||||
echo "WATCHDOG_NOTIFY_BAN=y" >> mailcow.conf
|
||||
;;
|
||||
WATCHDOG_NOTIFY_START)
|
||||
echo '# Send a notification when the watchdog is started.' >> mailcow.conf
|
||||
echo "WATCHDOG_NOTIFY_START=y" >> mailcow.conf
|
||||
;;
|
||||
WATCHDOG_SUBJECT)
|
||||
echo '# Subject for watchdog mails. Defaults to "Watchdog ALERT" followed by the error message.' >> mailcow.conf
|
||||
echo "#WATCHDOG_SUBJECT=" >> mailcow.conf
|
||||
;;
|
||||
WATCHDOG_EXTERNAL_CHECKS)
|
||||
echo '# Checks if mailcow is an open relay. Requires a SAL. More checks will follow.' >> mailcow.conf
|
||||
echo '# No data is collected. Opt-in and anonymous.' >> mailcow.conf
|
||||
echo '# Will only work with unmodified mailcow setups.' >> mailcow.conf
|
||||
echo "WATCHDOG_EXTERNAL_CHECKS=n" >> mailcow.conf
|
||||
;;
|
||||
SOGO_EXPIRE_SESSION)
|
||||
echo '# SOGo session timeout in minutes' >> mailcow.conf
|
||||
echo "SOGO_EXPIRE_SESSION=480" >> mailcow.conf
|
||||
;;
|
||||
REDIS_PORT)
|
||||
echo "REDIS_PORT=127.0.0.1:7654" >> mailcow.conf
|
||||
;;
|
||||
DOVECOT_MASTER_USER)
|
||||
echo '# DOVECOT_MASTER_USER and _PASS must _both_ be provided. No special chars.' >> mailcow.conf
|
||||
echo '# Empty by default to auto-generate master user and password on start.' >> mailcow.conf
|
||||
echo '# User expands to DOVECOT_MASTER_USER@mailcow.local' >> mailcow.conf
|
||||
echo '# LEAVE EMPTY IF UNSURE' >> mailcow.conf
|
||||
echo "DOVECOT_MASTER_USER=" >> mailcow.conf
|
||||
;;
|
||||
DOVECOT_MASTER_PASS)
|
||||
echo '# LEAVE EMPTY IF UNSURE' >> mailcow.conf
|
||||
echo "DOVECOT_MASTER_PASS=" >> mailcow.conf
|
||||
;;
|
||||
MAILCOW_PASS_SCHEME)
|
||||
echo '# Password hash algorithm' >> mailcow.conf
|
||||
echo '# Only certain password hash algorithm are supported. For a fully list of supported schemes,' >> mailcow.conf
|
||||
echo '# see https://docs.mailcow.email/models/model-passwd/' >> mailcow.conf
|
||||
echo "MAILCOW_PASS_SCHEME=BLF-CRYPT" >> mailcow.conf
|
||||
;;
|
||||
ADDITIONAL_SERVER_NAMES)
|
||||
echo '# Additional server names for mailcow UI' >> mailcow.conf
|
||||
echo '#' >> mailcow.conf
|
||||
echo '# Specify alternative addresses for the mailcow UI to respond to' >> mailcow.conf
|
||||
echo '# This is useful when you set mail.* as ADDITIONAL_SAN and want to make sure mail.maildomain.com will always point to the mailcow UI.' >> mailcow.conf
|
||||
echo '# If the server name does not match a known site, Nginx decides by best-guess and may redirect users to the wrong web root.' >> mailcow.conf
|
||||
echo '# You can understand this as server_name directive in Nginx.' >> mailcow.conf
|
||||
echo '# Comma separated list without spaces! Example: ADDITIONAL_SERVER_NAMES=a.b.c,d.e.f' >> mailcow.conf
|
||||
echo 'ADDITIONAL_SERVER_NAMES=' >> mailcow.conf
|
||||
;;
|
||||
WEBAUTHN_ONLY_TRUSTED_VENDORS)
|
||||
echo "# WebAuthn device manufacturer verification" >> mailcow.conf
|
||||
echo '# After setting WEBAUTHN_ONLY_TRUSTED_VENDORS=y only devices from trusted manufacturers are allowed' >> mailcow.conf
|
||||
echo '# root certificates can be placed for validation under mailcow-dockerized/data/web/inc/lib/WebAuthn/rootCertificates' >> mailcow.conf
|
||||
echo 'WEBAUTHN_ONLY_TRUSTED_VENDORS=n' >> mailcow.conf
|
||||
;;
|
||||
SPAMHAUS_DQS_KEY)
|
||||
echo "# Spamhaus Data Query Service Key" >> mailcow.conf
|
||||
echo '# Optional: Leave empty for none' >> mailcow.conf
|
||||
echo '# Enter your key here if you are using a blocked ASN (OVH, AWS, Cloudflare e.g) for the unregistered Spamhaus Blocklist.' >> mailcow.conf
|
||||
echo '# If empty, it will completely disable Spamhaus blocklists if it detects that you are running on a server using a blocked AS.' >> mailcow.conf
|
||||
echo '# Otherwise it will work as usual.' >> mailcow.conf
|
||||
echo 'SPAMHAUS_DQS_KEY=' >> mailcow.conf
|
||||
;;
|
||||
WATCHDOG_VERBOSE)
|
||||
echo '# Enable watchdog verbose logging' >> mailcow.conf
|
||||
echo 'WATCHDOG_VERBOSE=n' >> mailcow.conf
|
||||
;;
|
||||
SKIP_UNBOUND_HEALTHCHECK)
|
||||
echo '# Skip Unbound (DNS Resolver) Healthchecks (NOT Recommended!) - y/n' >> mailcow.conf
|
||||
echo 'SKIP_UNBOUND_HEALTHCHECK=n' >> mailcow.conf
|
||||
;;
|
||||
DISABLE_NETFILTER_ISOLATION_RULE)
|
||||
echo '# Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n' >> mailcow.conf
|
||||
echo '# CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost' >> mailcow.conf
|
||||
echo 'DISABLE_NETFILTER_ISOLATION_RULE=n' >> mailcow.conf
|
||||
;;
|
||||
HTTP_REDIRECT)
|
||||
echo '# Redirect HTTP connections to HTTPS - y/n' >> mailcow.conf
|
||||
echo 'HTTP_REDIRECT=n' >> mailcow.conf
|
||||
;;
|
||||
ENABLE_IPV6)
|
||||
echo '# IPv6 Controller Section' >> mailcow.conf
|
||||
echo '# This variable controls the usage of IPv6 within mailcow.' >> mailcow.conf
|
||||
echo '# Can either be true or false | Defaults to true' >> mailcow.conf
|
||||
echo '# WARNING: MAKE SURE TO PROPERLY CONFIGURE IPv6 ON YOUR HOST FIRST BEFORE ENABLING THIS AS FAULTY CONFIGURATIONS CAN LEAD TO OPEN RELAYS!' >> mailcow.conf
|
||||
echo '# A COMPLETE DOCKER STACK REBUILD (compose down && compose up -d) IS NEEDED TO APPLY THIS.' >> mailcow.conf
|
||||
echo ENABLE_IPV6=${IPV6_BOOL} >> mailcow.conf
|
||||
;;
|
||||
SKIP_CLAMD)
|
||||
echo '# Skip ClamAV (clamd-mailcow) anti-virus (Rspamd will auto-detect a missing ClamAV container) - y/n' >> mailcow.conf
|
||||
echo 'SKIP_CLAMD=n' >> mailcow.conf
|
||||
;;
|
||||
SKIP_OLEFY)
|
||||
echo '# Skip Olefy (olefy-mailcow) anti-virus for Office documents (Rspamd will auto-detect a missing Olefy container) - y/n' >> mailcow.conf
|
||||
echo 'SKIP_OLEFY=n' >> mailcow.conf
|
||||
;;
|
||||
REDISPASS)
|
||||
echo "REDISPASS=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 28)" >> mailcow.conf
|
||||
;;
|
||||
SOGO_URL_ENCRYPTION_KEY)
|
||||
echo '# SOGo URL encryption key (exactly 16 characters, limited to A–Z, a–z, 0–9)' >> mailcow.conf
|
||||
echo '# This key is used to encrypt email addresses within SOGo URLs' >> mailcow.conf
|
||||
echo "SOGO_URL_ENCRYPTION_KEY=$(LC_ALL=C </dev/urandom tr -dc A-Za-z0-9 2>/dev/null | head -c 16)" >> mailcow.conf
|
||||
;;
|
||||
ACME_DNS_CHALLENGE)
|
||||
echo '# Enable DNS-01 challenge for ACME (acme-mailcow) - y/n' >> mailcow.conf
|
||||
echo '# This requires you to set ACME_DNS_PROVIDER and ACME_ACCOUNT_EMAIL below' >> mailcow.conf
|
||||
echo 'ACME_DNS_CHALLENGE=n' >> mailcow.conf
|
||||
;;
|
||||
ACME_DNS_PROVIDER)
|
||||
echo '# DNS provider for DNS-01 challenge (e.g. dns_cf, dns_azure, dns_gd, etc.)' >> mailcow.conf
|
||||
echo '# See the dns-01 provider documentation for more information.' >> mailcow.conf
|
||||
echo 'ACME_DNS_PROVIDER=dns_xxx' >> mailcow.conf
|
||||
;;
|
||||
ACME_ACCOUNT_EMAIL)
|
||||
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
|
||||
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
|
||||
;;
|
||||
*)
|
||||
echo "${option}=" >> mailcow.conf
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
FROM alpine:3.18
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --update --no-cache \
|
||||
bash \
|
||||
@@ -15,16 +18,35 @@ RUN apk upgrade --no-cache \
|
||||
tini \
|
||||
tzdata \
|
||||
python3 \
|
||||
py3-pip \
|
||||
&& pip3 install --upgrade pip \
|
||||
&& pip3 install acme-tiny
|
||||
acme-tiny \
|
||||
git \
|
||||
socat \
|
||||
&& git clone --depth 1 https://github.com/acmesh-official/acme.sh.git /opt/acme.sh \
|
||||
&& chmod +x /opt/acme.sh/acme.sh \
|
||||
&& mkdir -p /var/lib/acme/acme-sh
|
||||
|
||||
ENV ACME_SH_BIN=/opt/acme.sh/acme.sh \
|
||||
ACME_SH_HOME=/opt/acme.sh \
|
||||
ACME_SH_CONFIG_HOME=/var/lib/acme/acme-sh
|
||||
|
||||
COPY acme.sh /srv/acme.sh
|
||||
COPY functions.sh /srv/functions.sh
|
||||
COPY obtain-certificate.sh /srv/obtain-certificate.sh
|
||||
COPY obtain-certificate-dns.sh /srv/obtain-certificate-dns.sh
|
||||
COPY load-dns-config.sh /srv/load-dns-config.sh
|
||||
COPY reload-configurations.sh /srv/reload-configurations.sh
|
||||
COPY expand6.sh /srv/expand6.sh
|
||||
|
||||
RUN chmod +x /srv/*.sh
|
||||
|
||||
CMD ["/sbin/tini", "-g", "--", "/srv/acme.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=acme \
|
||||
MAILCOW_AGENT_MAIN_CMD="/sbin/tini -g -- /srv/acme.sh"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -4,9 +4,9 @@ exec 5>&1
|
||||
|
||||
# Do not attempt to write to slave
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
export REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT}"
|
||||
export REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
|
||||
else
|
||||
export REDIS_CMDLINE="redis-cli -h redis -p 6379"
|
||||
export REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning"
|
||||
fi
|
||||
|
||||
until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
|
||||
@@ -14,6 +14,17 @@ until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Create DNS-01 configuration template if it doesn't exist
|
||||
if [[ ! -f /etc/acme/dns-01.conf ]]; then
|
||||
mkdir -p /etc/acme
|
||||
cat > /etc/acme/dns-01.conf <<'EOF'
|
||||
# Add here your DNS-01 challenge configuration
|
||||
# For more information, visit the acme.sh documentation:
|
||||
# https://github.com/acmesh-official/acme.sh/wiki/dnsapi
|
||||
EOF
|
||||
echo "Created DNS-01 configuration template at /etc/acme/dns-01.conf"
|
||||
fi
|
||||
|
||||
source /srv/functions.sh
|
||||
# Thanks to https://github.com/cvmiller -> https://github.com/cvmiller/expand6
|
||||
source /srv/expand6.sh
|
||||
@@ -33,22 +44,30 @@ if [[ "${ONLY_MAILCOW_HOSTNAME}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
ONLY_MAILCOW_HOSTNAME=y
|
||||
fi
|
||||
|
||||
if [[ "${AUTODISCOVER_SAN}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
AUTODISCOVER_SAN=y
|
||||
fi
|
||||
|
||||
# Request individual certificate for every domain
|
||||
if [[ "${ENABLE_SSL_SNI}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
ENABLE_SSL_SNI=y
|
||||
fi
|
||||
|
||||
if [[ "${ACME_DNS_CHALLENGE}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
ACME_DNS_CHALLENGE=y
|
||||
fi
|
||||
|
||||
if [[ "${SKIP_LETS_ENCRYPT}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
log_f "SKIP_LETS_ENCRYPT=y, skipping Let's Encrypt..."
|
||||
sleep 365d
|
||||
exec $(readlink -f "$0")
|
||||
fi
|
||||
|
||||
log_f "Waiting for Docker API..."
|
||||
until ping dockerapi -c1 > /dev/null; do
|
||||
log_f "Waiting for Redis control bus..."
|
||||
until redis-cli -h "${REDIS_SLAVEOF_IP:-redis-mailcow}" -p "${REDIS_SLAVEOF_PORT:-6379}" -a "${REDISPASS}" --no-auth-warning ping > /dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
log_f "Docker API OK"
|
||||
log_f "Redis control bus OK"
|
||||
|
||||
log_f "Waiting for Postfix..."
|
||||
until ping postfix -c1 > /dev/null; do
|
||||
@@ -113,13 +132,13 @@ fi
|
||||
chmod 600 ${ACME_BASE}/key.pem
|
||||
|
||||
log_f "Waiting for database..."
|
||||
while ! mysqladmin status --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent > /dev/null; do
|
||||
while ! /usr/bin/mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent > /dev/null; do
|
||||
sleep 2
|
||||
done
|
||||
log_f "Database OK"
|
||||
|
||||
log_f "Waiting for Nginx..."
|
||||
until $(curl --output /dev/null --silent --head --fail http://nginx:8081); do
|
||||
until $(curl --output /dev/null --silent --head --fail http://nginx.${COMPOSE_PROJECT_NAME}_mailcow-network:8081); do
|
||||
sleep 2
|
||||
done
|
||||
log_f "Nginx OK"
|
||||
@@ -133,8 +152,8 @@ log_f "Resolver OK"
|
||||
# Waiting for domain table
|
||||
log_f "Waiting for domain table..."
|
||||
while [[ -z ${DOMAIN_TABLE} ]]; do
|
||||
curl --silent http://nginx/ >/dev/null 2>&1
|
||||
DOMAIN_TABLE=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'domain'" -Bs)
|
||||
curl --silent http://nginx.${COMPOSE_PROJECT_NAME}_mailcow-network/ >/dev/null 2>&1
|
||||
DOMAIN_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'domain'" -Bs)
|
||||
[[ -z ${DOMAIN_TABLE} ]] && sleep 10
|
||||
done
|
||||
log_f "OK" no_date
|
||||
@@ -155,18 +174,6 @@ while true; do
|
||||
fi
|
||||
if [[ ! -f ${ACME_BASE}/acme/account.pem ]]; then
|
||||
log_f "Generating missing Lets Encrypt account key..."
|
||||
if [[ ! -z ${ACME_CONTACT} ]]; then
|
||||
if ! verify_email "${ACME_CONTACT}"; then
|
||||
log_f "Invalid email address, will not start registration!"
|
||||
sleep 365d
|
||||
exec $(readlink -f "$0")
|
||||
else
|
||||
ACME_CONTACT_PARAMETER="--contact mailto:${ACME_CONTACT}"
|
||||
log_f "Valid email address, using ${ACME_CONTACT} for registration"
|
||||
fi
|
||||
else
|
||||
ACME_CONTACT_PARAMETER=""
|
||||
fi
|
||||
openssl genrsa 4096 > ${ACME_BASE}/acme/account.pem
|
||||
else
|
||||
log_f "Using existing Lets Encrypt account key ${ACME_BASE}/acme/account.pem"
|
||||
@@ -211,7 +218,11 @@ while true; do
|
||||
ADDITIONAL_SAN_ARR+=($i)
|
||||
fi
|
||||
done
|
||||
ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig')
|
||||
|
||||
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
|
||||
# Fetch certs for autoconfig and autodiscover subdomains
|
||||
ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig' 'mta-sts')
|
||||
fi
|
||||
|
||||
if [[ ${SKIP_IP_CHECK} != "y" ]]; then
|
||||
# Start IP detection
|
||||
@@ -223,7 +234,7 @@ while true; do
|
||||
|
||||
#########################################
|
||||
# IP and webroot challenge verification #
|
||||
SQL_DOMAINS=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain WHERE backupmx=0 and active=1" -Bs)
|
||||
SQL_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain WHERE backupmx=0 and active=1" -Bs)
|
||||
if [[ ! $? -eq 0 ]]; then
|
||||
log_f "Failed to read SQL domains, retrying in 1 minute..."
|
||||
sleep 1m
|
||||
@@ -242,14 +253,46 @@ while true; do
|
||||
unset VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||
declare -a VALIDATED_CONFIG_DOMAINS_SUBDOMAINS
|
||||
for SUBDOMAIN in "${ADDITIONAL_WC_ARR[@]}"; do
|
||||
if [[ "${SUBDOMAIN}.${SQL_DOMAIN}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||
if check_domain "${SUBDOMAIN}.${SQL_DOMAIN}"; then
|
||||
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${SUBDOMAIN}.${SQL_DOMAIN}")
|
||||
fi
|
||||
FULL_SUBDOMAIN="${SUBDOMAIN}.${SQL_DOMAIN}"
|
||||
|
||||
# Skip if subdomain matches MAILCOW_HOSTNAME
|
||||
if [[ "${FULL_SUBDOMAIN}" == "${MAILCOW_HOSTNAME}" ]]; then
|
||||
continue
|
||||
fi
|
||||
# Skip if subdomain is covered by a wildcard in ADDITIONAL_SAN
|
||||
if is_covered_by_wildcard "${FULL_SUBDOMAIN}"; then
|
||||
log_f "Subdomain '${FULL_SUBDOMAIN}' is covered by wildcard - skipping explicit subdomain"
|
||||
continue
|
||||
fi
|
||||
# Validate and add subdomain
|
||||
if check_domain "${FULL_SUBDOMAIN}"; then
|
||||
VALIDATED_CONFIG_DOMAINS_SUBDOMAINS+=("${FULL_SUBDOMAIN}")
|
||||
fi
|
||||
done
|
||||
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
|
||||
done
|
||||
|
||||
# Fetch alias domains where target domain has MTA-STS enabled
|
||||
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
|
||||
SQL_ALIAS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT ad.alias_domain FROM alias_domain ad INNER JOIN mta_sts m ON ad.target_domain = m.domain WHERE ad.active = 1 AND m.active = 1" -Bs)
|
||||
if [[ $? -eq 0 ]]; then
|
||||
while read alias_domain; do
|
||||
if [[ -z "${alias_domain}" ]]; then
|
||||
# ignore empty lines
|
||||
continue
|
||||
fi
|
||||
# Only add mta-sts subdomain for alias domains
|
||||
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
|
||||
# Skip if mta-sts subdomain is covered by a wildcard
|
||||
if is_covered_by_wildcard "mta-sts.${alias_domain}"; then
|
||||
log_f "Alias domain mta-sts subdomain 'mta-sts.${alias_domain}' is covered by wildcard - skipping"
|
||||
elif check_domain "mta-sts.${alias_domain}"; then
|
||||
VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}")
|
||||
fi
|
||||
fi
|
||||
done <<< "${SQL_ALIAS_DOMAINS}"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if check_domain ${MAILCOW_HOSTNAME}; then
|
||||
@@ -278,20 +321,38 @@ while true; do
|
||||
done
|
||||
fi
|
||||
|
||||
# Check if MAILCOW_HOSTNAME is covered by a wildcard in ADDITIONAL_SAN
|
||||
MAILCOW_HOSTNAME_COVERED=0
|
||||
if [[ ! -z ${VALIDATED_MAILCOW_HOSTNAME} ]]; then
|
||||
if is_covered_by_wildcard "${VALIDATED_MAILCOW_HOSTNAME}"; then
|
||||
MAILCOW_PARENT_DOMAIN=$(echo ${VALIDATED_MAILCOW_HOSTNAME} | cut -d. -f2-)
|
||||
log_f "MAILCOW_HOSTNAME '${VALIDATED_MAILCOW_HOSTNAME}' is covered by wildcard '*.${MAILCOW_PARENT_DOMAIN}' - skipping explicit hostname"
|
||||
MAILCOW_HOSTNAME_COVERED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Unique domains for server certificate
|
||||
if [[ ${ENABLE_SSL_SNI} == "y" ]]; then
|
||||
# create certificate for server name and fqdn SANs only
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
|
||||
SERVER_SAN_VALIDATED=($(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
else
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
fi
|
||||
else
|
||||
# create certificate for all domains, including all subdomains from other domains [*]
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
if [[ ${MAILCOW_HOSTNAME_COVERED} == "1" ]]; then
|
||||
SERVER_SAN_VALIDATED=($(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
else
|
||||
SERVER_SAN_VALIDATED=(${VALIDATED_MAILCOW_HOSTNAME} $(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} | xargs -n1 | sort -u | xargs))
|
||||
fi
|
||||
fi
|
||||
if [[ ! -z ${SERVER_SAN_VALIDATED[*]} ]]; then
|
||||
CERT_NAME=${SERVER_SAN_VALIDATED[0]}
|
||||
VALIDATED_CERTIFICATES+=("${CERT_NAME}")
|
||||
|
||||
# obtain server certificate if required
|
||||
ACME_CONTACT_PARAMETER=${ACME_CONTACT_PARAMETER} DOMAINS=${SERVER_SAN_VALIDATED[@]} /srv/obtain-certificate.sh rsa
|
||||
DOMAINS=${SERVER_SAN_VALIDATED[@]} /srv/obtain-certificate.sh rsa
|
||||
RETURN="$?"
|
||||
if [[ "$RETURN" == "0" ]]; then # 0 = cert created successfully
|
||||
CERT_AMOUNT_CHANGED=1
|
||||
|
||||
@@ -80,6 +80,11 @@ check_domain(){
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ ${ACME_DNS_CHALLENGE} == "y" ]]; then
|
||||
log_f "ACME_DNS_CHALLENGE=y - skipping IP and HTTP validation for ${DOMAIN}"
|
||||
return 0
|
||||
fi
|
||||
# Check if CNAME without v6 enabled target
|
||||
if [[ ! -z ${AAAA_DOMAIN} ]] && [[ -z $(echo ${AAAA_DOMAIN} | grep "^\([0-9a-fA-F]\{0,4\}:\)\{1,7\}[0-9a-fA-F]\{0,4\}$") ]]; then
|
||||
AAAA_DOMAIN=
|
||||
@@ -130,3 +135,32 @@ verify_challenge_path(){
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if a domain is covered by a wildcard (*.example.com) in ADDITIONAL_SAN
|
||||
# Usage: is_covered_by_wildcard "subdomain.example.com"
|
||||
# Returns: 0 if covered, 1 if not covered
|
||||
# Note: Only returns 0 (covered) when DNS-01 challenge is enabled,
|
||||
# as wildcards cannot be validated with HTTP-01 challenge
|
||||
is_covered_by_wildcard() {
|
||||
local DOMAIN=$1
|
||||
|
||||
# Only skip if DNS challenge is enabled (wildcards require DNS-01)
|
||||
if [[ ${ACME_DNS_CHALLENGE} != "y" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Return early if no ADDITIONAL_SAN is set
|
||||
if [[ -z ${ADDITIONAL_SAN} ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Extract parent domain (e.g., mail.example.com -> example.com)
|
||||
local PARENT_DOMAIN=$(echo ${DOMAIN} | cut -d. -f2-)
|
||||
|
||||
# Check if ADDITIONAL_SAN contains a wildcard for this parent domain
|
||||
if [[ "${ADDITIONAL_SAN}" == *"*.${PARENT_DOMAIN}"* ]]; then
|
||||
return 0 # Covered by wildcard
|
||||
fi
|
||||
|
||||
return 1 # Not covered
|
||||
}
|
||||
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
|
||||
SCRIPT_SOURCE="${BASH_SOURCE[0]:-${0}}"
|
||||
if [[ "${SCRIPT_SOURCE}" == "${0}" ]]; then
|
||||
__dns_loader_standalone=1
|
||||
else
|
||||
__dns_loader_standalone=0
|
||||
fi
|
||||
|
||||
CONFIG_PATH="${ACME_DNS_CONFIG_FILE:-/etc/acme/dns-01.conf}"
|
||||
|
||||
if [[ ! -f "${CONFIG_PATH}" ]]; then
|
||||
if [[ $__dns_loader_standalone -eq 1 ]]; then
|
||||
exit 0
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
source /srv/functions.sh
|
||||
|
||||
log_f "Loading DNS-01 configuration from ${CONFIG_PATH}"
|
||||
|
||||
LINE_NO=0
|
||||
while IFS= read -r line || [[ -n "${line}" ]]; do
|
||||
LINE_NO=$((LINE_NO+1))
|
||||
line="${line%$'\r'}"
|
||||
line_trimmed="$(printf '%s' "${line}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
[[ -z "${line_trimmed}" ]] && continue
|
||||
[[ "${line_trimmed:0:1}" == "#" ]] && continue
|
||||
if [[ "${line_trimmed}" != *=* ]]; then
|
||||
log_f "Skipping invalid DNS config line ${LINE_NO} (missing key=value)"
|
||||
continue
|
||||
fi
|
||||
KEY="${line_trimmed%%=*}"
|
||||
VALUE="${line_trimmed#*=}"
|
||||
KEY="$(printf '%s' "${KEY}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
VALUE="$(printf '%s' "${VALUE}" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
||||
if [[ -z "${KEY}" ]]; then
|
||||
log_f "Skipping invalid DNS config line ${LINE_NO} (empty key)"
|
||||
continue
|
||||
fi
|
||||
if [[ "${VALUE}" =~ ^\".*\"$ ]]; then
|
||||
VALUE="${VALUE:1:-1}"
|
||||
elif [[ "${VALUE}" =~ ^\'.*\'$ ]]; then
|
||||
VALUE="${VALUE:1:-1}"
|
||||
fi
|
||||
export "${KEY}"="${VALUE}"
|
||||
log_f "Exported DNS config key ${KEY}"
|
||||
|
||||
done < "${CONFIG_PATH}"
|
||||
|
||||
if [[ $__dns_loader_standalone -eq 1 ]]; then
|
||||
exit 0
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Return values / exit codes
|
||||
# 0 = cert created successfully
|
||||
# 1 = cert renewed successfully
|
||||
# 2 = cert not due for renewal
|
||||
# * = errors
|
||||
|
||||
source /srv/functions.sh
|
||||
|
||||
CERT_DOMAINS=(${DOMAINS[@]})
|
||||
CERT_DOMAIN=${CERT_DOMAINS[0]}
|
||||
ACME_BASE=/var/lib/acme
|
||||
|
||||
# Load optional DNS provider secrets from /etc/acme/dns-01.conf
|
||||
if [[ -f /srv/load-dns-config.sh ]]; then
|
||||
source /srv/load-dns-config.sh
|
||||
if declare -F log_f >/dev/null; then
|
||||
log_f "ACME_DNS_CHALLENGE is enabled, DNS provider secrets loaded"
|
||||
fi
|
||||
fi
|
||||
|
||||
TYPE=${1}
|
||||
PREFIX=""
|
||||
# only support rsa certificates for now
|
||||
if [[ "${TYPE}" != "rsa" ]]; then
|
||||
log_f "Unknown certificate type '${TYPE}' requested"
|
||||
exit 5
|
||||
fi
|
||||
|
||||
if [[ -z "${ACME_DNS_PROVIDER}" ]]; then
|
||||
log_f "ACME_DNS_PROVIDER is required when ACME_DNS_CHALLENGE is enabled"
|
||||
exit 6
|
||||
fi
|
||||
|
||||
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
|
||||
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
|
||||
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
|
||||
KEY=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}key.pem
|
||||
CSR=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}acme.csr
|
||||
|
||||
if [[ -z ${CERT_DOMAINS[*]} ]]; then
|
||||
log_f "Missing CERT_DOMAINS to obtain a certificate"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
if [[ "${LE_STAGING}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
if [[ ! -z "${DIRECTORY_URL}" ]]; then
|
||||
log_f "Cannot use DIRECTORY_URL with LE_STAGING=y - ignoring DIRECTORY_URL"
|
||||
fi
|
||||
log_f "Using Let's Encrypt staging servers"
|
||||
ACME_SH_SERVER_ARGS=("--staging")
|
||||
elif [[ ! -z "${DIRECTORY_URL}" ]]; then
|
||||
log_f "Using custom directory URL ${DIRECTORY_URL}"
|
||||
ACME_SH_SERVER_ARGS=("--server" "${DIRECTORY_URL}")
|
||||
else
|
||||
log_f "Using Let's Encrypt production servers"
|
||||
ACME_SH_SERVER_ARGS=("--server" "letsencrypt")
|
||||
fi
|
||||
|
||||
if [[ -f ${DOMAINS_FILE} && "$(cat ${DOMAINS_FILE})" == "${CERT_DOMAINS[*]}" ]]; then
|
||||
if [[ ! -f ${CERT} || ! -f "${KEY}" || -f "${ACME_BASE}/force_renew" ]]; then
|
||||
log_f "Certificate ${CERT} doesn't exist yet or forced renewal - start obtaining"
|
||||
elif ! openssl x509 -checkend 2592000 -noout -in ${CERT} > /dev/null; then
|
||||
log_f "Certificate ${CERT} is due for renewal (< 30 days) - start renewing"
|
||||
else
|
||||
log_f "Certificate ${CERT} validation done, neither changed nor due for renewal."
|
||||
exit 2
|
||||
fi
|
||||
else
|
||||
log_f "Certificate ${CERT} missing or changed domains '${CERT_DOMAINS[*]}' - start obtaining"
|
||||
fi
|
||||
|
||||
# Make backup
|
||||
if [[ -f ${CERT} ]]; then
|
||||
DATE=$(date +%Y-%m-%d_%H_%M_%S)
|
||||
BACKUP_DIR=${ACME_BASE}/backups/${CERT_DOMAIN}/${PREFIX}${DATE}
|
||||
log_f "Creating backups in ${BACKUP_DIR} ..."
|
||||
mkdir -p ${BACKUP_DIR}/
|
||||
[[ -f ${DOMAINS_FILE} ]] && cp ${DOMAINS_FILE} ${BACKUP_DIR}/
|
||||
[[ -f ${CERT} ]] && cp ${CERT} ${BACKUP_DIR}/
|
||||
[[ -f ${KEY} ]] && cp ${KEY} ${BACKUP_DIR}/
|
||||
[[ -f ${CSR} ]] && cp ${CSR} ${BACKUP_DIR}/
|
||||
fi
|
||||
|
||||
mkdir -p ${ACME_BASE}/${CERT_DOMAIN}
|
||||
if [[ ! -f ${KEY} ]]; then
|
||||
log_f "Copying shared private key for this certificate..."
|
||||
cp ${SHARED_KEY} ${KEY}
|
||||
chmod 600 ${KEY}
|
||||
fi
|
||||
|
||||
# Generating CSR to keep layout parity with HTTP challenge flow
|
||||
printf "[SAN]\nsubjectAltName=" > /tmp/_SAN
|
||||
printf "DNS:%s," "${CERT_DOMAINS[@]}" >> /tmp/_SAN
|
||||
sed -i '$s/,$//' /tmp/_SAN
|
||||
openssl req -new -sha256 -key ${KEY} -subj "/" -reqexts SAN -config <(cat "$(openssl version -d | sed 's/.*\"\(.*\)\"/\1/g')/openssl.cnf" /tmp/_SAN) > ${CSR}
|
||||
|
||||
log_f "Checking resolver..."
|
||||
until dig letsencrypt.org +time=3 +tries=1 @unbound > /dev/null; do
|
||||
sleep 2
|
||||
done
|
||||
log_f "Resolver OK"
|
||||
|
||||
ACME_SH_BIN_PATH=${ACME_SH_BIN:-/opt/acme.sh/acme.sh}
|
||||
ACME_SH_WORK_HOME=${ACME_SH_CONFIG_HOME:-/var/lib/acme/acme-sh}
|
||||
mkdir -p ${ACME_SH_WORK_HOME}
|
||||
|
||||
if [[ ! -x ${ACME_SH_BIN_PATH} ]]; then
|
||||
log_f "acme.sh binary not found at ${ACME_SH_BIN_PATH}"
|
||||
exit 7
|
||||
fi
|
||||
|
||||
if [[ ! -f ${ACME_SH_WORK_HOME}/account.conf ]]; then
|
||||
if [[ -z "${ACME_ACCOUNT_EMAIL}" ]]; then
|
||||
log_f "ACME_ACCOUNT_EMAIL is required to register a new acme.sh account"
|
||||
exit 8
|
||||
fi
|
||||
log_f "Registering acme.sh account for ${ACME_ACCOUNT_EMAIL}"
|
||||
REGISTER_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}" "--register-account" "-m" "${ACME_ACCOUNT_EMAIL}")
|
||||
REGISTER_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
|
||||
REGISTER_RESPONSE=$("${REGISTER_CMD[@]}" 2>&1)
|
||||
if [[ $? -ne 0 ]]; then
|
||||
log_f "Failed to register acme.sh account: ${REGISTER_RESPONSE}"
|
||||
exit 9
|
||||
fi
|
||||
fi
|
||||
|
||||
TMP_CERT=$(mktemp /tmp/acme-cert.XXXXXX)
|
||||
TMP_FULLCHAIN=$(mktemp /tmp/acme-fullchain.XXXXXX)
|
||||
|
||||
ACME_CMD=("${ACME_SH_BIN_PATH}" "--home" "${ACME_SH_WORK_HOME}" "--config-home" "${ACME_SH_WORK_HOME}" "--cert-home" "${ACME_SH_WORK_HOME}")
|
||||
ACME_CMD+=("${ACME_SH_SERVER_ARGS[@]}")
|
||||
ACME_CMD+=("--issue" "--dns" "${ACME_DNS_PROVIDER}" "--key-file" "${KEY}" "--cert-file" "${TMP_CERT}" "--fullchain-file" "${TMP_FULLCHAIN}" "--force")
|
||||
for domain in "${CERT_DOMAINS[@]}"; do
|
||||
ACME_CMD+=("-d" "${domain}")
|
||||
done
|
||||
|
||||
log_f "Using command ${ACME_CMD[*]}"
|
||||
if [[ -n "${ACME_DNS_PROVIDER}" ]]; then
|
||||
log_f "DNS provider: ${ACME_DNS_PROVIDER}"
|
||||
fi
|
||||
if compgen -A variable | grep -Eq "^DNS_|^ACME_"; then
|
||||
LOG_KEYS=$(env | grep -E "^(DNS_|ACME_)" | cut -d= -f1 | tr '\n' ' ')
|
||||
log_f "Available DNS/ACME env keys: ${LOG_KEYS}" redis_only
|
||||
fi
|
||||
ACME_RESPONSE=$("${ACME_CMD[@]}" 2>&1 | tee /dev/fd/5; exit ${PIPESTATUS[0]})
|
||||
SUCCESS="$?"
|
||||
ACME_RESPONSE_B64=$(echo "${ACME_RESPONSE}" | openssl enc -e -A -base64)
|
||||
log_f "${ACME_RESPONSE_B64}" redis_only b64
|
||||
|
||||
case "$SUCCESS" in
|
||||
0)
|
||||
log_f "Deploying certificate ${CERT}..."
|
||||
if verify_hash_match ${TMP_FULLCHAIN} ${KEY}; then
|
||||
RETURN=0
|
||||
if [[ -f ${CERT} ]]; then
|
||||
RETURN=1
|
||||
fi
|
||||
mv -f ${TMP_FULLCHAIN} ${CERT}
|
||||
rm -f ${TMP_CERT}
|
||||
echo -n ${CERT_DOMAINS[*]} > ${DOMAINS_FILE}
|
||||
log_f "Certificate successfully obtained via DNS challenge"
|
||||
exit ${RETURN}
|
||||
else
|
||||
log_f "Certificate was requested, but key and certificate hashes do not match"
|
||||
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
|
||||
exit 4
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log_f "Failed to obtain certificate ${CERT} for domains '${CERT_DOMAINS[*]}' via DNS challenge"
|
||||
redis-cli -h redis -a ${REDISPASS} --no-auth-warning SET ACME_FAIL_TIME "$(date +%s)"
|
||||
rm -f ${TMP_CERT} ${TMP_FULLCHAIN}
|
||||
exit 100${SUCCESS}
|
||||
;;
|
||||
esac
|
||||
@@ -20,6 +20,10 @@ if [[ "${TYPE}" != "rsa" ]]; then
|
||||
log_f "Unknown certificate type '${TYPE}' requested"
|
||||
exit 5
|
||||
fi
|
||||
|
||||
if [[ "${ACME_DNS_CHALLENGE}" == "y" ]]; then
|
||||
exec /srv/obtain-certificate-dns.sh "$@"
|
||||
fi
|
||||
DOMAINS_FILE=${ACME_BASE}/${CERT_DOMAIN}/domains
|
||||
CERT=${ACME_BASE}/${CERT_DOMAIN}/${PREFIX}cert.pem
|
||||
SHARED_KEY=${ACME_BASE}/acme/${PREFIX}key.pem # must already exist
|
||||
@@ -93,8 +97,8 @@ until dig letsencrypt.org +time=3 +tries=1 @unbound > /dev/null; do
|
||||
sleep 2
|
||||
done
|
||||
log_f "Resolver OK"
|
||||
log_f "Using command acme-tiny ${DIRECTORY_URL} ${ACME_CONTACT_PARAMETER} --account-key ${ACME_BASE}/acme/account.pem --disable-check --csr ${CSR} --acme-dir /var/www/acme/"
|
||||
ACME_RESPONSE=$(acme-tiny ${DIRECTORY_URL} ${ACME_CONTACT_PARAMETER} \
|
||||
log_f "Using command acme-tiny ${DIRECTORY_URL} --account-key ${ACME_BASE}/acme/account.pem --disable-check --csr ${CSR} --acme-dir /var/www/acme/"
|
||||
ACME_RESPONSE=$(acme-tiny ${DIRECTORY_URL} \
|
||||
--account-key ${ACME_BASE}/acme/account.pem \
|
||||
--disable-check \
|
||||
--csr ${CSR} \
|
||||
@@ -124,7 +128,7 @@ case "$SUCCESS" in
|
||||
;;
|
||||
*) # non-zero is non-fun
|
||||
log_f "Failed to obtain certificate ${CERT} for domains '${CERT_DOMAINS[*]}'"
|
||||
redis-cli -h redis SET ACME_FAIL_TIME "$(date +%s)"
|
||||
redis-cli -h redis -a ${REDISPASS} --no-auth-warning SET ACME_FAIL_TIME "$(date +%s)"
|
||||
exit 100${SUCCESS}
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -1,45 +1,29 @@
|
||||
#!/bin/bash
|
||||
# Tell every live replica of nginx / dovecot / postfix to reload (or restart
|
||||
# on cert-amount change) via the mailcow-agent control bus. Replaces the old
|
||||
# dockerapi-based container_id lookup + exec dance.
|
||||
|
||||
# Reading container IDs
|
||||
# Wrapping as array to ensure trimmed content when calling $NGINX etc.
|
||||
NGINX=($(curl --silent --insecure https://dockerapi/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"nginx-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
|
||||
DOVECOT=($(curl --silent --insecure https://dockerapi/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"dovecot-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
|
||||
POSTFIX=($(curl --silent --insecure https://dockerapi/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" | tr "\n" " "))
|
||||
|
||||
reload_nginx(){
|
||||
echo "Reloading Nginx..."
|
||||
NGINX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${NGINX}/exec -d '{"cmd":"reload", "task":"nginx"}' --silent -H 'Content-type: application/json' | jq -r .type)
|
||||
[[ ${NGINX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Nginx, restarting container..."; restart_container ${NGINX} ; }
|
||||
reload_service() {
|
||||
local svc="$1"
|
||||
echo "Reloading ${svc} via mailcow-agent..."
|
||||
if ! mailcow-agent-cli send "${svc}" reload >/dev/null; then
|
||||
echo "Could not publish reload to ${svc}, attempting restart..."
|
||||
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
reload_dovecot(){
|
||||
echo "Reloading Dovecot..."
|
||||
DOVECOT_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${DOVECOT}/exec -d '{"cmd":"reload", "task":"dovecot"}' --silent -H 'Content-type: application/json' | jq -r .type)
|
||||
[[ ${DOVECOT_RELOAD_RET} != 'success' ]] && { echo "Could not reload Dovecot, restarting container..."; restart_container ${DOVECOT} ; }
|
||||
}
|
||||
|
||||
reload_postfix(){
|
||||
echo "Reloading Postfix..."
|
||||
POSTFIX_RELOAD_RET=$(curl -X POST --insecure https://dockerapi/containers/${POSTFIX}/exec -d '{"cmd":"reload", "task":"postfix"}' --silent -H 'Content-type: application/json' | jq -r .type)
|
||||
[[ ${POSTFIX_RELOAD_RET} != 'success' ]] && { echo "Could not reload Postfix, restarting container..."; restart_container ${POSTFIX} ; }
|
||||
}
|
||||
|
||||
restart_container(){
|
||||
for container in $*; do
|
||||
echo "Restarting ${container}..."
|
||||
C_REST_OUT=$(curl -X POST --insecure https://dockerapi/containers/${container}/restart --silent | jq -r '.msg')
|
||||
echo "${C_REST_OUT}"
|
||||
done
|
||||
restart_service() {
|
||||
local svc="$1"
|
||||
echo "Restarting ${svc} via mailcow-agent..."
|
||||
mailcow-agent-cli send "${svc}" restart >/dev/null || true
|
||||
}
|
||||
|
||||
if [[ "${CERT_AMOUNT_CHANGED}" == "1" ]]; then
|
||||
restart_container ${NGINX}
|
||||
restart_container ${DOVECOT}
|
||||
restart_container ${POSTFIX}
|
||||
restart_service nginx
|
||||
restart_service dovecot
|
||||
restart_service postfix
|
||||
else
|
||||
reload_nginx
|
||||
#reload_dovecot
|
||||
restart_container ${DOVECOT}
|
||||
#reload_postfix
|
||||
restart_container ${POSTFIX}
|
||||
reload_service nginx
|
||||
restart_service dovecot
|
||||
restart_service postfix
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Builder image for mailcow-agent. Each service Dockerfile pulls the static
|
||||
# binary from here via:
|
||||
#
|
||||
# COPY --from=ghcr.io/mailcow/agent:VERSION /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
#
|
||||
# For local development: build this image first.
|
||||
#
|
||||
# docker build -t ghcr.io/mailcow/agent:dev data/Dockerfiles/agent/
|
||||
#
|
||||
# CI publishes a versioned tag and the service Dockerfiles pin against it via
|
||||
# ARG AGENT_IMAGE.
|
||||
|
||||
FROM golang:1.22-alpine AS build
|
||||
|
||||
ENV CGO_ENABLED=0 \
|
||||
GOOS=linux
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /out \
|
||||
&& go build -trimpath -ldflags="-s -w" \
|
||||
-o /out/mailcow-agent ./cmd/mailcow-agent \
|
||||
&& cp mailcow-agent-cli /out/mailcow-agent-cli \
|
||||
&& chmod +x /out/mailcow-agent-cli
|
||||
|
||||
# Final stage: tiny image whose only purpose is to be a COPY --from source.
|
||||
FROM scratch
|
||||
COPY --from=build /out/mailcow-agent /out/mailcow-agent
|
||||
COPY --from=build /out/mailcow-agent-cli /out/mailcow-agent-cli
|
||||
@@ -0,0 +1,16 @@
|
||||
# mailcow-agent
|
||||
|
||||
Each mailcow service container (postfix, dovecot, …) runs `mailcow-agent` as
|
||||
ENTRYPOINT. It supervises the original service main process and exposes its
|
||||
control commands over a Redis Pub/Sub bus:
|
||||
|
||||
- `mailcow.control.<service>` — request channel (Backend → Agent)
|
||||
- `mailcow.reply.<request_id>` — per-request reply channel
|
||||
- `mailcow.events.<topic>` — broadcast events
|
||||
- `mailcow.nodes.<service>` (ZSET) + `mailcow.node.<service>.<node_id>` (HASH) — heartbeat registry
|
||||
- `mailcow.stats.<service>.<node_id>` (HASH) — per-node cpu/memory stats
|
||||
|
||||
Service behaviour is selected via `MAILCOW_AGENT_SERVICE=<service>`. The main
|
||||
process command is configured via `MAILCOW_AGENT_MAIN_CMD` (string, executed via
|
||||
`sh -c` so existing entrypoints/supervisord commands keep working).
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Per-container control-bus subscriber. Subscribes to mailcow.control.<service>
|
||||
// on Redis, runs handlers from the per-service command table, publishes
|
||||
// heartbeats and stats. Optionally supervises a child process.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/bus"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/registry"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/services"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/stats"
|
||||
)
|
||||
|
||||
const agentVersion = "0.1.0"
|
||||
|
||||
// atomicSignal shares the last caught terminal signal between the handler
|
||||
// goroutine and main() so it can be forwarded to the supervised child.
|
||||
type atomicSignal struct{ v atomic.Int32 }
|
||||
|
||||
func (a *atomicSignal) Store(s syscall.Signal) { a.v.Store(int32(s)) }
|
||||
func (a *atomicSignal) Load() os.Signal { return syscall.Signal(a.v.Load()) }
|
||||
|
||||
// healthState holds the latest health probe result. Written by the probe loop,
|
||||
// read by the heartbeat loop.
|
||||
type healthState struct {
|
||||
mu sync.RWMutex
|
||||
ok bool
|
||||
detail string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (h *healthState) Set(ok bool, detail string) {
|
||||
h.mu.Lock()
|
||||
h.ok = ok
|
||||
h.detail = detail
|
||||
h.at = time.Now()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *healthState) Snapshot() (ok bool, detail string, at time.Time) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.ok, h.detail, h.at
|
||||
}
|
||||
|
||||
func main() {
|
||||
service := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_SERVICE"))
|
||||
if service == "" {
|
||||
fmt.Fprintf(os.Stderr, "mailcow-agent: MAILCOW_AGENT_SERVICE is required. Known: %v\n", services.Known())
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// `mailcow-agent healthcheck` runs the probe once and exits 0/1
|
||||
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
|
||||
runHealthcheckOnce(service)
|
||||
}
|
||||
|
||||
nodeID := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_NODE_ID"))
|
||||
if nodeID == "" {
|
||||
h, err := os.Hostname()
|
||||
if err != nil {
|
||||
log.Fatalf("mailcow-agent: hostname: %v", err)
|
||||
}
|
||||
nodeID = h
|
||||
}
|
||||
|
||||
mainCmd := strings.TrimSpace(os.Getenv("MAILCOW_AGENT_MAIN_CMD"))
|
||||
// host-agent has no supervised child; everything else runs one.
|
||||
wantsSupervisor := service != "host" && mainCmd != ""
|
||||
|
||||
rdb, err := newRedis()
|
||||
if err != nil {
|
||||
log.Fatalf("mailcow-agent: redis: %v", err)
|
||||
}
|
||||
defer rdb.Close()
|
||||
|
||||
// Start the supervised process before serving bus requests — restart/stop
|
||||
// handlers assume something is already running.
|
||||
var sup *proc.Supervisor
|
||||
if wantsSupervisor {
|
||||
sup = proc.New(mainCmd)
|
||||
if err := sup.Start(); err != nil {
|
||||
log.Fatalf("mailcow-agent: start main: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
table, err := services.Build(service, sup)
|
||||
if err != nil {
|
||||
log.Fatalf("mailcow-agent: %v", err)
|
||||
}
|
||||
|
||||
// We handle signals ourselves so we can (a) suppress the Go-runtime stack
|
||||
// dump on SIGQUIT (php-fpm-alpine's STOPSIGNAL) and (b) remember which
|
||||
// signal arrived to forward it to the child on shutdown.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT,
|
||||
syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2)
|
||||
defer signal.Stop(sigCh)
|
||||
|
||||
stopSig := atomicSignal{}
|
||||
stopSig.Store(syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
for sig := range sigCh {
|
||||
switch sig {
|
||||
case syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT:
|
||||
stopSig.Store(sig.(syscall.Signal))
|
||||
log.Printf("mailcow-agent: caught %s, beginning graceful shutdown", sig)
|
||||
cancel()
|
||||
return
|
||||
case syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2:
|
||||
if sup != nil {
|
||||
sup.SignalChild(sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Initial state is "ok" so the service isn't flagged unhealthy before the
|
||||
// first probe has run.
|
||||
health := &healthState{ok: true, detail: "", at: time.Now()}
|
||||
if table.HealthProbe != nil {
|
||||
go runHealthLoop(ctx, table.HealthProbe, health, 10*time.Second)
|
||||
}
|
||||
|
||||
hb := registry.Heartbeat{
|
||||
Service: service,
|
||||
NodeID: nodeID,
|
||||
Version: agentVersion,
|
||||
StartedAt: time.Now(),
|
||||
Image: os.Getenv("MAILCOW_AGENT_IMAGE"),
|
||||
Health: health,
|
||||
}
|
||||
go registry.Loop(ctx, rdb, hb, 10*time.Second)
|
||||
|
||||
// cgroup stats for this container. Host metrics come from exec.host-stats.
|
||||
pub := stats.NewPublisher(rdb, service, nodeID)
|
||||
go pub.Run(ctx, 10*time.Second)
|
||||
|
||||
srv := bus.NewServer(rdb, table, nodeID)
|
||||
busErrCh := make(chan error, 1)
|
||||
go func() { busErrCh <- srv.Run(ctx) }()
|
||||
|
||||
log.Printf("mailcow-agent: service=%s node=%s ready (commands=%d)", service, nodeID, len(table.Handlers))
|
||||
|
||||
// Exit only on outside termination or fatal bus error. A crashed/stopped
|
||||
// child should not tear down the container — the operator may want to
|
||||
// issue `start` over the bus afterwards.
|
||||
exitCode := 0
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Println("mailcow-agent: shutdown signal received")
|
||||
case err := <-busErrCh:
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("mailcow-agent: bus loop exited: %v", err)
|
||||
exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown bounded at 35s.
|
||||
shutCtx, shutCancel := context.WithTimeout(context.Background(), 35*time.Second)
|
||||
defer shutCancel()
|
||||
_ = srv.Shutdown(shutCtx)
|
||||
_ = registry.Deregister(shutCtx, rdb, service, nodeID)
|
||||
if sup != nil {
|
||||
// Forward the exact signal we received so the child sees the same
|
||||
// shutdown semantics it would without us in front (e.g. SIGQUIT →
|
||||
// php-fpm graceful drain).
|
||||
if err := sup.StopWithSignal(shutCtx, stopSig.Load()); err != nil {
|
||||
log.Printf("mailcow-agent: stop main: %v", err)
|
||||
}
|
||||
}
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
// runHealthcheckOnce runs the local probe with a tight deadline and exits 0/1.
|
||||
// Used by the `healthcheck` sub-command path.
|
||||
func runHealthcheckOnce(service string) {
|
||||
table, err := services.Build(service, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mailcow-agent healthcheck:", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if table.HealthProbe == nil {
|
||||
// Services without a probe are considered healthy.
|
||||
os.Exit(0)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := table.HealthProbe(ctx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "unhealthy:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// runHealthLoop ticks the probe and updates state. Same probe path as the
|
||||
// healthcheck sub-command.
|
||||
func runHealthLoop(ctx context.Context, probe commands.HealthProbe, state *healthState, interval time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
check := func() {
|
||||
pctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
if err := probe(pctx); err != nil {
|
||||
state.Set(false, err.Error())
|
||||
} else {
|
||||
state.Set(true, "")
|
||||
}
|
||||
}
|
||||
check()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
check()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newRedis() (*redis.Client, error) {
|
||||
addr := os.Getenv("REDIS_SLAVEOF_IP")
|
||||
port := os.Getenv("REDIS_SLAVEOF_PORT")
|
||||
if addr == "" {
|
||||
addr = "redis-mailcow"
|
||||
port = "6379"
|
||||
}
|
||||
if port == "" {
|
||||
port = "6379"
|
||||
}
|
||||
pass := os.Getenv("REDISPASS")
|
||||
cli := redis.NewClient(&redis.Options{
|
||||
Addr: addr + ":" + port,
|
||||
Password: pass,
|
||||
DB: 0,
|
||||
MaxRetries: -1,
|
||||
MinRetryBackoff: 200 * time.Millisecond,
|
||||
MaxRetryBackoff: 5 * time.Second,
|
||||
})
|
||||
// Wait up to 2 minutes for Redis to come up before giving up
|
||||
deadline := time.Now().Add(2 * time.Minute)
|
||||
var lastErr error
|
||||
for attempt := 1; time.Now().Before(deadline); attempt++ {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err := cli.Ping(ctx).Err()
|
||||
cancel()
|
||||
if err == nil {
|
||||
return cli, nil
|
||||
}
|
||||
lastErr = err
|
||||
wait := time.Duration(attempt) * time.Second
|
||||
if wait > 10*time.Second {
|
||||
wait = 10 * time.Second
|
||||
}
|
||||
log.Printf("mailcow-agent: waiting for redis %s (attempt %d): %v", addr, attempt, err)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
return nil, fmt.Errorf("ping %s after 2m: %w", addr, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
module github.com/mailcow/mailcow-dockerized/agent
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/oklog/ulid/v2 v2.1.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package bus implements the Redis Pub/Sub control bus: subscribing to the
|
||||
// service's control channel, dispatching envelopes to a commands.Table, and
|
||||
// publishing responses back to env.ReplyTo.
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/envelope"
|
||||
)
|
||||
|
||||
// ControlChannel assembles the per-service control channel.
|
||||
func ControlChannel(service string) string { return "mailcow.control." + service }
|
||||
|
||||
// Server subscribes to a control channel and dispatches commands.
|
||||
type Server struct {
|
||||
rdb *redis.Client
|
||||
table *commands.Table
|
||||
nodeID string
|
||||
dedupe *lru
|
||||
stop chan struct{}
|
||||
stopped sync.Once
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewServer wires a fresh server. nodeID is stamped into every Response and is
|
||||
// what the backend's fan-in aggregator uses to attribute results.
|
||||
func NewServer(rdb *redis.Client, table *commands.Table, nodeID string) *Server {
|
||||
return &Server{
|
||||
rdb: rdb,
|
||||
table: table,
|
||||
nodeID: nodeID,
|
||||
dedupe: newLRU(1024),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks, subscribing to ControlChannel(service) and dispatching incoming
|
||||
// envelopes concurrently. It returns when ctx is done or Shutdown is called.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
channel := ControlChannel(s.table.Service)
|
||||
sub := s.rdb.Subscribe(ctx, channel)
|
||||
defer sub.Close()
|
||||
if _, err := sub.Receive(ctx); err != nil {
|
||||
return fmt.Errorf("bus: subscribe %s: %w", channel, err)
|
||||
}
|
||||
msgs := sub.Channel()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.wg.Wait()
|
||||
return ctx.Err()
|
||||
case <-s.stop:
|
||||
s.wg.Wait()
|
||||
return nil
|
||||
case m, ok := <-msgs:
|
||||
if !ok {
|
||||
s.wg.Wait()
|
||||
return errors.New("bus: subscription channel closed")
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func(payload string) {
|
||||
defer s.wg.Done()
|
||||
s.dispatch(ctx, payload)
|
||||
}(m.Payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown signals Run to stop and waits for in-flight handlers (bounded by
|
||||
// ctx).
|
||||
func (s *Server) Shutdown(ctx context.Context) error {
|
||||
s.stopped.Do(func() { close(s.stop) })
|
||||
done := make(chan struct{})
|
||||
go func() { s.wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) dispatch(parent context.Context, payload string) {
|
||||
var req envelope.Request
|
||||
if err := json.Unmarshal([]byte(payload), &req); err != nil {
|
||||
// Malformed envelope: no RequestID/ReplyTo we can trust — drop.
|
||||
return
|
||||
}
|
||||
if req.RequestID != "" && !s.dedupe.add(req.RequestID) {
|
||||
// Duplicate (retry of an idempotent command): silently absorb.
|
||||
return
|
||||
}
|
||||
// Per-node targeting: if args.target_node is set and doesn't match us,
|
||||
// drop silently. The intended replica picks it up and replies.
|
||||
if target, ok := req.Args["target_node"].(string); ok && target != "" && target != s.nodeID {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := handlerContext(parent, req.Deadline)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
resp := envelope.Response{RequestID: req.RequestID, OK: true, Node: s.nodeID}
|
||||
|
||||
if h := s.table.Lookup(req.Cmd); h == nil {
|
||||
resp.OK = false
|
||||
resp.Error = fmt.Sprintf("no handler for cmd %q", req.Cmd)
|
||||
resp.ErrorCode = envelope.ErrCodeUnsupportedCommand
|
||||
} else {
|
||||
result, err := runWithRecover(ctx, h, req.Args)
|
||||
switch {
|
||||
case err == nil:
|
||||
resp.Result = result
|
||||
case errors.Is(err, commands.ErrNotFound):
|
||||
resp.OK = false
|
||||
resp.Error = err.Error()
|
||||
resp.ErrorCode = envelope.ErrCodeNotFound
|
||||
case errors.Is(err, commands.ErrValidation):
|
||||
resp.OK = false
|
||||
resp.Error = err.Error()
|
||||
resp.ErrorCode = envelope.ErrCodeValidation
|
||||
case errors.Is(err, context.DeadlineExceeded), errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
resp.OK = false
|
||||
resp.Error = err.Error()
|
||||
resp.ErrorCode = envelope.ErrCodeTimeout
|
||||
default:
|
||||
resp.OK = false
|
||||
resp.Error = err.Error()
|
||||
resp.ErrorCode = envelope.ErrCodeInternal
|
||||
}
|
||||
}
|
||||
resp.DurationMS = time.Since(start).Milliseconds()
|
||||
|
||||
if req.ReplyTo == "" {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Replies go through a List (RPUSH + EXPIRE), not Pub/Sub. This sidesteps
|
||||
// the "subscribe-before-publish" race and lets the backend fan-in via
|
||||
// BLPOP — important because PhpRedis's subscribe() blocks, so we can't
|
||||
// publish on the same connection after subscribing. Use parent ctx so a
|
||||
// per-handler deadline can't stop us from delivering the timeout response.
|
||||
pipe := s.rdb.Pipeline()
|
||||
pipe.RPush(parent, req.ReplyTo, data)
|
||||
pipe.Expire(parent, req.ReplyTo, 60*time.Second)
|
||||
_, _ = pipe.Exec(parent)
|
||||
}
|
||||
|
||||
func runWithRecover(ctx context.Context, h commands.Handler, args map[string]any) (out any, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("handler panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return h(ctx, args)
|
||||
}
|
||||
|
||||
func handlerContext(parent context.Context, deadline time.Time) (context.Context, context.CancelFunc) {
|
||||
if deadline.IsZero() {
|
||||
return context.WithCancel(parent)
|
||||
}
|
||||
return context.WithDeadline(parent, deadline)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// lru is a tiny request-id deduplication cache. The bus treats Pub/Sub
|
||||
// retries (same request_id) as no-ops. Not a security boundary — only a
|
||||
// best-effort guard against accidental double-execution.
|
||||
type lru struct {
|
||||
mu sync.Mutex
|
||||
cap int
|
||||
idx map[string]*list.Element
|
||||
list *list.List
|
||||
}
|
||||
|
||||
func newLRU(cap int) *lru {
|
||||
return &lru{cap: cap, idx: make(map[string]*list.Element, cap), list: list.New()}
|
||||
}
|
||||
|
||||
// add returns true if id is new and was inserted; false if it was already
|
||||
// known (caller should skip the duplicate).
|
||||
func (l *lru) add(id string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if _, ok := l.idx[id]; ok {
|
||||
return false
|
||||
}
|
||||
e := l.list.PushFront(id)
|
||||
l.idx[id] = e
|
||||
for l.list.Len() > l.cap {
|
||||
old := l.list.Back()
|
||||
l.list.Remove(old)
|
||||
delete(l.idx, old.Value.(string))
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Package commands defines the per-service handler table. The bus dispatcher
|
||||
// looks up handlers by name and wraps the result in an envelope.Response.
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrNotFound signals that the target (queue id, mailbox, …) doesn't live on
|
||||
// this node. For broadcast operations the aggregator still counts success if
|
||||
// any other node returns ok.
|
||||
var ErrNotFound = errors.New("not_found")
|
||||
|
||||
// ErrValidation indicates a missing or malformed argument.
|
||||
var ErrValidation = errors.New("validation")
|
||||
|
||||
// Handler executes a single command for a service.
|
||||
type Handler func(ctx context.Context, args map[string]any) (any, error)
|
||||
|
||||
// HealthProbe returns nil if the supervised service is healthy, error otherwise.
|
||||
// Shared between the `healthcheck` sub-command and the agent's heartbeat loop.
|
||||
type HealthProbe func(ctx context.Context) error
|
||||
|
||||
// Table is the per-service command registry built once at startup.
|
||||
type Table struct {
|
||||
Service string
|
||||
Handlers map[string]Handler
|
||||
HealthProbe HealthProbe
|
||||
}
|
||||
|
||||
// New constructs an empty table for a service.
|
||||
func New(service string) *Table {
|
||||
return &Table{Service: service, Handlers: make(map[string]Handler)}
|
||||
}
|
||||
|
||||
// Register adds a handler. Duplicate registration panics — wiring bugs should
|
||||
// be loud.
|
||||
func (t *Table) Register(cmd string, h Handler) {
|
||||
if _, dup := t.Handlers[cmd]; dup {
|
||||
panic("commands: duplicate handler " + t.Service + "/" + cmd)
|
||||
}
|
||||
t.Handlers[cmd] = h
|
||||
}
|
||||
|
||||
// Lookup returns the handler for cmd or nil.
|
||||
func (t *Table) Lookup(cmd string) Handler {
|
||||
return t.Handlers[cmd]
|
||||
}
|
||||
|
||||
// ArgString extracts a required string argument.
|
||||
func ArgString(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok {
|
||||
return "", errArg(key, "missing")
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok || s == "" {
|
||||
return "", errArg(key, "must be non-empty string")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ArgStringOpt returns an optional string argument with a default.
|
||||
func ArgStringOpt(args map[string]any, key, def string) string {
|
||||
if v, ok := args[key]; ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func errArg(key, reason string) error {
|
||||
return &validationError{key: key, reason: reason}
|
||||
}
|
||||
|
||||
type validationError struct{ key, reason string }
|
||||
|
||||
func (e *validationError) Error() string { return "arg " + e.key + ": " + e.reason }
|
||||
func (e *validationError) Is(target error) bool {
|
||||
return target == ErrValidation
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// RunOptions configures a single Run invocation.
|
||||
type RunOptions struct {
|
||||
// Stdin, if non-nil, is written to the process stdin.
|
||||
Stdin []byte
|
||||
// CombinedOutputCap limits the captured output (truncated at the end).
|
||||
// 0 means unlimited. The agent uses ~1 MiB for cat-queue, smaller for
|
||||
// status-style commands.
|
||||
OutputCap int
|
||||
}
|
||||
|
||||
// RunResult is what every shell-style command returns.
|
||||
type RunResult struct {
|
||||
Stdout string `json:"stdout,omitempty"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
}
|
||||
|
||||
// Run executes argv[0] argv[1:] under ctx (the bus deadline). It does not
|
||||
// translate exit codes to errors — callers inspect r.ExitCode themselves so
|
||||
// they can map e.g. "queue id not found" exit codes to ErrNotFound.
|
||||
func Run(ctx context.Context, opts RunOptions, argv ...string) (*RunResult, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, fmt.Errorf("commands.Run: empty argv")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if opts.Stdin != nil {
|
||||
cmd.Stdin = bytes.NewReader(opts.Stdin)
|
||||
}
|
||||
err := cmd.Run()
|
||||
|
||||
out := stdout.String()
|
||||
errOut := stderr.String()
|
||||
if opts.OutputCap > 0 {
|
||||
if len(out) > opts.OutputCap {
|
||||
out = out[:opts.OutputCap] + "\n…(truncated)"
|
||||
}
|
||||
if len(errOut) > opts.OutputCap {
|
||||
errOut = errOut[:opts.OutputCap] + "\n…(truncated)"
|
||||
}
|
||||
}
|
||||
|
||||
exit := 0
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
exit = exitErr.ExitCode()
|
||||
err = nil
|
||||
}
|
||||
return &RunResult{Stdout: out, Stderr: errOut, ExitCode: exit}, err
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package envelope defines the wire format for the mailcow-agent control bus.
|
||||
package envelope
|
||||
|
||||
import "time"
|
||||
|
||||
// Request is what the backend publishes on mailcow.control.<service>.
|
||||
type Request struct {
|
||||
Cmd string `json:"cmd"`
|
||||
RequestID string `json:"request_id"`
|
||||
Args map[string]any `json:"args,omitempty"`
|
||||
ReplyTo string `json:"reply_to,omitempty"`
|
||||
Deadline time.Time `json:"deadline,omitempty"`
|
||||
IssuedBy string `json:"issued_by,omitempty"`
|
||||
}
|
||||
|
||||
// Response is what the agent publishes on the reply_to channel.
|
||||
type Response struct {
|
||||
RequestID string `json:"request_id"`
|
||||
OK bool `json:"ok"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorCode string `json:"error_code,omitempty"`
|
||||
DurationMS int64 `json:"duration_ms"`
|
||||
Node string `json:"node,omitempty"`
|
||||
}
|
||||
|
||||
// Error codes returned in Response.ErrorCode. Keep in sync with the V2 schema.
|
||||
const (
|
||||
ErrCodeValidation = "validation"
|
||||
ErrCodeNotFound = "not_found"
|
||||
ErrCodeTimeout = "timeout"
|
||||
ErrCodeUnsupportedCommand = "unsupported_command"
|
||||
ErrCodeInternal = "internal"
|
||||
)
|
||||
@@ -0,0 +1,253 @@
|
||||
// Package proc supervises the service's main process — postfix, dovecot,
|
||||
// nginx, … — as a child of the agent. It exposes the high-level lifecycle
|
||||
// verbs (reload/restart/stop/start) used by the per-service command tables.
|
||||
//
|
||||
// "reload" → SIGHUP
|
||||
// "restart" → SIGTERM, wait, exec again
|
||||
// "stop" → SIGTERM, leave stopped
|
||||
// "start" → exec again (only if currently stopped)
|
||||
package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Supervisor wraps a single child process.
|
||||
type Supervisor struct {
|
||||
cmdLine string // shell command (passed to `sh -c …`)
|
||||
stopSignal os.Signal
|
||||
stopGrace time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
stopped bool
|
||||
exitedCh chan struct{}
|
||||
}
|
||||
|
||||
// New constructs a Supervisor. cmdLine is executed via `sh -c` so existing
|
||||
// docker-entrypoint.sh scripts keep working without quoting headaches.
|
||||
func New(cmdLine string) *Supervisor {
|
||||
return &Supervisor{
|
||||
cmdLine: cmdLine,
|
||||
stopSignal: syscall.SIGTERM,
|
||||
stopGrace: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the child process. Returns an error if it cannot be spawned.
|
||||
// The agent's main() also blocks on Wait() to surface exit status.
|
||||
func (s *Supervisor) Start() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cmd != nil && s.cmd.Process != nil && !s.stopped {
|
||||
return errors.New("proc: already running")
|
||||
}
|
||||
// `exec ` prefix tells the shell to replace itself with the command
|
||||
// instead of forking and waiting. Without it, sh stays alive as the
|
||||
// parent of the real service process, signals from us land on the
|
||||
// shell instead of on the service, and SIGHUP for config reloads
|
||||
// silently does nothing. With the prefix the supervised PID *is* the
|
||||
// service after the script's own `exec "$@"` chains through.
|
||||
cmd := exec.Command("/bin/sh", "-c", "exec "+s.cmdLine)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("proc: start: %w", err)
|
||||
}
|
||||
s.cmd = cmd
|
||||
s.stopped = false
|
||||
s.exitedCh = make(chan struct{})
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(s.exitedCh)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the child exits and returns its exit code.
|
||||
func (s *Supervisor) Wait() int {
|
||||
s.mu.Lock()
|
||||
exited := s.exitedCh
|
||||
cmd := s.cmd
|
||||
s.mu.Unlock()
|
||||
if exited == nil {
|
||||
return -1
|
||||
}
|
||||
<-exited
|
||||
if cmd == nil || cmd.ProcessState == nil {
|
||||
return -1
|
||||
}
|
||||
return cmd.ProcessState.ExitCode()
|
||||
}
|
||||
|
||||
// SignalChild forwards a single signal to the supervised child without
|
||||
// changing the supervisor's lifecycle state. Used to relay SIGHUP/USR1/USR2
|
||||
// from the agent's signal handler to the service so operators can still
|
||||
// `docker compose kill -s HUP postfix-mailcow` and see the expected effect.
|
||||
func (s *Supervisor) SignalChild(sig os.Signal) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cmd == nil || s.cmd.Process == nil || s.stopped {
|
||||
return errors.New("proc: not running")
|
||||
}
|
||||
return s.cmd.Process.Signal(sig)
|
||||
}
|
||||
|
||||
// Reload sends SIGHUP. Returns nil if the signal was delivered.
|
||||
func (s *Supervisor) Reload() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cmd == nil || s.cmd.Process == nil || s.stopped {
|
||||
return errors.New("proc: not running")
|
||||
}
|
||||
return s.cmd.Process.Signal(syscall.SIGHUP)
|
||||
}
|
||||
|
||||
// Stop sends the configured stop signal and waits for the process to exit
|
||||
// (bounded by stopGrace). Marks the supervisor as stopped — Start must be
|
||||
// called again to relaunch.
|
||||
func (s *Supervisor) Stop(ctx context.Context) error {
|
||||
return s.StopWithSignal(ctx, s.stopSignal)
|
||||
}
|
||||
|
||||
// StopWithSignal is like Stop but lets the caller override the stop signal.
|
||||
// Used by main() to forward whatever signal Docker sent us (SIGTERM for
|
||||
// most containers, SIGQUIT for php-fpm-alpine which uses SIGQUIT for
|
||||
// graceful shutdown) so the child gets the same signal semantics it would
|
||||
// receive without the agent in front of it.
|
||||
func (s *Supervisor) StopWithSignal(ctx context.Context, sig os.Signal) error {
|
||||
s.mu.Lock()
|
||||
cmd := s.cmd
|
||||
exited := s.exitedCh
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.stopped = true
|
||||
s.mu.Unlock()
|
||||
|
||||
sysSig, ok := sig.(syscall.Signal)
|
||||
if !ok {
|
||||
sysSig = syscall.SIGTERM
|
||||
}
|
||||
pgid, err := syscall.Getpgid(cmd.Process.Pid)
|
||||
if err == nil {
|
||||
_ = syscall.Kill(-pgid, sysSig)
|
||||
} else {
|
||||
_ = cmd.Process.Signal(sysSig)
|
||||
}
|
||||
|
||||
timer := time.NewTimer(s.stopGrace)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-exited:
|
||||
return nil
|
||||
case <-timer.C:
|
||||
// Last resort: SIGKILL the whole process group.
|
||||
if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil {
|
||||
_ = syscall.Kill(-pgid, syscall.SIGKILL)
|
||||
} else {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
<-exited
|
||||
return errors.New("proc: forced kill after grace period")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Restart performs Stop+Start using the supervisor's default stop signal.
|
||||
// Different from a Docker-initiated shutdown: here it's an explicit "restart
|
||||
// this service" command, so we want the standard SIGTERM semantics.
|
||||
func (s *Supervisor) Restart(ctx context.Context) error {
|
||||
if err := s.Stop(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Start()
|
||||
}
|
||||
|
||||
// IsRunning reports whether the supervised child is currently alive (started
|
||||
// and not yet exited or stopped).
|
||||
func (s *Supervisor) IsRunning() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.stopped || s.cmd == nil || s.cmd.Process == nil {
|
||||
return false
|
||||
}
|
||||
// exitedCh is closed when the child exits. Non-blocking read.
|
||||
select {
|
||||
case <-s.exitedCh:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// WaitStable blocks for `settle` and returns nil if the supervised child is
|
||||
// still running at the end, otherwise an error describing the exit. Used by
|
||||
// the `restart` command to give the operator real "did it come back up"
|
||||
// feedback instead of an immediate OK.
|
||||
func (s *Supervisor) WaitStable(ctx context.Context, settle time.Duration) error {
|
||||
s.mu.Lock()
|
||||
exited := s.exitedCh
|
||||
s.mu.Unlock()
|
||||
if exited == nil {
|
||||
return errors.New("proc: not running")
|
||||
}
|
||||
select {
|
||||
case <-exited:
|
||||
// Child died within the settle window.
|
||||
s.mu.Lock()
|
||||
cmd := s.cmd
|
||||
s.mu.Unlock()
|
||||
code := -1
|
||||
if cmd != nil && cmd.ProcessState != nil {
|
||||
code = cmd.ProcessState.ExitCode()
|
||||
}
|
||||
return fmt.Errorf("proc: child exited within settle window (code=%d)", code)
|
||||
case <-time.After(settle):
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// Forward installs a signal forwarder: SIGINT/SIGTERM/SIGHUP/SIGUSR1/SIGUSR2
|
||||
// received by the agent are propagated to the child. Returns a cancel func
|
||||
// to release the handler.
|
||||
func (s *Supervisor) Forward(signals ...os.Signal) func() {
|
||||
ch := make(chan os.Signal, len(signals)+1)
|
||||
signalNotify(ch, signals...)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case sig := <-ch:
|
||||
s.mu.Lock()
|
||||
cmd := s.cmd
|
||||
s.mu.Unlock()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Signal(sig)
|
||||
}
|
||||
if sig == syscall.SIGTERM || sig == syscall.SIGINT {
|
||||
// On terminal signals propagate and let main exit.
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(done)
|
||||
signalStop(ch)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
)
|
||||
|
||||
// Indirection so tests can stub these out if ever needed.
|
||||
var (
|
||||
signalNotify = signal.Notify
|
||||
signalStop = signal.Stop
|
||||
)
|
||||
|
||||
var _ = os.Stdout // anchor import for go vet
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package registry publishes per-node heartbeats to Redis so the backend can
|
||||
// enumerate live containers. Two keys per service:
|
||||
//
|
||||
// ZSET mailcow.nodes.<service> score=unix_ts member=node_id
|
||||
// HASH mailcow.node.<service>.<node_id> { version, started_at, image, health* }
|
||||
//
|
||||
// Both keys have a 30s TTL refreshed every 10s. Deregister clears them on
|
||||
// graceful shutdown.
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// HealthSnapshotter returns the latest health probe result so the heartbeat
|
||||
// can attach it to each tick. Implemented by main.healthState.
|
||||
type HealthSnapshotter interface {
|
||||
Snapshot() (ok bool, detail string, at time.Time)
|
||||
}
|
||||
|
||||
// Heartbeat carries the metadata published with every refresh.
|
||||
type Heartbeat struct {
|
||||
Service string
|
||||
NodeID string
|
||||
Version string
|
||||
StartedAt time.Time
|
||||
Image string
|
||||
Health HealthSnapshotter // optional; nil → omit health fields
|
||||
}
|
||||
|
||||
func nodesKey(service string) string { return "mailcow.nodes." + service }
|
||||
func nodeKey(service, node string) string { return "mailcow.node." + service + "." + node }
|
||||
|
||||
// Publish writes one heartbeat tick. Callers run this in a loop.
|
||||
func Publish(ctx context.Context, rdb *redis.Client, h Heartbeat) error {
|
||||
now := time.Now().Unix()
|
||||
fields := map[string]any{
|
||||
"version": h.Version,
|
||||
"started_at": h.StartedAt.UTC().Format(time.RFC3339),
|
||||
"image": h.Image,
|
||||
"node_id": h.NodeID,
|
||||
"service": h.Service,
|
||||
"updated_at": strconv.FormatInt(now, 10),
|
||||
}
|
||||
if h.Health != nil {
|
||||
ok, detail, at := h.Health.Snapshot()
|
||||
if ok {
|
||||
fields["health"] = "ok"
|
||||
} else {
|
||||
fields["health"] = "fail"
|
||||
}
|
||||
fields["health_detail"] = detail
|
||||
fields["health_at"] = strconv.FormatInt(at.Unix(), 10)
|
||||
}
|
||||
pipe := rdb.Pipeline()
|
||||
pipe.ZAdd(ctx, nodesKey(h.Service), redis.Z{Score: float64(now), Member: h.NodeID})
|
||||
pipe.Expire(ctx, nodesKey(h.Service), 5*time.Minute)
|
||||
pipe.HSet(ctx, nodeKey(h.Service, h.NodeID), fields)
|
||||
pipe.Expire(ctx, nodeKey(h.Service, h.NodeID), 30*time.Second)
|
||||
_, err := pipe.Exec(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry: heartbeat exec: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deregister removes the node from the ZSET and deletes its detail hash.
|
||||
// Called on graceful shutdown so the dashboard reflects intentional stops
|
||||
// immediately rather than waiting for TTL.
|
||||
func Deregister(ctx context.Context, rdb *redis.Client, service, nodeID string) error {
|
||||
pipe := rdb.Pipeline()
|
||||
pipe.ZRem(ctx, nodesKey(service), nodeID)
|
||||
pipe.Del(ctx, nodeKey(service, nodeID))
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// Loop runs Publish on a ticker until ctx is done. It is the typical caller.
|
||||
func Loop(ctx context.Context, rdb *redis.Client, h Heartbeat, interval time.Duration) {
|
||||
// Publish once immediately so the dashboard sees us right away.
|
||||
_ = Publish(ctx, rdb, h)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = Publish(ctx, rdb, h)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package services
|
||||
|
||||
import "time"
|
||||
|
||||
// nowStamp returns a sortable timestamp used to suffix moved/garbage maildirs
|
||||
// so repeated cleanups don't collide.
|
||||
func nowStamp() string {
|
||||
return time.Now().UTC().Format("20060102T150405Z")
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("dovecot", buildDovecot) }
|
||||
|
||||
const vmailRoot = "/var/vmail"
|
||||
|
||||
func dovecotHealthProbe(ctx context.Context) error {
|
||||
// IMAP greeting on :143 — must be "* OK ..."
|
||||
conn, err := net.DialTimeout("tcp", "127.0.0.1:143", 3*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 64)
|
||||
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read greeting: %w", err)
|
||||
}
|
||||
greeting := string(buf[:n])
|
||||
if !strings.HasPrefix(greeting, "* OK") {
|
||||
return fmt.Errorf("unexpected greeting: %s", strings.TrimSpace(greeting))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildDovecot(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("dovecot")
|
||||
t.HealthProbe = dovecotHealthProbe
|
||||
|
||||
// `dovecot reload` re-reads config without restarting the master process.
|
||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "dovecot", "reload")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
addLifecycleExceptReload(t, sup)
|
||||
|
||||
t.Register("exec.fts-rescan", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user := commands.ArgStringOpt(args, "user", "")
|
||||
argv := []string{"doveadm", "fts", "rescan"}
|
||||
if user != "" {
|
||||
argv = append(argv, "-u", user)
|
||||
} else {
|
||||
argv = append(argv, "-A")
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, argv...)
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.sieve-list", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "sieve", "list", "-u", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
return nil, &runError{msg: strings.TrimSpace(r.Stderr)}
|
||||
}
|
||||
scripts := splitNonEmpty(r.Stdout)
|
||||
return map[string]any{"scripts": scripts}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.sieve-print", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
script, err := commands.ArgString(args, "script")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 1 << 20}, "doveadm", "sieve", "get", "-u", user, script)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
return nil, &runError{msg: strings.TrimSpace(r.Stderr)}
|
||||
}
|
||||
return map[string]any{"body": r.Stdout}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.acl-get", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// First enumerate mailboxes, then collect ACLs per mailbox.
|
||||
boxes, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "mailbox", "list", "-u", user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if boxes.ExitCode != 0 {
|
||||
return nil, &runError{msg: strings.TrimSpace(boxes.Stderr)}
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, mbx := range splitNonEmpty(boxes.Stdout) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "get", "-u", user, mbx)
|
||||
if err != nil || r.ExitCode != 0 {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(r.Stdout), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "ID") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
out = append(out, map[string]any{
|
||||
"mailbox": mbx,
|
||||
"identifier": fields[0],
|
||||
"rights": strings.Join(fields[1:], " "),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]any{"acls": out}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.acl-set", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox, err := commands.ArgString(args, "mailbox")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identifier, err := commands.ArgString(args, "identifier")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rights, err := commands.ArgString(args, "rights")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "set", "-u", user, mailbox, identifier, rights)
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.acl-delete", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
user, err := commands.ArgString(args, "user")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mailbox, err := commands.ArgString(args, "mailbox")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identifier, err := commands.ArgString(args, "identifier")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "doveadm", "acl", "delete", "-u", user, mailbox, identifier)
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.maildir-cleanup", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
maildir, err := commands.ArgString(args, "maildir")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertSafeMaildirPath(maildir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src := filepath.Join(vmailRoot, maildir)
|
||||
dst := filepath.Join(vmailRoot, "_garbage", maildir+"_"+nowStamp())
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return nil, commands.ErrNotFound
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o770); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, os.Rename(src, dst)
|
||||
})
|
||||
|
||||
t.Register("exec.df", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
dir := commands.ArgStringOpt(args, "dir", "/var/vmail")
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(dir, &st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := uint64(st.Blocks) * uint64(st.Bsize)
|
||||
free := uint64(st.Bavail) * uint64(st.Bsize)
|
||||
used := size - free
|
||||
pct := 0
|
||||
if size > 0 {
|
||||
pct = int(float64(used) / float64(size) * 100)
|
||||
}
|
||||
// Format: Filesystem,Size,Used,Avail,Use%,Mounted-on
|
||||
return fmt.Sprintf("%s,%s,%s,%s,%d%%,%s",
|
||||
"local", humanBytes(size), humanBytes(used), humanBytes(free), pct, dir), nil
|
||||
})
|
||||
|
||||
t.Register("exec.maildir-move", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
from, err := commands.ArgString(args, "from")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to, err := commands.ArgString(args, "to")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertSafeMaildirPath(from); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := assertSafeMaildirPath(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src := filepath.Join(vmailRoot, from)
|
||||
dst := filepath.Join(vmailRoot, to)
|
||||
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||
return nil, commands.ErrNotFound
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o770); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, os.Rename(src, dst)
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// addLifecycleExceptReload wires restart/stop/start without overriding reload,
|
||||
// which postfix/dovecot define themselves (canonical CLI command).
|
||||
func addLifecycleExceptReload(t *commands.Table, sup *proc.Supervisor) {
|
||||
if sup == nil {
|
||||
return
|
||||
}
|
||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Restart(ctx)
|
||||
})
|
||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Stop(ctx)
|
||||
})
|
||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Start()
|
||||
})
|
||||
}
|
||||
|
||||
func splitNonEmpty(s string) []string {
|
||||
out := []string{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// assertSafeMaildirPath blocks path traversal and absolute paths — relative
|
||||
// names under /var/vmail only.
|
||||
func assertSafeMaildirPath(p string) error {
|
||||
if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "..") {
|
||||
return &validationErr{msg: "unsafe maildir path"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type validationErr struct{ msg string }
|
||||
|
||||
func (e *validationErr) Error() string { return e.msg }
|
||||
func (e *validationErr) Is(target error) bool { return target == commands.ErrValidation }
|
||||
|
||||
// humanBytes renders a byte count in `df -H` style (1000-based units).
|
||||
func humanBytes(n uint64) string {
|
||||
const unit = 1000
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
div, exp := uint64(unit), 0
|
||||
for x := n / unit; x >= unit; x /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f%c", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
// Services without any exec.* commands of their own — lifecycle only.
|
||||
func init() {
|
||||
Register("clamd", genericBuilder("clamd", tcpProbe("127.0.0.1:3310", 2*time.Second)))
|
||||
Register("olefy", genericBuilder("olefy", tcpProbe("127.0.0.1:10055", 2*time.Second)))
|
||||
Register("postfix-tlspol", genericBuilder("postfix-tlspol", tcpProbe("127.0.0.1:8642", 2*time.Second)))
|
||||
Register("php-fpm", genericBuilder("php-fpm", tcpProbe("127.0.0.1:9001", 2*time.Second)))
|
||||
Register("acme", genericBuilder("acme", nil))
|
||||
Register("watchdog", genericBuilder("watchdog", nil))
|
||||
Register("netfilter", genericBuilder("netfilter", nil))
|
||||
Register("ofelia", genericBuilder("ofelia", nil))
|
||||
Register("dovecot-fts", genericBuilder("dovecot-fts", nil))
|
||||
}
|
||||
|
||||
func genericBuilder(name string, probe commands.HealthProbe) Builder {
|
||||
return func(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New(name)
|
||||
t.HealthProbe = probe
|
||||
addLifecycle(t, sup)
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
func tcpProbe(addr string, timeout time.Duration) commands.HealthProbe {
|
||||
return func(ctx context.Context) error {
|
||||
return probeTCP(addr, timeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
)
|
||||
|
||||
// runError is what we return when a shell command exited non-zero but the
|
||||
// failure is not a "target not found" case. The bus maps it to
|
||||
// ErrCodeInternal.
|
||||
type runError struct{ msg string }
|
||||
|
||||
func (e *runError) Error() string { return e.msg }
|
||||
|
||||
// asError converts a (RunResult, err) pair from commands.Run into a single
|
||||
// error: pre-exec error → return as-is; non-zero exit → wrap stderr.
|
||||
func asError(r *commands.RunResult, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
msg := strings.TrimSpace(r.Stderr)
|
||||
if msg == "" {
|
||||
msg = "command exited " + itoa(r.ExitCode)
|
||||
}
|
||||
return &runError{msg: msg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// asNotFoundOrError is the variant for queue/mailbox operations that may fail
|
||||
// because the target doesn't live on this node. Maps known stderr fragments
|
||||
// to commands.ErrNotFound so broadcast aggregation works.
|
||||
func asNotFoundOrError(r *commands.RunResult, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.ExitCode == 0 {
|
||||
return nil
|
||||
}
|
||||
if matchesAny(r.Stderr, notFoundFragments) {
|
||||
return commands.ErrNotFound
|
||||
}
|
||||
return &runError{msg: strings.TrimSpace(r.Stderr)}
|
||||
}
|
||||
|
||||
func matchesAny(haystack string, fragments []string) bool {
|
||||
for _, f := range fragments {
|
||||
if strings.Contains(haystack, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
// avoid strconv import for a one-shot; small ints only
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := false
|
||||
if i < 0 {
|
||||
neg = true
|
||||
i = -i
|
||||
}
|
||||
var b [20]byte
|
||||
n := len(b)
|
||||
for i > 0 {
|
||||
n--
|
||||
b[n] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
if neg {
|
||||
n--
|
||||
b[n] = '-'
|
||||
}
|
||||
return string(b[n:])
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("host", buildHost) }
|
||||
|
||||
// hostProcRoot is where the host-agent container mounts /proc. If we're not
|
||||
// running as host-agent, falling back to /proc still produces sensible numbers
|
||||
// (the container's own view) so dashboards don't blank out in unit tests.
|
||||
var hostProcRoot = "/host/proc"
|
||||
|
||||
func resolveProc(p string) string {
|
||||
if _, err := os.Stat(hostProcRoot); err == nil {
|
||||
return hostProcRoot + p
|
||||
}
|
||||
return "/proc" + p
|
||||
}
|
||||
|
||||
func buildHost(_ *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("host")
|
||||
// No lifecycle — the host-agent container has no main process to manage.
|
||||
|
||||
t.Register("exec.df", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
path := commands.ArgStringOpt(args, "path", "/")
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return nil, fmt.Errorf("statfs %s: %w", path, err)
|
||||
}
|
||||
size := int64(stat.Blocks) * int64(stat.Bsize)
|
||||
free := int64(stat.Bavail) * int64(stat.Bsize)
|
||||
used := size - free
|
||||
return map[string]any{
|
||||
"path": path,
|
||||
"size": size,
|
||||
"used": used,
|
||||
"available": free,
|
||||
}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.host-stats", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return readHostStats()
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func readHostStats() (map[string]any, error) {
|
||||
out := map[string]any{
|
||||
"system_time": time.Now().Format("2006-01-02 15:04:05"),
|
||||
"architecture": readArchitecture(),
|
||||
}
|
||||
|
||||
if uptime, err := readUptime(); err == nil {
|
||||
out["uptime"] = int64(uptime)
|
||||
} else {
|
||||
out["uptime"] = int64(0)
|
||||
}
|
||||
|
||||
cores := readCPUCores()
|
||||
cpuUsage, _ := sampleHostCPU(500 * time.Millisecond)
|
||||
out["cpu"] = map[string]any{
|
||||
"cores": cores,
|
||||
"usage": cpuUsage,
|
||||
}
|
||||
|
||||
memTotal, memUsage := readMemoryTotalAndUsagePct()
|
||||
out["memory"] = map[string]any{
|
||||
"total": memTotal, // bytes
|
||||
"usage": memUsage, // percent 0..100
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// readArchitecture returns the host's machine architecture (e.g. "x86_64",
|
||||
// "aarch64"). Falls back to a single dash if syscall.Uname fails.
|
||||
func readArchitecture() string {
|
||||
var u syscall.Utsname
|
||||
if err := syscall.Uname(&u); err != nil {
|
||||
return "-"
|
||||
}
|
||||
return charsToString(u.Machine[:])
|
||||
}
|
||||
|
||||
func charsToString(b []int8) string {
|
||||
out := make([]byte, 0, len(b))
|
||||
for _, c := range b {
|
||||
if c == 0 {
|
||||
break
|
||||
}
|
||||
out = append(out, byte(c))
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// readCPUCores counts `^processor` lines in /proc/cpuinfo. On a container
|
||||
// with /host/proc bind-mounted this gives the host's logical CPU count,
|
||||
// not the container's cgroup limits.
|
||||
func readCPUCores() int {
|
||||
f, err := os.Open(resolveProc("/cpuinfo"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
n := 0
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
if strings.HasPrefix(sc.Text(), "processor") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// readMemoryTotalAndUsagePct reads /proc/meminfo and returns (total_bytes,
|
||||
// usage_pct_0_100). "Usage" is computed as (Total - Available)/Total which
|
||||
// matches what tools like `free` show as "used".
|
||||
func readMemoryTotalAndUsagePct() (int64, int) {
|
||||
f, err := os.Open(resolveProc("/meminfo"))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var total, available int64
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
switch fields[0] {
|
||||
case "MemTotal:":
|
||||
total = parseInt64(fields[1]) * 1024
|
||||
case "MemAvailable:":
|
||||
available = parseInt64(fields[1]) * 1024
|
||||
}
|
||||
}
|
||||
if total <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
used := total - available
|
||||
if available <= 0 {
|
||||
used = total
|
||||
}
|
||||
pct := int(float64(used) / float64(total) * 100.0)
|
||||
if pct < 0 {
|
||||
pct = 0
|
||||
}
|
||||
if pct > 100 {
|
||||
pct = 100
|
||||
}
|
||||
return total, pct
|
||||
}
|
||||
|
||||
func readUptime() (float64, error) {
|
||||
b, err := os.ReadFile(resolveProc("/uptime"))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fields := strings.Fields(string(b))
|
||||
if len(fields) < 1 {
|
||||
return 0, fmt.Errorf("malformed uptime")
|
||||
}
|
||||
return strconv.ParseFloat(fields[0], 64)
|
||||
}
|
||||
|
||||
// sampleHostCPU returns CPU utilization (0..100) sampled over `window`.
|
||||
func sampleHostCPU(window time.Duration) (float64, error) {
|
||||
a, err := readCPULine()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
time.Sleep(window)
|
||||
b, err := readCPULine()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
totalA, totalB := sum(a), sum(b)
|
||||
idleA, idleB := a[3], b[3]
|
||||
dTotal, dIdle := totalB-totalA, idleB-idleA
|
||||
if dTotal == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return 100.0 * float64(dTotal-dIdle) / float64(dTotal), nil
|
||||
}
|
||||
|
||||
func readCPULine() ([]int64, error) {
|
||||
f, err := os.Open(resolveProc("/stat"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
if !sc.Scan() {
|
||||
return nil, fmt.Errorf("empty /proc/stat")
|
||||
}
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return nil, fmt.Errorf("unexpected /proc/stat first line")
|
||||
}
|
||||
out := make([]int64, 0, len(fields)-1)
|
||||
for _, f := range fields[1:] {
|
||||
n, err := strconv.ParseInt(f, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sum(xs []int64) int64 {
|
||||
var s int64
|
||||
for _, x := range xs {
|
||||
s += x
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseInt64(s string) int64 {
|
||||
n, _ := strconv.ParseInt(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("nginx", buildNginx) }
|
||||
|
||||
func nginxHealthProbe(ctx context.Context) error {
|
||||
if err := probeShell(ctx, 3*time.Second, "nginx", "-t"); err != nil {
|
||||
return err
|
||||
}
|
||||
return probeTCP("127.0.0.1:8081", 2*time.Second)
|
||||
}
|
||||
|
||||
func buildNginx(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("nginx")
|
||||
t.HealthProbe = nginxHealthProbe
|
||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "nginx", "-s", "reload")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
addLifecycleExceptReload(t, sup)
|
||||
t.Register("exec.test-config", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "nginx", "-t")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"ok": r.ExitCode == 0,
|
||||
"output": r.Stderr + r.Stdout,
|
||||
}, nil
|
||||
})
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("postfix", buildPostfix) }
|
||||
|
||||
// notFoundFragments are substrings emitted by postsuper/postqueue when the
|
||||
// requested queue id doesn't live on this node. Broadcast handlers map them
|
||||
// to commands.ErrNotFound so the backend can count partial success.
|
||||
var notFoundFragments = []string{
|
||||
"No such file or directory",
|
||||
"no such file",
|
||||
"unknown",
|
||||
}
|
||||
|
||||
func postfixHealthProbe(ctx context.Context) error {
|
||||
if err := probeSMTPGreeting("127.0.0.1:25", 3*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
return probeShell(ctx, 5*time.Second, "postfix", "status")
|
||||
}
|
||||
|
||||
func buildPostfix(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("postfix")
|
||||
t.HealthProbe = postfixHealthProbe
|
||||
|
||||
// Override generic reload — `postfix reload` is the canonical operation,
|
||||
// not SIGHUP-to-supervisord (which would just rotate logs).
|
||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postfix", "reload")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
// Lifecycle: stop/start/restart still go through the supervisor.
|
||||
if sup != nil {
|
||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Restart(ctx)
|
||||
})
|
||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Stop(ctx)
|
||||
})
|
||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Start()
|
||||
})
|
||||
}
|
||||
|
||||
t.Register("exec.mailq", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 8 << 20}, "postqueue", "-j")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
return nil, &runError{msg: "postqueue failed: " + r.Stderr}
|
||||
}
|
||||
// postqueue -j prints one JSON object per line.
|
||||
entries := make([]map[string]any, 0)
|
||||
for _, line := range strings.Split(strings.TrimSpace(r.Stdout), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &obj); err == nil {
|
||||
entries = append(entries, obj)
|
||||
}
|
||||
}
|
||||
return map[string]any{"queue": entries}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.flush-queue", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postqueue", "-f")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.delete-from-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-d", qid)
|
||||
return nil, asNotFoundOrError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.hold-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-h", qid)
|
||||
return nil, asNotFoundOrError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.unhold-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-H", qid)
|
||||
return nil, asNotFoundOrError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.deliver-now", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postqueue", "-i", qid)
|
||||
return nil, asNotFoundOrError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.cat-queue", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
qid, err := commands.ArgString(args, "queue_id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{OutputCap: 2 << 20}, "postcat", "-q", qid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
if matchesAny(r.Stderr, notFoundFragments) {
|
||||
return nil, commands.ErrNotFound
|
||||
}
|
||||
return nil, &runError{msg: "postcat failed: " + r.Stderr}
|
||||
}
|
||||
return map[string]any{"body": r.Stdout}, nil
|
||||
})
|
||||
|
||||
t.Register("exec.super-delete", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "postsuper", "-d", "ALL")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
)
|
||||
|
||||
// probeTCP opens a TCP connection to addr within timeout. Returns nil if the
|
||||
// port accepts a connection, otherwise the dial error.
|
||||
func probeTCP(addr string, timeout time.Duration) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeSMTPGreeting connects to addr and reads the SMTP greeting line. The
|
||||
// service is considered healthy if the line starts with "220".
|
||||
func probeSMTPGreeting(addr string, timeout time.Duration) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||
line, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
return fmt.Errorf("read greeting: %w", err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "220") {
|
||||
return fmt.Errorf("unexpected greeting: %s", strings.TrimSpace(line))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeHTTP issues a GET to url, checks for a 2xx status.
|
||||
func probeHTTP(ctx context.Context, url string, timeout time.Duration) error {
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(cctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("http %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// probeShell runs argv with a timeout and returns nil if exit code is 0.
|
||||
func probeShell(ctx context.Context, timeout time.Duration, argv ...string) error {
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
r, err := commands.Run(cctx, commands.RunOptions{}, argv...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
msg := strings.TrimSpace(r.Stderr)
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("exit %d", r.ExitCode)
|
||||
}
|
||||
return errors.New(msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("rspamd", buildRspamd) }
|
||||
|
||||
func rspamdHealthProbe(ctx context.Context) error {
|
||||
return probeHTTP(ctx, "http://127.0.0.1:11334/ping", 3*time.Second)
|
||||
}
|
||||
|
||||
// Override file rspamd reads on startup for the controller's enable_password.
|
||||
const rspamdWorkerPasswordPath = "/etc/rspamd/override.d/worker-controller-password.inc"
|
||||
|
||||
func buildRspamd(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("rspamd")
|
||||
t.HealthProbe = rspamdHealthProbe
|
||||
addLifecycle(t, sup)
|
||||
|
||||
t.Register("exec.set-worker-password", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
password, err := commands.ArgString(args, "password")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// rspamadm pw -e -p <pw> writes the hashed value to stdout.
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "rspamadm", "pw", "-e", "-p", password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.ExitCode != 0 {
|
||||
return nil, &runError{msg: "rspamadm pw failed: " + strings.TrimSpace(r.Stderr)}
|
||||
}
|
||||
hash := strings.TrimSpace(r.Stdout)
|
||||
// rspamd distinguishes `password` (read-only access to the controller)
|
||||
// from `enable_password` (write access — restart, settings, learn).
|
||||
content := "enable_password = \"" + hash + "\";\n"
|
||||
if err := os.MkdirAll(filepath.Dir(rspamdWorkerPasswordPath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(rspamdWorkerPasswordPath, []byte(content), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Must do a full re-fork of workers (SIGHUP to rspamd master), not
|
||||
// `rspamadm control reload`
|
||||
if sup != nil {
|
||||
return nil, sup.Reload()
|
||||
}
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
t.Register("exec.relearn-spam", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
path, err := commands.ArgString(args, "file")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{Stdin: data}, "rspamc", "learn_spam")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
t.Register("exec.relearn-ham", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
path, err := commands.ArgString(args, "file")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{Stdin: data}, "rspamc", "learn_ham")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Package services registers per-service command tables. The agent selects
|
||||
// the right table at startup via MAILCOW_AGENT_SERVICE.
|
||||
//
|
||||
// A service "builder" receives a Supervisor for lifecycle commands; services
|
||||
// that don't supervise a main process (currently just "host") pass nil and
|
||||
// the generic lifecycle commands are skipped.
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
// Builder constructs a command table for a service. sup may be nil for
|
||||
// services without a supervised main process.
|
||||
type Builder func(sup *proc.Supervisor) *commands.Table
|
||||
|
||||
var registry = map[string]Builder{}
|
||||
|
||||
// Register installs a builder for a service name. Called from init() in each
|
||||
// per-service file.
|
||||
func Register(service string, b Builder) {
|
||||
if _, dup := registry[service]; dup {
|
||||
panic("services: duplicate registration for " + service)
|
||||
}
|
||||
registry[service] = b
|
||||
}
|
||||
|
||||
// Build returns the table for service, or an error if no builder exists.
|
||||
func Build(service string, sup *proc.Supervisor) (*commands.Table, error) {
|
||||
b, ok := registry[service]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services: unknown service %q (set MAILCOW_AGENT_SERVICE correctly)", service)
|
||||
}
|
||||
return b(sup), nil
|
||||
}
|
||||
|
||||
// Known returns the list of registered service names (sorted-ish, depends on
|
||||
// map iteration — for help output only).
|
||||
func Known() []string {
|
||||
out := make([]string, 0, len(registry))
|
||||
for k := range registry {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// restartSettle is how long we wait after a Start to verify the new child
|
||||
// didn't immediately crash. Gives the operator real "did the service come
|
||||
// back up?" feedback instead of an instant OK that hides flapping services.
|
||||
const restartSettle = 3 * time.Second
|
||||
|
||||
// addLifecycle wires reload/restart/stop/start onto t backed by sup. Services
|
||||
// override these (e.g. postfix overrides reload to run `postfix reload`).
|
||||
func addLifecycle(t *commands.Table, sup *proc.Supervisor) {
|
||||
if sup == nil {
|
||||
return
|
||||
}
|
||||
t.Register("reload", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Reload()
|
||||
})
|
||||
t.Register("restart", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
if err := sup.Restart(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sup.WaitStable(ctx, restartSettle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"status": "restarted", "settled_ms": int(restartSettle / time.Millisecond)}, nil
|
||||
})
|
||||
t.Register("stop", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
return nil, sup.Stop(ctx)
|
||||
})
|
||||
t.Register("start", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
if err := sup.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := sup.WaitStable(ctx, restartSettle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"status": "started", "settled_ms": int(restartSettle / time.Millisecond)}, nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("sogo", buildSogo) }
|
||||
|
||||
func sogoHealthProbe(ctx context.Context) error {
|
||||
return probeHTTP(ctx, "http://127.0.0.1:20000/SOGo.index/", 3*time.Second)
|
||||
}
|
||||
|
||||
func buildSogo(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("sogo")
|
||||
t.HealthProbe = sogoHealthProbe
|
||||
addLifecycle(t, sup)
|
||||
|
||||
t.Register("exec.rename-user", func(ctx context.Context, args map[string]any) (any, error) {
|
||||
oldName, err := commands.ArgString(args, "old")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newName, err := commands.ArgString(args, "new")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "sogo-tool", "rename-user", oldName, newName)
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/commands"
|
||||
"github.com/mailcow/mailcow-dockerized/agent/internal/proc"
|
||||
)
|
||||
|
||||
func init() { Register("unbound", buildUnbound) }
|
||||
|
||||
func unboundHealthProbe(ctx context.Context) error {
|
||||
return probeShell(ctx, 3*time.Second, "dig", "+time=2", "+tries=1", "@127.0.0.1", "mailcow.email", "A")
|
||||
}
|
||||
|
||||
func buildUnbound(sup *proc.Supervisor) *commands.Table {
|
||||
t := commands.New("unbound")
|
||||
t.HealthProbe = unboundHealthProbe
|
||||
addLifecycle(t, sup)
|
||||
t.Register("exec.flush-cache", func(ctx context.Context, _ map[string]any) (any, error) {
|
||||
r, err := commands.Run(ctx, commands.RunOptions{}, "unbound-control", "flush_zone", ".")
|
||||
return nil, asError(r, err)
|
||||
})
|
||||
return t
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package stats reads cgroup CPU + memory usage and publishes them to
|
||||
//
|
||||
// HASH mailcow.stats.<service>.<node_id>
|
||||
//
|
||||
// with a 30s TTL. Supports both cgroup v1 and v2. The numbers are intentionally
|
||||
// approximate — they replace what dockerapi exposed via /containers/<id>/stats.
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// Sample is one observation. CPUPercent is the share of one host CPU consumed
|
||||
// since the previous sample (range 0..100*numCPU).
|
||||
type Sample struct {
|
||||
CPUPercent float64
|
||||
MemoryBytes int64
|
||||
MemoryLimit int64
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
func statsKey(service, node string) string { return "mailcow.stats." + service + "." + node }
|
||||
|
||||
// Publisher reads cgroup metrics and pushes them to Redis on a ticker.
|
||||
type Publisher struct {
|
||||
rdb *redis.Client
|
||||
service string
|
||||
node string
|
||||
|
||||
// previous CPU sample to derive a delta-based percent
|
||||
prevCPUNanos int64
|
||||
prevAt time.Time
|
||||
}
|
||||
|
||||
// NewPublisher constructs a publisher. Caller drives it via Run.
|
||||
func NewPublisher(rdb *redis.Client, service, node string) *Publisher {
|
||||
return &Publisher{rdb: rdb, service: service, node: node}
|
||||
}
|
||||
|
||||
// Run blocks on a ticker until ctx is done.
|
||||
func (p *Publisher) Run(ctx context.Context, interval time.Duration) {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
// Prime the CPU sample so the first publish has a real delta.
|
||||
if cpu, ok := readCPUNanos(); ok {
|
||||
p.prevCPUNanos = cpu
|
||||
p.prevAt = time.Now()
|
||||
}
|
||||
// Immediate first publish so the dashboard never sees a node without a
|
||||
// stats hash. CPU is 0 in this first sample (no prev delta yet); memory
|
||||
// is already accurate.
|
||||
_ = p.publish(ctx, p.sample())
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = p.publish(ctx, p.sample())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Publisher) sample() Sample {
|
||||
s := Sample{Timestamp: time.Now()}
|
||||
if cpu, ok := readCPUNanos(); ok {
|
||||
if !p.prevAt.IsZero() {
|
||||
dCPU := cpu - p.prevCPUNanos
|
||||
dT := s.Timestamp.Sub(p.prevAt).Nanoseconds()
|
||||
if dT > 0 && dCPU >= 0 {
|
||||
s.CPUPercent = (float64(dCPU) / float64(dT)) * 100.0
|
||||
}
|
||||
}
|
||||
p.prevCPUNanos = cpu
|
||||
p.prevAt = s.Timestamp
|
||||
}
|
||||
if mem, limit, ok := readMemory(); ok {
|
||||
s.MemoryBytes = mem
|
||||
s.MemoryLimit = limit
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *Publisher) publish(ctx context.Context, s Sample) error {
|
||||
pipe := p.rdb.Pipeline()
|
||||
pipe.HSet(ctx, statsKey(p.service, p.node), map[string]any{
|
||||
"cpu_percent": strconv.FormatFloat(s.CPUPercent, 'f', 2, 64),
|
||||
"memory_bytes": s.MemoryBytes,
|
||||
"memory_limit": s.MemoryLimit,
|
||||
"timestamp": s.Timestamp.Unix(),
|
||||
"node_id": p.node,
|
||||
"service": p.service,
|
||||
})
|
||||
pipe.Expire(ctx, statsKey(p.service, p.node), 30*time.Second)
|
||||
_, err := pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- cgroup readers --------------------------------------------------------
|
||||
|
||||
// readCPUNanos returns total CPU-nanoseconds consumed by the current cgroup,
|
||||
// summed across all CPUs. Works for both cgroup v2 (cpu.stat) and v1
|
||||
// (cpuacct.usage).
|
||||
func readCPUNanos() (int64, bool) {
|
||||
if data, err := os.ReadFile("/sys/fs/cgroup/cpu.stat"); err == nil {
|
||||
// v2: lines like "usage_usec 12345"
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "usage_usec ") {
|
||||
n, err := strconv.ParseInt(strings.TrimPrefix(line, "usage_usec "), 10, 64)
|
||||
if err == nil {
|
||||
return n * 1000, true // µs → ns
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if data, err := os.ReadFile("/sys/fs/cgroup/cpuacct/cpuacct.usage"); err == nil {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
||||
if err == nil {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// readMemory returns current usage and limit in bytes.
|
||||
func readMemory() (int64, int64, bool) {
|
||||
// v2
|
||||
if cur, err := readInt("/sys/fs/cgroup/memory.current"); err == nil {
|
||||
limit, _ := readInt("/sys/fs/cgroup/memory.max")
|
||||
return cur, limit, true
|
||||
}
|
||||
// v1
|
||||
if cur, err := readInt("/sys/fs/cgroup/memory/memory.usage_in_bytes"); err == nil {
|
||||
limit, _ := readInt("/sys/fs/cgroup/memory/memory.limit_in_bytes")
|
||||
return cur, limit, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func readInt(path string) (int64, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s := strings.TrimSpace(string(b))
|
||||
if s == "max" {
|
||||
return -1, nil
|
||||
}
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/sh
|
||||
# mailcow-agent-cli — publish a control-bus command from inside a service
|
||||
# container, optionally collecting one reply. Same wire protocol as the Go
|
||||
# agent (see internal/envelope/envelope.go).
|
||||
#
|
||||
# Usage:
|
||||
# mailcow-agent-cli send <service> <cmd> [json-args]
|
||||
# Fire-and-forget. Prints the number of subscribers reached.
|
||||
# mailcow-agent-cli call <service> <cmd> [json-args] [timeout-seconds]
|
||||
# Publish + wait for one reply on its private reply list. Prints the
|
||||
# reply envelope JSON on stdout.
|
||||
#
|
||||
# Requires the `redis-cli` binary to be present in the calling container.
|
||||
|
||||
set -e
|
||||
|
||||
op="${1:-}"
|
||||
svc="${2:-}"
|
||||
cmd="${3:-}"
|
||||
args="${4:-{\}}"
|
||||
tmo="${5:-10}"
|
||||
|
||||
if [ -z "$op" ] || [ -z "$svc" ] || [ -z "$cmd" ]; then
|
||||
echo "usage: $0 send|call <service> <cmd> [json-args] [timeout-seconds]" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
redis_host="${REDIS_SLAVEOF_IP:-redis-mailcow}"
|
||||
redis_port="${REDIS_SLAVEOF_PORT:-6379}"
|
||||
|
||||
rcli() {
|
||||
if [ -n "${REDISPASS:-}" ]; then
|
||||
redis-cli -h "$redis_host" -p "$redis_port" -a "$REDISPASS" --no-auth-warning "$@"
|
||||
else
|
||||
redis-cli -h "$redis_host" -p "$redis_port" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
rid="$(date +%s%N)$$"
|
||||
issued_by="$(hostname 2>/dev/null || echo unknown)"
|
||||
|
||||
case "$op" in
|
||||
send)
|
||||
payload="{\"cmd\":\"${cmd}\",\"request_id\":\"${rid}\",\"args\":${args},\"issued_by\":\"${issued_by}\"}"
|
||||
rcli PUBLISH "mailcow.control.${svc}" "$payload"
|
||||
;;
|
||||
call)
|
||||
reply="mailcow.reply.${rid}"
|
||||
payload="{\"cmd\":\"${cmd}\",\"request_id\":\"${rid}\",\"args\":${args},\"reply_to\":\"${reply}\",\"issued_by\":\"${issued_by}\"}"
|
||||
rcli PUBLISH "mailcow.control.${svc}" "$payload" >/dev/null
|
||||
# BLPOP returns two lines: the list name then the value. Print only the value.
|
||||
rcli BLPOP "$reply" "$tmo" 2>/dev/null | tail -n1
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 send|call <service> <cmd> [json-args] [timeout-seconds]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,3 +1,3 @@
|
||||
FROM debian:bullseye-slim
|
||||
FROM debian:trixie-slim
|
||||
|
||||
RUN apt update && apt install pigz
|
||||
RUN apt update && apt install pigz zstd -y --no-install-recommends
|
||||
@@ -1,14 +1,103 @@
|
||||
FROM alpine:3.19
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21 AS builder
|
||||
|
||||
WORKDIR /src
|
||||
ENV CLAMD_VERSION=1.4.2
|
||||
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --update --no-cache \
|
||||
rsync \
|
||||
clamav \
|
||||
bind-tools \
|
||||
bash \
|
||||
tini
|
||||
g++ \
|
||||
gcc \
|
||||
gdb \
|
||||
make \
|
||||
cmake \
|
||||
py3-pytest \
|
||||
python3 \
|
||||
valgrind \
|
||||
bzip2-dev \
|
||||
check-dev \
|
||||
curl-dev \
|
||||
json-c-dev \
|
||||
libmilter-dev \
|
||||
libxml2-dev \
|
||||
linux-headers \
|
||||
ncurses-dev \
|
||||
openssl-dev \
|
||||
pcre2-dev \
|
||||
zlib-dev \
|
||||
cargo \
|
||||
rust
|
||||
|
||||
RUN wget -P /src https://www.clamav.net/downloads/production/clamav-${CLAMD_VERSION}.tar.gz \
|
||||
&& tar xzfv /src/clamav-${CLAMD_VERSION}.tar.gz \
|
||||
&& cd /src/clamav-${CLAMD_VERSION} \
|
||||
&& cmake . \
|
||||
-D CMAKE_BUILD_TYPE="Release" \
|
||||
-D CMAKE_INSTALL_PREFIX="/usr" \
|
||||
-D CMAKE_INSTALL_LIBDIR="/usr/lib" \
|
||||
-D APP_CONFIG_DIRECTORY="/etc/clamav" \
|
||||
-D DATABASE_DIRECTORY="/var/lib/clamav" \
|
||||
-D ENABLE_CLAMONACC=OFF \
|
||||
-D ENABLE_EXAMPLES=OFF \
|
||||
-D ENABLE_MILTER=ON \
|
||||
-D ENABLE_MAN_PAGES=OFF \
|
||||
-D ENABLE_STATIC_LIB=OFF \
|
||||
-D ENABLE_JSON_SHARED=ON \
|
||||
&& cmake --build . \
|
||||
&& make DESTDIR="/clamav" -j$(($(nproc) - 1)) install \
|
||||
&& rm -r "/clamav/usr/lib/pkgconfig/" \
|
||||
&& sed -e "s|^\(Example\)|\# \1|" \
|
||||
-e "s|.*\(LocalSocket\) .*|\1 /tmp/clamd.sock|" \
|
||||
-e "s|.*\(TCPSocket\) .*|\1 3310|" \
|
||||
-e "s|.*\(TCPAddr\) .*|#\1 0.0.0.0|" \
|
||||
-e "s|.*\(User\) .*|\1 clamav|" \
|
||||
-e "s|^\#\(LogFile\) .*|\1 /var/log/clamav/clamd.log|" \
|
||||
-e "s|^\#\(LogTime\).*|\1 yes|" \
|
||||
"/clamav/etc/clamav/clamd.conf.sample" > "/clamav/etc/clamav/clamd.conf" \
|
||||
&& sed -e "s|^\(Example\)|\# \1|" \
|
||||
-e "s|.*\(DatabaseOwner\) .*|\1 clamav|" \
|
||||
-e "s|^\#\(UpdateLogFile\) .*|\1 /var/log/clamav/freshclam.log|" \
|
||||
-e "s|^\#\(NotifyClamd\).*|\1 /etc/clamav/clamd.conf|" \
|
||||
-e "s|^\#\(ScriptedUpdates\).*|\1 yes|" \
|
||||
"/clamav/etc/clamav/freshclam.conf.sample" > "/clamav/etc/clamav/freshclam.conf" \
|
||||
&& sed -e "s|^\(Example\)|\# \1|" \
|
||||
-e "s|.*\(MilterSocket\) .*|\1 inet:7357|" \
|
||||
-e "s|.*\(User\) .*|\1 clamav|" \
|
||||
-e "s|^\#\(LogFile\) .*|\1 /var/log/clamav/milter.log|" \
|
||||
-e "s|^\#\(LogTime\).*|\1 yes|" \
|
||||
-e "s|.*\(\ClamdSocket\) .*|\1 unix:/tmp/clamd.sock|" \
|
||||
"/clamav/etc/clamav/clamav-milter.conf.sample" > "/clamav/etc/clamav/clamav-milter.conf" || exit 1
|
||||
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
RUN apk upgrade --no-cache \
|
||||
&& apk add --update --no-cache \
|
||||
tzdata \
|
||||
rsync \
|
||||
bind-tools \
|
||||
bash \
|
||||
tini \
|
||||
json-c \
|
||||
libbz2 \
|
||||
libcurl \
|
||||
libmilter \
|
||||
libxml2 \
|
||||
ncurses-libs \
|
||||
pcre2 \
|
||||
zlib \
|
||||
libgcc \
|
||||
&& addgroup -S "clamav" && \
|
||||
adduser -D -G "clamav" -h "/var/lib/clamav" -s "/bin/false" -S "clamav" && \
|
||||
install -d -m 755 -g "clamav" -o "clamav" "/var/log/clamav" && \
|
||||
chown -R clamav:clamav /var/lib/clamav
|
||||
|
||||
COPY --from=builder "/clamav" "/"
|
||||
|
||||
# init
|
||||
COPY clamd.sh /clamd.sh
|
||||
@@ -19,7 +108,15 @@ COPY healthcheck.sh /healthcheck.sh
|
||||
COPY clamdcheck.sh /usr/local/bin
|
||||
RUN chmod +x /healthcheck.sh
|
||||
RUN chmod +x /usr/local/bin/clamdcheck.sh
|
||||
HEALTHCHECK --start-period=6m CMD "/healthcheck.sh"
|
||||
|
||||
ENTRYPOINT []
|
||||
CMD ["/sbin/tini", "-g", "--", "/clamd.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=clamd \
|
||||
MAILCOW_AGENT_MAIN_CMD="/sbin/tini -g -- /clamd.sh"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
@@ -8,7 +8,7 @@ fi
|
||||
|
||||
# Cleaning up garbage
|
||||
echo "Cleaning up tmp files..."
|
||||
rm -rf /var/lib/clamav/clamav-*.tmp
|
||||
rm -rf /var/lib/clamav/tmp.*
|
||||
|
||||
# Prepare whitelist
|
||||
|
||||
@@ -91,6 +91,7 @@ done
|
||||
) &
|
||||
BACKGROUND_TASKS+=($!)
|
||||
|
||||
echo "$(clamd -V) is starting... please wait a moment."
|
||||
nice -n10 clamd &
|
||||
BACKGROUND_TASKS+=($!)
|
||||
|
||||
|
||||
@@ -11,4 +11,4 @@ if [ "${CLAMAV_NO_CLAMD:-}" != "false" ]; then
|
||||
echo "Clamd is up"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
exit 0
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
FROM alpine:3.19
|
||||
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --update --no-cache python3 \
|
||||
py3-pip \
|
||||
openssl \
|
||||
tzdata \
|
||||
py3-psutil \
|
||||
py3-redis \
|
||||
py3-async-timeout \
|
||||
&& pip3 install --upgrade pip \
|
||||
fastapi \
|
||||
uvicorn \
|
||||
aiodocker \
|
||||
docker
|
||||
RUN mkdir /app/modules
|
||||
|
||||
COPY docker-entrypoint.sh /app/
|
||||
COPY main.py /app/main.py
|
||||
COPY modules/ /app/modules/
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "/app/docker-entrypoint.sh"]
|
||||
CMD exec python main.py
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
`openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
|
||||
-keyout /app/dockerapi_key.pem \
|
||||
-out /app/dockerapi_cert.pem \
|
||||
-subj /CN=dockerapi/O=mailcow \
|
||||
-addext subjectAltName=DNS:dockerapi`
|
||||
|
||||
exec "$@"
|
||||
@@ -1,261 +0,0 @@
|
||||
import os
|
||||
import sys
|
||||
import uvicorn
|
||||
import json
|
||||
import uuid
|
||||
import async_timeout
|
||||
import asyncio
|
||||
import aiodocker
|
||||
import docker
|
||||
import logging
|
||||
from logging.config import dictConfig
|
||||
from fastapi import FastAPI, Response, Request
|
||||
from modules.DockerApi import DockerApi
|
||||
from redis import asyncio as aioredis
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
dockerapi = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global dockerapi
|
||||
|
||||
# Initialize a custom logger
|
||||
logger = logging.getLogger("dockerapi")
|
||||
logger.setLevel(logging.INFO)
|
||||
# Configure the logger to output logs to the terminal
|
||||
handler = logging.StreamHandler()
|
||||
handler.setLevel(logging.INFO)
|
||||
formatter = logging.Formatter("%(levelname)s: %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
|
||||
logger.info("Init APP")
|
||||
|
||||
# Init redis client
|
||||
if os.environ['REDIS_SLAVEOF_IP'] != "":
|
||||
redis_client = redis = await aioredis.from_url(f"redis://{os.environ['REDIS_SLAVEOF_IP']}:{os.environ['REDIS_SLAVEOF_PORT']}/0")
|
||||
else:
|
||||
redis_client = redis = await aioredis.from_url("redis://redis-mailcow:6379/0")
|
||||
|
||||
# Init docker clients
|
||||
sync_docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock', version='auto')
|
||||
async_docker_client = aiodocker.Docker(url='unix:///var/run/docker.sock')
|
||||
|
||||
dockerapi = DockerApi(redis_client, sync_docker_client, async_docker_client, logger)
|
||||
|
||||
logger.info("Subscribe to redis channel")
|
||||
# Subscribe to redis channel
|
||||
dockerapi.pubsub = redis.pubsub()
|
||||
await dockerapi.pubsub.subscribe("MC_CHANNEL")
|
||||
asyncio.create_task(handle_pubsub_messages(dockerapi.pubsub))
|
||||
|
||||
|
||||
yield
|
||||
|
||||
# Close docker connections
|
||||
dockerapi.sync_docker_client.close()
|
||||
await dockerapi.async_docker_client.close()
|
||||
|
||||
# Close redis
|
||||
await dockerapi.pubsub.unsubscribe("MC_CHANNEL")
|
||||
await dockerapi.redis_client.close()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Define Routes
|
||||
@app.get("/host/stats")
|
||||
async def get_host_update_stats():
|
||||
global dockerapi
|
||||
|
||||
if dockerapi.host_stats_isUpdating == False:
|
||||
asyncio.create_task(dockerapi.get_host_stats())
|
||||
dockerapi.host_stats_isUpdating = True
|
||||
|
||||
while True:
|
||||
if await dockerapi.redis_client.exists('host_stats'):
|
||||
break
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
stats = json.loads(await dockerapi.redis_client.get('host_stats'))
|
||||
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
|
||||
|
||||
@app.get("/containers/{container_id}/json")
|
||||
async def get_container(container_id : str):
|
||||
global dockerapi
|
||||
|
||||
if container_id and container_id.isalnum():
|
||||
try:
|
||||
for container in (await dockerapi.async_docker_client.containers.list()):
|
||||
if container._id == container_id:
|
||||
container_info = await container.show()
|
||||
return Response(content=json.dumps(container_info, indent=4), media_type="application/json")
|
||||
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "no container found"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
except Exception as e:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "no or invalid id defined"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
@app.get("/containers/json")
|
||||
async def get_containers():
|
||||
global dockerapi
|
||||
|
||||
containers = {}
|
||||
try:
|
||||
for container in (await dockerapi.async_docker_client.containers.list()):
|
||||
container_info = await container.show()
|
||||
containers.update({container_info['Id']: container_info})
|
||||
return Response(content=json.dumps(containers, indent=4), media_type="application/json")
|
||||
except Exception as e:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
@app.post("/containers/{container_id}/{post_action}")
|
||||
async def post_containers(container_id : str, post_action : str, request: Request):
|
||||
global dockerapi
|
||||
|
||||
try :
|
||||
request_json = await request.json()
|
||||
except Exception as err:
|
||||
request_json = {}
|
||||
|
||||
if container_id and container_id.isalnum() and post_action:
|
||||
try:
|
||||
"""Dispatch container_post api call"""
|
||||
if post_action == 'exec':
|
||||
if not request_json or not 'cmd' in request_json:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "cmd is missing"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
if not request_json or not 'task' in request_json:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "task is missing"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
api_call_method_name = '__'.join(['container_post', str(post_action), str(request_json['cmd']), str(request_json['task']) ])
|
||||
else:
|
||||
api_call_method_name = '__'.join(['container_post', str(post_action) ])
|
||||
|
||||
api_call_method = getattr(dockerapi, api_call_method_name, lambda container_id: Response(content=json.dumps({'type': 'danger', 'msg':'container_post - unknown api call' }, indent=4), media_type="application/json"))
|
||||
|
||||
dockerapi.logger.info("api call: %s, container_id: %s" % (api_call_method_name, container_id))
|
||||
return api_call_method(request_json, container_id=container_id)
|
||||
except Exception as e:
|
||||
dockerapi.logger.error("error - container_post: %s" % str(e))
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
else:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "invalid container id or missing action"
|
||||
}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
@app.post("/container/{container_id}/stats/update")
|
||||
async def post_container_update_stats(container_id : str):
|
||||
global dockerapi
|
||||
|
||||
# start update task for container if no task is running
|
||||
if container_id not in dockerapi.containerIds_to_update:
|
||||
asyncio.create_task(dockerapi.get_container_stats(container_id))
|
||||
dockerapi.containerIds_to_update.append(container_id)
|
||||
|
||||
while True:
|
||||
if await dockerapi.redis_client.exists(container_id + '_stats'):
|
||||
break
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
stats = json.loads(await dockerapi.redis_client.get(container_id + '_stats'))
|
||||
return Response(content=json.dumps(stats, indent=4), media_type="application/json")
|
||||
|
||||
|
||||
# PubSub Handler
|
||||
async def handle_pubsub_messages(channel: aioredis.client.PubSub):
|
||||
global dockerapi
|
||||
|
||||
while True:
|
||||
try:
|
||||
async with async_timeout.timeout(60):
|
||||
message = await channel.get_message(ignore_subscribe_messages=True, timeout=30)
|
||||
if message is not None:
|
||||
# Parse message
|
||||
data_json = json.loads(message['data'].decode('utf-8'))
|
||||
dockerapi.logger.info(f"PubSub Received - {json.dumps(data_json)}")
|
||||
|
||||
# Handle api_call
|
||||
if 'api_call' in data_json:
|
||||
# api_call: container_post
|
||||
if data_json['api_call'] == "container_post":
|
||||
if 'post_action' in data_json and 'container_name' in data_json:
|
||||
try:
|
||||
"""Dispatch container_post api call"""
|
||||
request_json = {}
|
||||
if data_json['post_action'] == 'exec':
|
||||
if 'request' in data_json:
|
||||
request_json = data_json['request']
|
||||
if 'cmd' in request_json:
|
||||
if 'task' in request_json:
|
||||
api_call_method_name = '__'.join(['container_post', str(data_json['post_action']), str(request_json['cmd']), str(request_json['task']) ])
|
||||
else:
|
||||
dockerapi.logger.error("api call: task missing")
|
||||
else:
|
||||
dockerapi.logger.error("api call: cmd missing")
|
||||
else:
|
||||
dockerapi.logger.error("api call: request missing")
|
||||
else:
|
||||
api_call_method_name = '__'.join(['container_post', str(data_json['post_action'])])
|
||||
|
||||
if api_call_method_name:
|
||||
api_call_method = getattr(dockerapi, api_call_method_name)
|
||||
if api_call_method:
|
||||
dockerapi.logger.info("api call: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
|
||||
api_call_method(request_json, container_name=data_json['container_name'])
|
||||
else:
|
||||
dockerapi.logger.error("api call not found: %s, container_name: %s" % (api_call_method_name, data_json['container_name']))
|
||||
except Exception as e:
|
||||
dockerapi.logger.error("container_post: %s" % str(e))
|
||||
else:
|
||||
dockerapi.logger.error("api call: missing container_name, post_action or request")
|
||||
else:
|
||||
dockerapi.logger.error("Unknwon PubSub recieved - %s" % json.dumps(data_json))
|
||||
else:
|
||||
dockerapi.logger.error("Unknwon PubSub recieved - %s" % json.dumps(data_json))
|
||||
|
||||
await asyncio.sleep(0.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
uvicorn.run(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=443,
|
||||
ssl_certfile="/app/dockerapi_cert.pem",
|
||||
ssl_keyfile="/app/dockerapi_key.pem",
|
||||
log_level="info",
|
||||
loop="none"
|
||||
)
|
||||
@@ -1,487 +0,0 @@
|
||||
import psutil
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import asyncio
|
||||
import platform
|
||||
from datetime import datetime
|
||||
from fastapi import FastAPI, Response, Request
|
||||
|
||||
class DockerApi:
|
||||
def __init__(self, redis_client, sync_docker_client, async_docker_client, logger):
|
||||
self.redis_client = redis_client
|
||||
self.sync_docker_client = sync_docker_client
|
||||
self.async_docker_client = async_docker_client
|
||||
self.logger = logger
|
||||
|
||||
self.host_stats_isUpdating = False
|
||||
self.containerIds_to_update = []
|
||||
|
||||
# api call: container_post - post_action: stop
|
||||
def container_post__stop(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
container.stop()
|
||||
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: start
|
||||
def container_post__start(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
container.start()
|
||||
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: restart
|
||||
def container_post__restart(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
container.restart()
|
||||
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: top
|
||||
def container_post__top(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
res = { 'type': 'success', 'msg': container.top()}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: stats
|
||||
def container_post__stats(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(all=True, filters=filters):
|
||||
for stat in container.stats(decode=True, stream=True):
|
||||
res = { 'type': 'success', 'msg': stat}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: delete
|
||||
def container_post__exec__mailq__delete(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
flagged_qids = ['-d %s' % i for i in filtered_qids]
|
||||
sanitized_string = str(' '.join(flagged_qids))
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||
return self.exec_run_handler('generic', postsuper_r)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: hold
|
||||
def container_post__exec__mailq__hold(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
flagged_qids = ['-h %s' % i for i in filtered_qids]
|
||||
sanitized_string = str(' '.join(flagged_qids))
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||
return self.exec_run_handler('generic', postsuper_r)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: cat
|
||||
def container_post__exec__mailq__cat(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
sanitized_string = str(' '.join(filtered_qids))
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postcat_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postcat -q " + sanitized_string], user='postfix')
|
||||
if not postcat_return:
|
||||
postcat_return = 'err: invalid'
|
||||
return self.exec_run_handler('utf8_text_only', postcat_return)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: unhold
|
||||
def container_post__exec__mailq__unhold(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
flagged_qids = ['-H %s' % i for i in filtered_qids]
|
||||
sanitized_string = str(' '.join(flagged_qids))
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postsuper_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postsuper " + sanitized_string])
|
||||
return self.exec_run_handler('generic', postsuper_r)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: deliver
|
||||
def container_post__exec__mailq__deliver(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'items' in request_json:
|
||||
r = re.compile("^[0-9a-fA-F]+$")
|
||||
filtered_qids = filter(r.match, request_json['items'])
|
||||
if filtered_qids:
|
||||
flagged_qids = ['-i %s' % i for i in filtered_qids]
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
for i in flagged_qids:
|
||||
postqueue_r = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postqueue " + i], user='postfix')
|
||||
# todo: check each exit code
|
||||
res = { 'type': 'success', 'msg': 'Scheduled immediate delivery'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: list
|
||||
def container_post__exec__mailq__list(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
mailq_return = container.exec_run(["/usr/sbin/postqueue", "-j"], user='postfix')
|
||||
return self.exec_run_handler('utf8_text_only', mailq_return)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: flush
|
||||
def container_post__exec__mailq__flush(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postqueue_r = container.exec_run(["/usr/sbin/postqueue", "-f"], user='postfix')
|
||||
return self.exec_run_handler('generic', postqueue_r)
|
||||
# api call: container_post - post_action: exec - cmd: mailq - task: super_delete
|
||||
def container_post__exec__mailq__super_delete(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
postsuper_r = container.exec_run(["/usr/sbin/postsuper", "-d", "ALL"])
|
||||
return self.exec_run_handler('generic', postsuper_r)
|
||||
# api call: container_post - post_action: exec - cmd: system - task: fts_rescan
|
||||
def container_post__exec__system__fts_rescan(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'username' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -u '" + request_json['username'].replace("'", "'\\''") + "'"], user='vmail')
|
||||
if rescan_return.exit_code == 0:
|
||||
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
if 'all' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
rescan_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm fts rescan -A"], user='vmail')
|
||||
if rescan_return.exit_code == 0:
|
||||
res = { 'type': 'success', 'msg': 'fts_rescan: rescan triggered'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'warning', 'msg': 'fts_rescan error'}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: system - task: df
|
||||
def container_post__exec__system__df(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'dir' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
df_return = container.exec_run(["/bin/bash", "-c", "/bin/df -H '" + request_json['dir'].replace("'", "'\\''") + "' | /usr/bin/tail -n1 | /usr/bin/tr -s [:blank:] | /usr/bin/tr ' ' ','"], user='nobody')
|
||||
if df_return.exit_code == 0:
|
||||
return df_return.output.decode('utf-8').rstrip()
|
||||
else:
|
||||
return "0,0,0,0,0,0"
|
||||
# api call: container_post - post_action: exec - cmd: system - task: mysql_upgrade
|
||||
def container_post__exec__system__mysql_upgrade(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_upgrade -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "'\n"], user='mysql')
|
||||
if sql_return.exit_code == 0:
|
||||
matched = False
|
||||
for line in sql_return.output.decode('utf-8').split("\n"):
|
||||
if 'is already upgraded to' in line:
|
||||
matched = True
|
||||
if matched:
|
||||
res = { 'type': 'success', 'msg':'mysql_upgrade: already upgraded', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
container.restart()
|
||||
res = { 'type': 'warning', 'msg':'mysql_upgrade: upgrade was applied', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'error', 'msg': 'mysql_upgrade: error running command', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: system - task: mysql_tzinfo_to_sql
|
||||
def container_post__exec__system__mysql_tzinfo_to_sql(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
sql_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/mysql_tzinfo_to_sql /usr/share/zoneinfo | /bin/sed 's/Local time zone must be set--see zic manual page/FCTY/' | /usr/bin/mysql -uroot -p'" + os.environ['DBROOT'].replace("'", "'\\''") + "' mysql \n"], user='mysql')
|
||||
if sql_return.exit_code == 0:
|
||||
res = { 'type': 'info', 'msg': 'mysql_tzinfo_to_sql: command completed successfully', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'error', 'msg': 'mysql_tzinfo_to_sql: error running command', 'text': sql_return.output.decode('utf-8')}
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
# api call: container_post - post_action: exec - cmd: reload - task: dovecot
|
||||
def container_post__exec__reload__dovecot(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/dovecot reload"])
|
||||
return self.exec_run_handler('generic', reload_return)
|
||||
# api call: container_post - post_action: exec - cmd: reload - task: postfix
|
||||
def container_post__exec__reload__postfix(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
reload_return = container.exec_run(["/bin/bash", "-c", "/usr/sbin/postfix reload"])
|
||||
return self.exec_run_handler('generic', reload_return)
|
||||
# api call: container_post - post_action: exec - cmd: reload - task: nginx
|
||||
def container_post__exec__reload__nginx(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
reload_return = container.exec_run(["/bin/sh", "-c", "/usr/sbin/nginx -s reload"])
|
||||
return self.exec_run_handler('generic', reload_return)
|
||||
# api call: container_post - post_action: exec - cmd: sieve - task: list
|
||||
def container_post__exec__sieve__list(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'username' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
sieve_return = container.exec_run(["/bin/bash", "-c", "/usr/bin/doveadm sieve list -u '" + request_json['username'].replace("'", "'\\''") + "'"])
|
||||
return self.exec_run_handler('utf8_text_only', sieve_return)
|
||||
# api call: container_post - post_action: exec - cmd: sieve - task: print
|
||||
def container_post__exec__sieve__print(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'username' in request_json and 'script_name' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]
|
||||
sieve_return = container.exec_run(cmd)
|
||||
return self.exec_run_handler('utf8_text_only', sieve_return)
|
||||
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
|
||||
def container_post__exec__maildir__cleanup(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'maildir' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
sane_name = re.sub(r'\W+', '', request_json['maildir'])
|
||||
vmail_name = request_json['maildir'].replace("'", "'\\''")
|
||||
cmd_vmail = "if [[ -d '/var/vmail/" + vmail_name + "' ]]; then /bin/mv '/var/vmail/" + vmail_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "'; fi"
|
||||
index_name = request_json['maildir'].split("/")
|
||||
if len(index_name) > 1:
|
||||
index_name = index_name[1].replace("'", "'\\''") + "@" + index_name[0].replace("'", "'\\''")
|
||||
cmd_vmail_index = "if [[ -d '/var/vmail_index/" + index_name + "' ]]; then /bin/mv '/var/vmail_index/" + index_name + "' '/var/vmail/_garbage/" + str(int(time.time())) + "_" + sane_name + "_index'; fi"
|
||||
cmd = ["/bin/bash", "-c", cmd_vmail + " && " + cmd_vmail_index]
|
||||
else:
|
||||
cmd = ["/bin/bash", "-c", cmd_vmail]
|
||||
maildir_cleanup = container.exec_run(cmd, user='vmail')
|
||||
return self.exec_run_handler('generic', maildir_cleanup)
|
||||
# api call: container_post - post_action: exec - cmd: rspamd - task: worker_password
|
||||
def container_post__exec__rspamd__worker_password(self, request_json, **kwargs):
|
||||
if 'container_id' in kwargs:
|
||||
filters = {"id": kwargs['container_id']}
|
||||
elif 'container_name' in kwargs:
|
||||
filters = {"name": kwargs['container_name']}
|
||||
|
||||
if 'raw' in request_json:
|
||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||
cmd = "/usr/bin/rspamadm pw -e -p '" + request_json['raw'].replace("'", "'\\''") + "' 2> /dev/null"
|
||||
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
|
||||
|
||||
matched = False
|
||||
for line in cmd_response.split("\n"):
|
||||
if '$2$' in line:
|
||||
hash = line.strip()
|
||||
hash_out = re.search('\$2\$.+$', hash).group(0)
|
||||
rspamd_passphrase_hash = re.sub('[^0-9a-zA-Z\$]+', '', hash_out.rstrip())
|
||||
rspamd_password_filename = "/etc/rspamd/override.d/worker-controller-password.inc"
|
||||
cmd = '''/bin/echo 'enable_password = "%s";' > %s && cat %s''' % (rspamd_passphrase_hash, rspamd_password_filename, rspamd_password_filename)
|
||||
cmd_response = self.exec_cmd_container(container, cmd, user="_rspamd")
|
||||
if rspamd_passphrase_hash.startswith("$2$") and rspamd_passphrase_hash in cmd_response:
|
||||
container.restart()
|
||||
matched = True
|
||||
if matched:
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully' }
|
||||
self.logger.info('success changing Rspamd password')
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
self.logger.error('failed changing Rspamd password')
|
||||
res = { 'type': 'danger', 'msg': 'command did not complete' }
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
|
||||
# Collect host stats
|
||||
async def get_host_stats(self, wait=5):
|
||||
try:
|
||||
system_time = datetime.now()
|
||||
host_stats = {
|
||||
"cpu": {
|
||||
"cores": psutil.cpu_count(),
|
||||
"usage": psutil.cpu_percent()
|
||||
},
|
||||
"memory": {
|
||||
"total": psutil.virtual_memory().total,
|
||||
"usage": psutil.virtual_memory().percent,
|
||||
"swap": psutil.swap_memory()
|
||||
},
|
||||
"uptime": time.time() - psutil.boot_time(),
|
||||
"system_time": system_time.strftime("%d.%m.%Y %H:%M:%S"),
|
||||
"architecture": platform.machine()
|
||||
}
|
||||
|
||||
await self.redis_client.set('host_stats', json.dumps(host_stats), ex=10)
|
||||
except Exception as e:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
|
||||
await asyncio.sleep(wait)
|
||||
self.host_stats_isUpdating = False
|
||||
# Collect container stats
|
||||
async def get_container_stats(self, container_id, wait=5, stop=False):
|
||||
if container_id and container_id.isalnum():
|
||||
try:
|
||||
for container in (await self.async_docker_client.containers.list()):
|
||||
if container._id == container_id:
|
||||
res = await container.stats(stream=False)
|
||||
|
||||
if await self.redis_client.exists(container_id + '_stats'):
|
||||
stats = json.loads(await self.redis_client.get(container_id + '_stats'))
|
||||
else:
|
||||
stats = []
|
||||
stats.append(res[0])
|
||||
if len(stats) > 3:
|
||||
del stats[0]
|
||||
await self.redis_client.set(container_id + '_stats', json.dumps(stats), ex=60)
|
||||
except Exception as e:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": str(e)
|
||||
}
|
||||
else:
|
||||
res = {
|
||||
"type": "danger",
|
||||
"msg": "no or invalid id defined"
|
||||
}
|
||||
|
||||
await asyncio.sleep(wait)
|
||||
if stop == True:
|
||||
# update task was called second time, stop
|
||||
self.containerIds_to_update.remove(container_id)
|
||||
else:
|
||||
# call update task a second time
|
||||
await self.get_container_stats(container_id, wait=0, stop=True)
|
||||
|
||||
def exec_cmd_container(self, container, cmd, user, timeout=2, shell_cmd="/bin/bash"):
|
||||
def recv_socket_data(c_socket, timeout):
|
||||
c_socket.setblocking(0)
|
||||
total_data=[]
|
||||
data=''
|
||||
begin=time.time()
|
||||
while True:
|
||||
if total_data and time.time()-begin > timeout:
|
||||
break
|
||||
elif time.time()-begin > timeout*2:
|
||||
break
|
||||
try:
|
||||
data = c_socket.recv(8192)
|
||||
if data:
|
||||
total_data.append(data.decode('utf-8'))
|
||||
#change the beginning time for measurement
|
||||
begin=time.time()
|
||||
else:
|
||||
#sleep for sometime to indicate a gap
|
||||
time.sleep(0.1)
|
||||
break
|
||||
except:
|
||||
pass
|
||||
return ''.join(total_data)
|
||||
|
||||
try :
|
||||
socket = container.exec_run([shell_cmd], stdin=True, socket=True, user=user).output._sock
|
||||
if not cmd.endswith("\n"):
|
||||
cmd = cmd + "\n"
|
||||
socket.send(cmd.encode('utf-8'))
|
||||
data = recv_socket_data(socket, timeout)
|
||||
socket.close()
|
||||
return data
|
||||
except Exception as e:
|
||||
self.logger.error("error - exec_cmd_container: %s" % str(e))
|
||||
traceback.print_exc(file=sys.stdout)
|
||||
|
||||
def exec_run_handler(self, type, output):
|
||||
if type == 'generic':
|
||||
if output.exit_code == 0:
|
||||
res = { 'type': 'success', 'msg': 'command completed successfully' }
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
else:
|
||||
res = { 'type': 'danger', 'msg': 'command failed: ' + output.output.decode('utf-8') }
|
||||
return Response(content=json.dumps(res, indent=4), media_type="application/json")
|
||||
if type == 'utf8_text_only':
|
||||
return Response(content=output.output.decode('utf-8'), media_type="text/plain")
|
||||
@@ -1,11 +1,16 @@
|
||||
FROM alpine:3.19
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||
ARG GOSU_VERSION=1.16
|
||||
ARG GOSU_VERSION=1.19
|
||||
|
||||
ENV LANG C.UTF-8
|
||||
ENV LC_ALL C.UTF-8
|
||||
ENV LANG=C.UTF-8
|
||||
ENV LC_ALL=C.UTF-8
|
||||
|
||||
# Add groups and users before installing Dovecot to not break compatibility
|
||||
RUN addgroup -g 5000 vmail \
|
||||
@@ -24,6 +29,7 @@ RUN addgroup -g 5000 vmail \
|
||||
envsubst \
|
||||
ca-certificates \
|
||||
curl \
|
||||
coreutils \
|
||||
jq \
|
||||
lua \
|
||||
lua-cjson \
|
||||
@@ -32,9 +38,13 @@ RUN addgroup -g 5000 vmail \
|
||||
lua5.3-sql-mysql \
|
||||
icu-data-full \
|
||||
mariadb-connector-c \
|
||||
lua-sec \
|
||||
mariadb-dev \
|
||||
glib-dev \
|
||||
gcompat \
|
||||
mariadb-client \
|
||||
perl \
|
||||
perl-dev \
|
||||
perl-ntlm \
|
||||
perl-cgi \
|
||||
perl-crypt-openssl-rsa \
|
||||
@@ -62,8 +72,8 @@ RUN addgroup -g 5000 vmail \
|
||||
perl-package-stash-xs \
|
||||
perl-par-packer \
|
||||
perl-parse-recdescent \
|
||||
perl-lockfile-simple --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ \
|
||||
libproc \
|
||||
perl-lockfile-simple \
|
||||
libproc2 \
|
||||
perl-readonly \
|
||||
perl-regexp-common \
|
||||
perl-sys-meminfo \
|
||||
@@ -103,14 +113,12 @@ RUN addgroup -g 5000 vmail \
|
||||
dovecot-submissiond \
|
||||
dovecot-pigeonhole-plugin \
|
||||
dovecot-pop3d \
|
||||
dovecot-fts-solr \
|
||||
dovecot-fts-flatcurve \
|
||||
&& 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" \
|
||||
&& chmod +x /usr/local/bin/gosu \
|
||||
&& gosu nobody true
|
||||
|
||||
# RUN cpan LockFile::Simple
|
||||
|
||||
COPY trim_logs.sh /usr/local/bin/trim_logs.sh
|
||||
COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh
|
||||
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||
@@ -129,6 +137,16 @@ COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
||||
COPY quarantine_notify.py /usr/local/bin/quarantine_notify.py
|
||||
COPY quota_notify.py /usr/local/bin/quota_notify.py
|
||||
COPY repl_health.sh /usr/local/bin/repl_health.sh
|
||||
COPY optimize-fts.sh /usr/local/bin/optimize-fts.sh
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
CMD exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=dovecot \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
source /source_env.sh
|
||||
|
||||
MAX_AGE=$(redis-cli --raw -h redis-mailcow GET Q_MAX_AGE)
|
||||
MAX_AGE=$(redis-cli --raw -h redis-mailcow -a ${REDISPASS} --no-auth-warning GET Q_MAX_AGE)
|
||||
|
||||
if [[ -z ${MAX_AGE} ]]; then
|
||||
echo "Max age for quarantine items not defined"
|
||||
@@ -15,6 +15,6 @@ if ! [[ ${MAX_AGE} =~ ${NUM_REGEXP} ]] ; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TO_DELETE=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT COUNT(id) FROM quarantine WHERE created < NOW() - INTERVAL ${MAX_AGE//[!0-9]/} DAY" -BN)
|
||||
mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DELETE FROM quarantine WHERE created < NOW() - INTERVAL ${MAX_AGE//[!0-9]/} DAY"
|
||||
TO_DELETE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT COUNT(id) FROM quarantine WHERE created < NOW() - INTERVAL ${MAX_AGE//[!0-9]/} DAY" -BN)
|
||||
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "DELETE FROM quarantine WHERE created < NOW() - INTERVAL ${MAX_AGE//[!0-9]/} DAY"
|
||||
echo "Deleted ${TO_DELETE} items from quarantine table (max age is ${MAX_AGE//[!0-9]/} days)"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -e
|
||||
|
||||
# Wait for MySQL to warm-up
|
||||
while ! mysqladmin status --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 database to come up..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -14,9 +14,9 @@ done
|
||||
|
||||
# Do not attempt to write to slave
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT}"
|
||||
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
|
||||
else
|
||||
REDIS_CMDLINE="redis-cli -h redis -p 6379"
|
||||
REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning"
|
||||
fi
|
||||
|
||||
until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
|
||||
@@ -28,7 +28,8 @@ ${REDIS_CMDLINE} SET DOVECOT_REPL_HEALTH 1 > /dev/null
|
||||
|
||||
# Create missing directories
|
||||
[[ ! -d /etc/dovecot/sql/ ]] && mkdir -p /etc/dovecot/sql/
|
||||
[[ ! -d /etc/dovecot/lua/ ]] && mkdir -p /etc/dovecot/lua/
|
||||
[[ ! -d /etc/dovecot/auth/ ]] && mkdir -p /etc/dovecot/auth/
|
||||
[[ ! -d /etc/dovecot/conf.d/ ]] && mkdir -p /etc/dovecot/conf.d/
|
||||
[[ ! -d /var/vmail/_garbage ]] && mkdir -p /var/vmail/_garbage
|
||||
[[ ! -d /var/vmail/sieve ]] && mkdir -p /var/vmail/sieve
|
||||
[[ ! -d /etc/sogo ]] && mkdir -p /etc/sogo
|
||||
@@ -109,14 +110,16 @@ EOF
|
||||
|
||||
echo -n ${ACL_ANYONE} > /etc/dovecot/acl_anyone
|
||||
|
||||
if [[ "${SKIP_SOLR}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify listescape replication' > /etc/dovecot/mail_plugins
|
||||
if [[ "${SKIP_FTS}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo -e "\e[33mDetecting SKIP_FTS=y... not enabling Flatcurve (FTS) then...\e[0m"
|
||||
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify listescape replication lazy_expunge' > /etc/dovecot/mail_plugins
|
||||
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify listescape replication mail_log' > /etc/dovecot/mail_plugins_imap
|
||||
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
|
||||
else
|
||||
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify fts fts_solr listescape replication' > /etc/dovecot/mail_plugins
|
||||
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify mail_log fts fts_solr listescape replication' > /etc/dovecot/mail_plugins_imap
|
||||
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl fts fts_solr notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
|
||||
echo -e "\e[32mDetecting SKIP_FTS=n... enabling Flatcurve (FTS)\e[0m"
|
||||
echo -n 'quota acl zlib mail_crypt mail_crypt_acl mail_log notify fts fts_flatcurve listescape replication lazy_expunge' > /etc/dovecot/mail_plugins
|
||||
echo -n 'quota imap_quota imap_acl acl zlib imap_zlib imap_sieve mail_crypt mail_crypt_acl notify mail_log fts fts_flatcurve listescape replication' > /etc/dovecot/mail_plugins_imap
|
||||
echo -n 'quota sieve acl zlib mail_crypt mail_crypt_acl fts fts_flatcurve notify listescape replication' > /etc/dovecot/mail_plugins_lmtp
|
||||
fi
|
||||
chmod 644 /etc/dovecot/mail_plugins /etc/dovecot/mail_plugins_imap /etc/dovecot/mail_plugins_lmtp /templates/quarantine.tpl
|
||||
|
||||
@@ -128,123 +131,6 @@ user_query = SELECT CONCAT(JSON_UNQUOTE(JSON_VALUE(attributes, '$.mailbox_format
|
||||
iterate_query = SELECT username FROM mailbox WHERE active = '1' OR active = '2';
|
||||
EOF
|
||||
|
||||
cat <<EOF > /etc/dovecot/lua/passwd-verify.lua
|
||||
function auth_password_verify(req, pass)
|
||||
|
||||
if req.domain == nil then
|
||||
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
|
||||
end
|
||||
|
||||
if cur == nil then
|
||||
script_init()
|
||||
end
|
||||
|
||||
if req.user == nil then
|
||||
req.user = ''
|
||||
end
|
||||
|
||||
respbody = {}
|
||||
|
||||
-- check against mailbox passwds
|
||||
local cur,errorString = con:execute(string.format([[SELECT password FROM mailbox
|
||||
WHERE username = '%s'
|
||||
AND active = '1'
|
||||
AND domain IN (SELECT domain FROM domain WHERE domain='%s' AND active='1')
|
||||
AND IFNULL(JSON_UNQUOTE(JSON_VALUE(mailbox.attributes, '$.force_pw_update')), 0) != '1'
|
||||
AND IFNULL(JSON_UNQUOTE(JSON_VALUE(attributes, '$.%s_access')), 1) = '1']], con:escape(req.user), con:escape(req.domain), con:escape(req.service)))
|
||||
local row = cur:fetch ({}, "a")
|
||||
while row do
|
||||
if req.password_verify(req, row.password, pass) == 1 then
|
||||
con:execute(string.format([[REPLACE INTO sasl_log (service, app_password, username, real_rip)
|
||||
VALUES ("%s", 0, "%s", "%s")]], con:escape(req.service), con:escape(req.user), con:escape(req.real_rip)))
|
||||
cur:close()
|
||||
con:close()
|
||||
return dovecot.auth.PASSDB_RESULT_OK, ""
|
||||
end
|
||||
row = cur:fetch (row, "a")
|
||||
end
|
||||
|
||||
-- check against app passwds for imap and smtp
|
||||
-- app passwords are only available for imap, smtp, sieve and pop3 when using sasl
|
||||
if req.service == "smtp" or req.service == "imap" or req.service == "sieve" or req.service == "pop3" then
|
||||
local cur,errorString = con:execute(string.format([[SELECT app_passwd.id, %s_access AS has_prot_access, app_passwd.password FROM app_passwd
|
||||
INNER JOIN mailbox ON mailbox.username = app_passwd.mailbox
|
||||
WHERE mailbox = '%s'
|
||||
AND app_passwd.active = '1'
|
||||
AND mailbox.active = '1'
|
||||
AND app_passwd.domain IN (SELECT domain FROM domain WHERE domain='%s' AND active='1')]], con:escape(req.service), con:escape(req.user), con:escape(req.domain)))
|
||||
local row = cur:fetch ({}, "a")
|
||||
while row do
|
||||
if req.password_verify(req, row.password, pass) == 1 then
|
||||
-- if password is valid and protocol access is 1 OR real_rip matches SOGo, proceed
|
||||
if tostring(req.real_rip) == "__IPV4_SOGO__" then
|
||||
cur:close()
|
||||
con:close()
|
||||
return dovecot.auth.PASSDB_RESULT_OK, ""
|
||||
elseif row.has_prot_access == "1" then
|
||||
con:execute(string.format([[REPLACE INTO sasl_log (service, app_password, username, real_rip)
|
||||
VALUES ("%s", %d, "%s", "%s")]], con:escape(req.service), row.id, con:escape(req.user), con:escape(req.real_rip)))
|
||||
cur:close()
|
||||
con:close()
|
||||
return dovecot.auth.PASSDB_RESULT_OK, ""
|
||||
end
|
||||
end
|
||||
row = cur:fetch (row, "a")
|
||||
end
|
||||
end
|
||||
|
||||
cur:close()
|
||||
con:close()
|
||||
|
||||
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "Failed to authenticate"
|
||||
|
||||
-- PoC
|
||||
-- local reqbody = string.format([[{
|
||||
-- "success":0,
|
||||
-- "service":"%s",
|
||||
-- "app_password":false,
|
||||
-- "username":"%s",
|
||||
-- "real_rip":"%s"
|
||||
-- }]], con:escape(req.service), con:escape(req.user), con:escape(req.real_rip))
|
||||
-- http.request {
|
||||
-- method = "POST",
|
||||
-- url = "http://nginx:8081/sasl_log.php",
|
||||
-- source = ltn12.source.string(reqbody),
|
||||
-- headers = {
|
||||
-- ["content-type"] = "application/json",
|
||||
-- ["content-length"] = tostring(#reqbody)
|
||||
-- },
|
||||
-- sink = ltn12.sink.table(respbody)
|
||||
-- }
|
||||
|
||||
end
|
||||
|
||||
function auth_passdb_lookup(req)
|
||||
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, ""
|
||||
end
|
||||
|
||||
function script_init()
|
||||
mysql = require "luasql.mysql"
|
||||
http = require "socket.http"
|
||||
http.TIMEOUT = 5
|
||||
ltn12 = require "ltn12"
|
||||
env = mysql.mysql()
|
||||
con = env:connect("__DBNAME__","__DBUSER__","__DBPASS__","localhost")
|
||||
return 0
|
||||
end
|
||||
|
||||
function script_deinit()
|
||||
con:close()
|
||||
env:close()
|
||||
end
|
||||
EOF
|
||||
|
||||
# Replace patterns in app-passdb.lua
|
||||
sed -i "s/__DBUSER__/${DBUSER}/g" /etc/dovecot/lua/passwd-verify.lua
|
||||
sed -i "s/__DBPASS__/${DBPASS}/g" /etc/dovecot/lua/passwd-verify.lua
|
||||
sed -i "s/__DBNAME__/${DBNAME}/g" /etc/dovecot/lua/passwd-verify.lua
|
||||
sed -i "s/__IPV4_SOGO__/${IPV4_NETWORK}.248/g" /etc/dovecot/lua/passwd-verify.lua
|
||||
|
||||
|
||||
# Migrate old sieve_after file
|
||||
[[ -f /etc/dovecot/sieve_after ]] && mv /etc/dovecot/sieve_after /etc/dovecot/global_sieve_after
|
||||
@@ -322,10 +208,13 @@ cat <<EOF > /etc/dovecot/sogo-sso.conf
|
||||
# Autogenerated by mailcow
|
||||
passdb {
|
||||
driver = static
|
||||
args = allow_real_nets=${IPV4_NETWORK}.248/32 password={plain}${RAND_PASS}
|
||||
args = allow_nets=${IPV4_NETWORK}.248/32 password={plain}${RAND_PASS}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Creating additional creds file for SOGo notify crons (calendars, etc) (dummy user, sso password)
|
||||
echo -n ${RAND_USER}@mailcow.local:${RAND_PASS} > /etc/sogo/cron.creds
|
||||
|
||||
if [[ "${MASTER}" =~ ^([nN][oO]|[nN])+$ ]]; then
|
||||
# Toggling MASTER will result in a rebuild of containers, so the quota script will be recreated
|
||||
cat <<'EOF' > /usr/local/bin/quota_notify.py
|
||||
@@ -335,6 +224,23 @@ sys.exit()
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Set mail_replica for HA setups
|
||||
if [[ -n ${MAILCOW_REPLICA_IP} && -n ${DOVEADM_REPLICA_PORT} ]]; then
|
||||
cat <<EOF > /etc/dovecot/mail_replica.conf
|
||||
# Autogenerated by mailcow
|
||||
mail_replica = tcp:${MAILCOW_REPLICA_IP}:${DOVEADM_REPLICA_PORT}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Setting variables for indexer-worker inside fts.conf automatically according to mailcow.conf settings
|
||||
if [[ "${SKIP_FTS}" =~ ^([nN][oO]|[nN])+$ ]]; then
|
||||
echo -e "\e[94mConfiguring FTS Settings...\e[0m"
|
||||
echo -e "\e[94mSetting FTS Memory Limit (per process) to ${FTS_HEAP} MB\e[0m"
|
||||
sed -i "s/vsz_limit\s*=\s*[0-9]*\s*MB*/vsz_limit=${FTS_HEAP} MB/" /etc/dovecot/conf.d/fts.conf
|
||||
echo -e "\e[94mSetting FTS Process Limit to ${FTS_PROCS}\e[0m"
|
||||
sed -i "s/process_limit\s*=\s*[0-9]*/process_limit=${FTS_PROCS}/" /etc/dovecot/conf.d/fts.conf
|
||||
fi
|
||||
|
||||
# 401 is user dovecot
|
||||
if [[ ! -s /mail_crypt/ecprivkey.pem || ! -s /mail_crypt/ecpubkey.pem ]]; then
|
||||
openssl ecparam -name prime256v1 -genkey | openssl pkey -out /mail_crypt/ecprivkey.pem
|
||||
@@ -344,24 +250,27 @@ else
|
||||
chown 401 /mail_crypt/ecprivkey.pem /mail_crypt/ecpubkey.pem
|
||||
fi
|
||||
|
||||
# Fix OpenSSL 3.X TLS1.0, 1.1 support (https://community.mailcow.email/d/4062-hi-all/20)
|
||||
if grep -qE 'ssl_min_protocol\s*=\s*(TLSv1|TLSv1\.1)\s*$' /etc/dovecot/dovecot.conf /etc/dovecot/extra.conf; then
|
||||
sed -i '/\[openssl_init\]/a ssl_conf = ssl_configuration' /etc/ssl/openssl.cnf
|
||||
|
||||
echo "[ssl_configuration]" >> /etc/ssl/openssl.cnf
|
||||
echo "system_default = tls_system_default" >> /etc/ssl/openssl.cnf
|
||||
echo "[tls_system_default]" >> /etc/ssl/openssl.cnf
|
||||
echo "MinProtocol = TLSv1" >> /etc/ssl/openssl.cnf
|
||||
echo "CipherString = DEFAULT@SECLEVEL=0" >> /etc/ssl/openssl.cnf
|
||||
fi
|
||||
|
||||
# Compile sieve scripts
|
||||
sievec /var/vmail/sieve/global_sieve_before.sieve
|
||||
sievec /var/vmail/sieve/global_sieve_after.sieve
|
||||
sievec /usr/lib/dovecot/sieve/report-spam.sieve
|
||||
sievec /usr/lib/dovecot/sieve/report-ham.sieve
|
||||
|
||||
for file in /var/vmail/*/*/sieve/*.sieve ; do
|
||||
if [[ "$file" == "/var/vmail/*/*/sieve/*.sieve" ]]; then
|
||||
continue
|
||||
fi
|
||||
sievec "$file" "$(dirname "$file")/../.dovecot.svbin"
|
||||
chown vmail:vmail "$(dirname "$file")/../.dovecot.svbin"
|
||||
done
|
||||
|
||||
# Fix permissions
|
||||
chown root:root /etc/dovecot/sql/*.conf
|
||||
chown root:dovecot /etc/dovecot/sql/dovecot-dict-sql-sieve* /etc/dovecot/sql/dovecot-dict-sql-quota* /etc/dovecot/lua/passwd-verify.lua
|
||||
chmod 640 /etc/dovecot/sql/*.conf /etc/dovecot/lua/passwd-verify.lua
|
||||
chown root:dovecot /etc/dovecot/sql/dovecot-dict-sql-sieve* /etc/dovecot/sql/dovecot-dict-sql-quota* /etc/dovecot/auth/passwd-verify.lua
|
||||
chmod 640 /etc/dovecot/sql/*.conf /etc/dovecot/auth/passwd-verify.lua
|
||||
chown -R vmail:vmail /var/vmail/sieve
|
||||
chown -R vmail:vmail /var/volatile
|
||||
chown -R vmail:vmail /var/vmail_index
|
||||
@@ -378,7 +287,8 @@ chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
|
||||
/usr/local/bin/maildir_gc.sh \
|
||||
/usr/local/sbin/stop-supervisor.sh \
|
||||
/usr/local/bin/quota_notify.py \
|
||||
/usr/local/bin/repl_health.sh
|
||||
/usr/local/bin/repl_health.sh \
|
||||
/usr/local/bin/optimize-fts.sh
|
||||
|
||||
# Prepare environment file for cronjobs
|
||||
printenv | sed 's/^\(.*\)$/export \1/g' > /source_env.sh
|
||||
@@ -388,15 +298,15 @@ printenv | sed 's/^\(.*\)$/export \1/g' > /source_env.sh
|
||||
|
||||
# Clean stopped imapsync jobs
|
||||
rm -f /tmp/imapsync_busy.lock
|
||||
IMAPSYNC_TABLE=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'imapsync'" -Bs)
|
||||
[[ ! -z ${IMAPSYNC_TABLE} ]] && mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "UPDATE imapsync SET is_running='0'"
|
||||
IMAPSYNC_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SHOW TABLES LIKE 'imapsync'" -Bs)
|
||||
[[ ! -z ${IMAPSYNC_TABLE} ]] && mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "UPDATE imapsync SET is_running='0'"
|
||||
|
||||
# Envsubst maildir_gc
|
||||
echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh
|
||||
|
||||
# GUID generation
|
||||
while [[ ${VERSIONS_OK} != 'OK' ]]; do
|
||||
if [[ ! -z $(mysql --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
|
||||
VERSIONS_OK=OK
|
||||
else
|
||||
echo "Waiting for versions table to be created..."
|
||||
@@ -407,11 +317,11 @@ PUBKEY_MCRYPT=$(doveconf -P 2> /dev/null | grep -i mail_crypt_global_public_key
|
||||
if [ -f ${PUBKEY_MCRYPT} ]; then
|
||||
GUID=$(cat <(echo ${MAILCOW_HOSTNAME}) /mail_crypt/ecpubkey.pem | sha256sum | cut -d ' ' -f1 | tr -cd "[a-fA-F0-9.:/] ")
|
||||
if [ ${#GUID} -eq 64 ]; then
|
||||
mysql --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
|
||||
REPLACE INTO versions (application, version) VALUES ("GUID", "${GUID}");
|
||||
EOF
|
||||
else
|
||||
mysql --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
|
||||
REPLACE INTO versions (application, version) VALUES ("GUID", "INVALID");
|
||||
EOF
|
||||
fi
|
||||
@@ -430,7 +340,7 @@ done
|
||||
|
||||
# For some strange, unknown and stupid reason, Dovecot may run into a race condition, when this file is not touched before it is read by dovecot/auth
|
||||
# May be related to something inside Docker, I seriously don't know
|
||||
touch /etc/dovecot/lua/passwd-verify.lua
|
||||
touch /etc/dovecot/auth/passwd-verify.lua
|
||||
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
cp /etc/syslog-ng/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng.conf
|
||||
|
||||
@@ -132,8 +132,8 @@ while ($row = $sth->fetchrow_arrayref()) {
|
||||
"--tmpdir", "/tmp",
|
||||
"--nofoldersizes",
|
||||
"--addheader",
|
||||
($timeout1 gt "0" ? () : ('--timeout1', $timeout1)),
|
||||
($timeout2 gt "0" ? () : ('--timeout2', $timeout2)),
|
||||
($timeout1 le "0" ? () : ('--timeout1', $timeout1)),
|
||||
($timeout2 le "0" ? () : ('--timeout2', $timeout2)),
|
||||
($exclude eq "" ? () : ("--exclude", $exclude)),
|
||||
($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)),
|
||||
($maxage eq "0" ? () : ('--maxage', $maxage)),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ "${SKIP_FTS}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
exit 0
|
||||
else
|
||||
doveadm fts optimize -A
|
||||
fi
|
||||
@@ -8,7 +8,8 @@ from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import COMMASPACE, formatdate
|
||||
import jinja2
|
||||
from jinja2 import Template
|
||||
from jinja2 import TemplateError
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
import json
|
||||
import redis
|
||||
import time
|
||||
@@ -31,7 +32,7 @@ try:
|
||||
|
||||
while True:
|
||||
try:
|
||||
r = redis.StrictRedis(host='redis', decode_responses=True, port=6379, db=0)
|
||||
r = redis.StrictRedis(host='redis', decode_responses=True, port=6379, db=0, password=os.environ['REDISPASS'])
|
||||
r.ping()
|
||||
except Exception as ex:
|
||||
print('%s - trying again...' % (ex))
|
||||
@@ -46,7 +47,7 @@ try:
|
||||
if max_score == "":
|
||||
max_score = 9999.0
|
||||
|
||||
def query_mysql(query, headers = True, update = False):
|
||||
def query_mysql(query, params = None, headers = True, update = False):
|
||||
while True:
|
||||
try:
|
||||
cnx = MySQLdb.connect(user=os.environ.get('DBUSER'), password=os.environ.get('DBPASS'), database=os.environ.get('DBNAME'), charset="utf8mb4", collation="utf8mb4_general_ci")
|
||||
@@ -56,7 +57,10 @@ try:
|
||||
else:
|
||||
break
|
||||
cur = cnx.cursor()
|
||||
cur.execute(query)
|
||||
if params:
|
||||
cur.execute(query, params)
|
||||
else:
|
||||
cur.execute(query)
|
||||
if not update:
|
||||
result = []
|
||||
columns = tuple( [d[0] for d in cur.description] )
|
||||
@@ -75,22 +79,27 @@ try:
|
||||
|
||||
def notify_rcpt(rcpt, msg_count, quarantine_acl, category):
|
||||
if category == "add_header": category = "add header"
|
||||
meta_query = query_mysql('SELECT SHA2(CONCAT(id, qid), 256) AS qhash, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = "%s" AND score < %f AND (action = "%s" OR "all" = "%s")' % (rcpt, max_score, category, category))
|
||||
meta_query = query_mysql('SELECT `qhash`, id, subject, score, sender, created, action FROM quarantine WHERE notified = 0 AND rcpt = %s AND score < %s AND (action = %s OR "all" = %s)', (rcpt, max_score, category, category))
|
||||
print("%s: %d of %d messages qualify for notification" % (rcpt, len(meta_query), msg_count))
|
||||
if len(meta_query) == 0:
|
||||
return
|
||||
msg_count = len(meta_query)
|
||||
env = SandboxedEnvironment()
|
||||
if r.get('Q_HTML'):
|
||||
try:
|
||||
template = Template(r.get('Q_HTML'))
|
||||
except:
|
||||
print("Error: Cannot parse quarantine template, falling back to default template.")
|
||||
with open('/templates/quarantine.tpl') as file_:
|
||||
template = Template(file_.read())
|
||||
try:
|
||||
template = env.from_string(r.get('Q_HTML'))
|
||||
except Exception:
|
||||
print("Error: Cannot parse quarantine template, falling back to default template.")
|
||||
with open('/templates/quarantine.tpl') as file_:
|
||||
template = env.from_string(file_.read())
|
||||
else:
|
||||
with open('/templates/quarantine.tpl') as file_:
|
||||
template = Template(file_.read())
|
||||
html = template.render(meta=meta_query, username=rcpt, counter=msg_count, hostname=mailcow_hostname, quarantine_acl=quarantine_acl)
|
||||
with open('/templates/quarantine.tpl') as file_:
|
||||
template = env.from_string(file_.read())
|
||||
try:
|
||||
html = template.render(meta=meta_query, username=rcpt, counter=msg_count, hostname=mailcow_hostname, quarantine_acl=quarantine_acl)
|
||||
except (jinja2.exceptions.SecurityError, TemplateError) as ex:
|
||||
print(f"SecurityError or TemplateError in template rendering: {ex}")
|
||||
return
|
||||
text = html2text.html2text(html)
|
||||
count = 0
|
||||
while count < 15:
|
||||
@@ -124,7 +133,7 @@ try:
|
||||
server.sendmail(msg['From'], [str(redirect)] + [str(bcc)], text)
|
||||
server.quit()
|
||||
for res in meta_query:
|
||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = "%d"' % (res['id']), update = True)
|
||||
query_mysql('UPDATE quarantine SET notified = 1 WHERE id = %s', (res['id'],), update = True)
|
||||
r.hset('Q_LAST_NOTIFIED', record['rcpt'], time_now)
|
||||
break
|
||||
except Exception as ex:
|
||||
@@ -132,7 +141,7 @@ try:
|
||||
print('%s' % (ex))
|
||||
time.sleep(3)
|
||||
|
||||
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %f AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt' % (max_score))
|
||||
records = query_mysql('SELECT IFNULL(user_acl.quarantine, 0) AS quarantine_acl, count(id) AS counter, rcpt FROM quarantine LEFT OUTER JOIN user_acl ON user_acl.username = rcpt WHERE notified = 0 AND score < %s AND rcpt in (SELECT username FROM mailbox) GROUP BY rcpt', (max_score,))
|
||||
|
||||
for record in records:
|
||||
attrs = ''
|
||||
@@ -150,7 +159,7 @@ try:
|
||||
except Exception as ex:
|
||||
print('Could not determine last notification for %s, assuming never' % (record['rcpt']))
|
||||
last_notification = 0
|
||||
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = "%s"' % (record['rcpt']))
|
||||
attrs_json = query_mysql('SELECT attributes FROM mailbox WHERE username = %s', (record['rcpt'],))
|
||||
attrs = attrs_json[0]['attributes']
|
||||
if isinstance(attrs, str):
|
||||
# if attr is str then just load it
|
||||
|
||||
@@ -6,7 +6,7 @@ from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import COMMASPACE, formatdate
|
||||
import jinja2
|
||||
from jinja2 import Template
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
import redis
|
||||
import time
|
||||
import json
|
||||
@@ -23,7 +23,7 @@ else:
|
||||
|
||||
while True:
|
||||
try:
|
||||
r = redis.StrictRedis(host='redis', decode_responses=True, port=6379, db=0)
|
||||
r = redis.StrictRedis(host='redis', decode_responses=True, port=6379, db=0, username='quota_notify', password='')
|
||||
r.ping()
|
||||
except Exception as ex:
|
||||
print('%s - trying again...' % (ex))
|
||||
@@ -33,16 +33,24 @@ while True:
|
||||
|
||||
if r.get('QW_HTML'):
|
||||
try:
|
||||
template = Template(r.get('QW_HTML'))
|
||||
except:
|
||||
print("Error: Cannot parse quarantine template, falling back to default template.")
|
||||
env = SandboxedEnvironment()
|
||||
template = env.from_string(r.get('QW_HTML'))
|
||||
except Exception:
|
||||
print("Error: Cannot parse quota template, falling back to default template.")
|
||||
with open('/templates/quota.tpl') as file_:
|
||||
template = Template(file_.read())
|
||||
env = SandboxedEnvironment()
|
||||
template = env.from_string(file_.read())
|
||||
else:
|
||||
with open('/templates/quota.tpl') as file_:
|
||||
template = Template(file_.read())
|
||||
env = SandboxedEnvironment()
|
||||
template = env.from_string(file_.read())
|
||||
|
||||
try:
|
||||
html = template.render(username=username, percent=percent)
|
||||
except (jinja2.exceptions.SecurityError, jinja2.TemplateError) as ex:
|
||||
print(f"SecurityError or TemplateError in template rendering: {ex}")
|
||||
sys.exit(1)
|
||||
|
||||
html = template.render(username=username, percent=percent)
|
||||
text = html2text.html2text(html)
|
||||
|
||||
try:
|
||||
|
||||
@@ -4,14 +4,14 @@ source /source_env.sh
|
||||
|
||||
# Do not attempt to write to slave
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT}"
|
||||
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
|
||||
else
|
||||
REDIS_CMDLINE="redis-cli -h redis -p 6379"
|
||||
REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning"
|
||||
fi
|
||||
|
||||
# Is replication active?
|
||||
# grep on file is less expensive than doveconf
|
||||
if ! grep -qi mail_replica /etc/dovecot/dovecot.conf; then
|
||||
if [ -n ${MAILCOW_REPLICA_IP} ]; then
|
||||
${REDIS_CMDLINE} SET DOVECOT_REPL_HEALTH 1 > /dev/null
|
||||
exit
|
||||
fi
|
||||
|
||||
@@ -3,8 +3,8 @@ FILE=/tmp/mail$$
|
||||
cat > $FILE
|
||||
trap "/bin/rm -f $FILE" 0 1 2 3 13 15
|
||||
|
||||
cat ${FILE} | /usr/bin/curl -H "Flag: 11" -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd/fuzzydel
|
||||
cat ${FILE} | /usr/bin/curl -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd/learnham
|
||||
cat ${FILE} | /usr/bin/curl -H "Flag: 13" -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd/fuzzyadd
|
||||
cat ${FILE} | /usr/bin/curl -H "Flag: 11" -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/fuzzydel
|
||||
cat ${FILE} | /usr/bin/curl -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/learnham
|
||||
cat ${FILE} | /usr/bin/curl -H "Flag: 13" -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/fuzzyadd
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -3,8 +3,8 @@ FILE=/tmp/mail$$
|
||||
cat > $FILE
|
||||
trap "/bin/rm -f $FILE" 0 1 2 3 13 15
|
||||
|
||||
cat ${FILE} | /usr/bin/curl -H "Flag: 13" -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd/fuzzydel
|
||||
cat ${FILE} | /usr/bin/curl -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd/learnspam
|
||||
cat ${FILE} | /usr/bin/curl -H "Flag: 11" -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd/fuzzyadd
|
||||
cat ${FILE} | /usr/bin/curl -H "Flag: 13" -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/fuzzydel
|
||||
cat ${FILE} | /usr/bin/curl -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/learnspam
|
||||
cat ${FILE} | /usr/bin/curl -H "Flag: 11" -s --data-binary @- --unix-socket /var/lib/rspamd/rspamd.sock http://rspamd.${COMPOSE_PROJECT_NAME}_mailcow-network/fuzzyadd
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -11,22 +11,25 @@ else
|
||||
fi
|
||||
|
||||
# Deploy
|
||||
curl --connect-timeout 15 --retry 10 --max-time 30 http://www.spamassassin.heinlein-support.de/$(dig txt 1.4.3.spamassassin.heinlein-support.de +short | tr -d '"' | tr -dc '0-9').tar.gz --output /tmp/sa-rules-heinlein.tar.gz
|
||||
if gzip -t /tmp/sa-rules-heinlein.tar.gz; then
|
||||
tar xfvz /tmp/sa-rules-heinlein.tar.gz -C /tmp/sa-rules-heinlein
|
||||
cat /tmp/sa-rules-heinlein/*cf > /etc/rspamd/custom/sa-rules
|
||||
if curl --connect-timeout 15 --retry 5 --max-time 30 https://www.spamassassin.heinlein-support.de/$(dig txt 1.4.3.spamassassin.heinlein-support.de +short | tr -d '"' | tr -dc '0-9').tar.gz --output /tmp/sa-rules-heinlein.tar.gz; then
|
||||
if gzip -t /tmp/sa-rules-heinlein.tar.gz; then
|
||||
tar xfvz /tmp/sa-rules-heinlein.tar.gz -C /tmp/sa-rules-heinlein
|
||||
cat /tmp/sa-rules-heinlein/*cf > /etc/rspamd/custom/sa-rules
|
||||
fi
|
||||
else
|
||||
echo "Failed to download SA rules. Exiting."
|
||||
exit 0 # Must be 0 otherwise dovecot would not start at all
|
||||
fi
|
||||
|
||||
sed -i -e 's/\([^\\]\)\$\([^\/]\)/\1\\$\2/g' /etc/rspamd/custom/sa-rules
|
||||
|
||||
if [[ "$(cat /etc/rspamd/custom/sa-rules | md5sum | cut -d' ' -f1)" != "${HASH_SA_RULES}" ]]; then
|
||||
CONTAINER_NAME=rspamd-mailcow
|
||||
CONTAINER_ID=$(curl --silent --insecure https://dockerapi/containers/json | \
|
||||
jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" | \
|
||||
jq -rc "select( .name | tostring | contains(\"${CONTAINER_NAME}\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id")
|
||||
if [[ ! -z ${CONTAINER_ID} ]]; then
|
||||
curl --silent --insecure -XPOST --connect-timeout 15 --max-time 120 https://dockerapi/containers/${CONTAINER_ID}/restart
|
||||
fi
|
||||
REDIS_HOST="${REDIS_SLAVEOF_IP:-redis-mailcow}"
|
||||
REDIS_PORT="${REDIS_SLAVEOF_PORT:-6379}"
|
||||
REQ_ID="$(date +%s%N)"
|
||||
PAYLOAD="{\"cmd\":\"restart\",\"request_id\":\"${REQ_ID}\",\"issued_by\":\"dovecot-sa-rules\"}"
|
||||
redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDISPASS}" --no-auth-warning \
|
||||
PUBLISH mailcow.control.rspamd "${PAYLOAD}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
|
||||
@@ -7,6 +7,7 @@ options {
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats(freq(0));
|
||||
keep_timestamp(no);
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_dgram {
|
||||
@@ -19,7 +20,8 @@ destination d_redis_ui_log {
|
||||
host("`REDIS_SLAVEOF_IP`")
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
@@ -27,6 +29,7 @@ destination d_redis_f2b_channel {
|
||||
host("`REDIS_SLAVEOF_IP`")
|
||||
persist-name("redis2")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("PUBLISH" "F2B_CHANNEL" "$(sanitize $MESSAGE)")
|
||||
);
|
||||
};
|
||||
@@ -35,8 +38,13 @@ filter f_replica {
|
||||
not match("User has no mail_replica in userdb" value("MESSAGE"));
|
||||
not match("Error: sync: Unknown user in remote" value("MESSAGE"));
|
||||
};
|
||||
filter f_dovecot_auth_try {
|
||||
not match("- trying the next passdb" value("MESSAGE")) and
|
||||
not match("- trying the next userdb" value("MESSAGE"));
|
||||
};
|
||||
log {
|
||||
source(s_dgram);
|
||||
filter(f_dovecot_auth_try);
|
||||
filter(f_replica);
|
||||
destination(d_stdout);
|
||||
filter(f_mail);
|
||||
|
||||
@@ -7,6 +7,7 @@ options {
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats(freq(0));
|
||||
keep_timestamp(no);
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_dgram {
|
||||
@@ -19,7 +20,8 @@ destination d_redis_ui_log {
|
||||
host("redis-mailcow")
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "DOVECOT_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
destination d_redis_f2b_channel {
|
||||
@@ -27,6 +29,7 @@ destination d_redis_f2b_channel {
|
||||
host("redis-mailcow")
|
||||
persist-name("redis2")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("PUBLISH" "F2B_CHANNEL" "$(sanitize $MESSAGE)")
|
||||
);
|
||||
};
|
||||
@@ -35,8 +38,13 @@ filter f_replica {
|
||||
not match("User has no mail_replica in userdb" value("MESSAGE"));
|
||||
not match("Error: sync: Unknown user in remote" value("MESSAGE"));
|
||||
};
|
||||
filter f_dovecot_auth_try {
|
||||
not match("- trying the next passdb" value("MESSAGE")) and
|
||||
not match("- trying the next userdb" value("MESSAGE"));
|
||||
};
|
||||
log {
|
||||
source(s_dgram);
|
||||
filter(f_dovecot_auth_try);
|
||||
filter(f_replica);
|
||||
destination(d_stdout);
|
||||
filter(f_mail);
|
||||
|
||||
@@ -10,9 +10,9 @@ catch_non_zero() {
|
||||
source /source_env.sh
|
||||
# Do not attempt to write to slave
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT}"
|
||||
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
|
||||
else
|
||||
REDIS_CMDLINE="redis-cli -h redis -p 6379"
|
||||
REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning"
|
||||
fi
|
||||
catch_non_zero "${REDIS_CMDLINE} LTRIM ACME_LOG 0 ${LOG_LINES}"
|
||||
catch_non_zero "${REDIS_CMDLINE} LTRIM POSTFIX_MAILLOG 0 ${LOG_LINES}"
|
||||
@@ -23,3 +23,4 @@ catch_non_zero "${REDIS_CMDLINE} LTRIM AUTODISCOVER_LOG 0 ${LOG_LINES}"
|
||||
catch_non_zero "${REDIS_CMDLINE} LTRIM API_LOG 0 ${LOG_LINES}"
|
||||
catch_non_zero "${REDIS_CMDLINE} LTRIM RL_LOG 0 ${LOG_LINES}"
|
||||
catch_non_zero "${REDIS_CMDLINE} LTRIM WATCHDOG_LOG 0 ${LOG_LINES}"
|
||||
catch_non_zero "${REDIS_CMDLINE} LTRIM CRON_LOG 0 ${LOG_LINES}"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# host-agent: dedicated container that reads /host/proc to publish host-level
|
||||
# stats and answer exec.df / exec.host-stats commands. Reuses the same agent
|
||||
# binary; behaviour selected via MAILCOW_AGENT_SERVICE=host.
|
||||
#
|
||||
# Requires:
|
||||
# volumes:
|
||||
# - /proc:/host/proc:ro
|
||||
# - /:/host/rootfs:ro
|
||||
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.0
|
||||
|
||||
FROM ${AGENT_IMAGE} AS agent
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates tzdata
|
||||
COPY --from=agent /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=agent /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=host
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
@@ -1,5 +1,10 @@
|
||||
FROM alpine:3.19
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.23
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -39,4 +44,14 @@ COPY ./docker-entrypoint.sh /app/
|
||||
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
CMD ["/bin/sh", "-c", "/app/docker-entrypoint.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=netfilter \
|
||||
MAILCOW_AGENT_MAIN_CMD="/app/docker-entrypoint.sh"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
backend=iptables
|
||||
backend=nftables
|
||||
|
||||
nft list table ip filter &>/dev/null
|
||||
nftables_found=$?
|
||||
|
||||
+189
-110
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
DEBUG = False
|
||||
|
||||
import re
|
||||
import os
|
||||
import sys
|
||||
@@ -20,55 +22,26 @@ from modules.Logger import Logger
|
||||
from modules.IPTables import IPTables
|
||||
from modules.NFTables import NFTables
|
||||
|
||||
def logdebug(msg):
|
||||
if DEBUG:
|
||||
logger.logInfo("DEBUG: %s" % msg)
|
||||
|
||||
# connect to redis
|
||||
while True:
|
||||
try:
|
||||
redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
|
||||
redis_slaveof_port = os.getenv('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)
|
||||
else:
|
||||
r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0)
|
||||
r.ping()
|
||||
except Exception as ex:
|
||||
print('%s - trying again in 3 seconds' % (ex))
|
||||
time.sleep(3)
|
||||
else:
|
||||
break
|
||||
pubsub = r.pubsub()
|
||||
|
||||
# rename fail2ban to netfilter
|
||||
if r.exists('F2B_LOG'):
|
||||
r.rename('F2B_LOG', 'NETFILTER_LOG')
|
||||
|
||||
|
||||
# globals
|
||||
# Globals
|
||||
WHITELIST = []
|
||||
BLACKLIST= []
|
||||
BLACKLIST = []
|
||||
bans = {}
|
||||
quit_now = False
|
||||
exit_code = 0
|
||||
lock = Lock()
|
||||
|
||||
|
||||
# init Logger
|
||||
logger = Logger(r)
|
||||
# init backend
|
||||
backend = sys.argv[1]
|
||||
if backend == "nftables":
|
||||
logger.logInfo('Using NFTables backend')
|
||||
tables = NFTables("MAILCOW", logger)
|
||||
else:
|
||||
logger.logInfo('Using IPTables backend')
|
||||
tables = IPTables("MAILCOW", logger)
|
||||
|
||||
chain_name = "MAILCOW"
|
||||
r = None
|
||||
pubsub = None
|
||||
clear_before_quit = False
|
||||
|
||||
def refreshF2boptions():
|
||||
global f2boptions
|
||||
global quit_now
|
||||
global exit_code
|
||||
|
||||
f2boptions = {}
|
||||
|
||||
if not r.get('F2B_OPTIONS'):
|
||||
@@ -82,8 +55,9 @@ def refreshF2boptions():
|
||||
else:
|
||||
try:
|
||||
f2boptions = json.loads(r.get('F2B_OPTIONS'))
|
||||
except ValueError:
|
||||
logger.logCrit('Error loading F2B options: F2B_OPTIONS is not json')
|
||||
except ValueError as e:
|
||||
logger.logCrit(
|
||||
'Error loading F2B options: F2B_OPTIONS is not json. Exception: %s' % e)
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
@@ -91,15 +65,15 @@ def refreshF2boptions():
|
||||
r.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False))
|
||||
|
||||
def verifyF2boptions(f2boptions):
|
||||
verifyF2boption(f2boptions,'ban_time', 1800)
|
||||
verifyF2boption(f2boptions,'max_ban_time', 10000)
|
||||
verifyF2boption(f2boptions,'ban_time_increment', True)
|
||||
verifyF2boption(f2boptions,'max_attempts', 10)
|
||||
verifyF2boption(f2boptions,'retry_window', 600)
|
||||
verifyF2boption(f2boptions,'netban_ipv4', 32)
|
||||
verifyF2boption(f2boptions,'netban_ipv6', 128)
|
||||
verifyF2boption(f2boptions,'banlist_id', str(uuid.uuid4()))
|
||||
verifyF2boption(f2boptions,'manage_external', 0)
|
||||
verifyF2boption(f2boptions, 'ban_time', 1800)
|
||||
verifyF2boption(f2boptions, 'max_ban_time', 10000)
|
||||
verifyF2boption(f2boptions, 'ban_time_increment', True)
|
||||
verifyF2boption(f2boptions, 'max_attempts', 10)
|
||||
verifyF2boption(f2boptions, 'retry_window', 600)
|
||||
verifyF2boption(f2boptions, 'netban_ipv4', 32)
|
||||
verifyF2boption(f2boptions, 'netban_ipv6', 128)
|
||||
verifyF2boption(f2boptions, 'banlist_id', str(uuid.uuid4()))
|
||||
verifyF2boption(f2boptions, 'manage_external', 0)
|
||||
|
||||
def verifyF2boption(f2boptions, f2boption, f2bdefault):
|
||||
f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
|
||||
@@ -110,16 +84,15 @@ def refreshF2bregex():
|
||||
global exit_code
|
||||
if not r.get('F2B_REGEX'):
|
||||
f2bregex = {}
|
||||
f2bregex[1] = 'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
|
||||
f2bregex[2] = 'Rspamd UI: Invalid password by ([0-9a-f\.:]+)'
|
||||
f2bregex[3] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+'
|
||||
f2bregex[4] = 'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+'
|
||||
f2bregex[5] = 'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+'
|
||||
f2bregex[6] = '-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),'
|
||||
f2bregex[7] = '-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
|
||||
f2bregex[8] = '-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
|
||||
f2bregex[9] = 'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
|
||||
f2bregex[10] = '([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+'
|
||||
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:
|
||||
@@ -136,82 +109,99 @@ def get_ip(address):
|
||||
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()
|
||||
BAN_TIME = int(f2boptions['ban_time'])
|
||||
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
|
||||
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: return
|
||||
if not ip:
|
||||
logdebug("No valid IP -- skipping ban()")
|
||||
return
|
||||
address = str(ip)
|
||||
self_network = ipaddress.ip_network(address)
|
||||
|
||||
with lock:
|
||||
temp_whitelist = set(WHITELIST)
|
||||
if temp_whitelist:
|
||||
for wl_key in temp_whitelist:
|
||||
wl_net = ipaddress.ip_network(wl_key, False)
|
||||
if wl_net.overlaps(self_network):
|
||||
logger.logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
|
||||
return
|
||||
logdebug("Checking if %s overlaps with any WHITELIST entries" % self_network)
|
||||
if temp_whitelist:
|
||||
for wl_key in temp_whitelist:
|
||||
wl_net = ipaddress.ip_network(wl_key, False)
|
||||
logdebug("Checking overlap between %s and %s" % (self_network, wl_net))
|
||||
if wl_net.overlaps(self_network):
|
||||
logger.logInfo(
|
||||
'Address %s is allowlisted by rule %s' % (self_network, wl_net))
|
||||
return
|
||||
|
||||
net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
|
||||
net = ipaddress.ip_network(
|
||||
(address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
|
||||
net = str(net)
|
||||
logdebug("Ban net: %s" % net)
|
||||
|
||||
if not net in bans:
|
||||
bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0}
|
||||
logdebug("Initing new ban counter for %s" % net)
|
||||
|
||||
current_attempt = time.time()
|
||||
logdebug("Current attempt ts=%s, previous: %s, retry_window: %s" %
|
||||
(current_attempt, bans[net]['last_attempt'], RETRY_WINDOW))
|
||||
if current_attempt - bans[net]['last_attempt'] > RETRY_WINDOW:
|
||||
bans[net]['attempts'] = 0
|
||||
logdebug("Ban counter for %s reset as window expired" % net)
|
||||
|
||||
bans[net]['attempts'] += 1
|
||||
bans[net]['last_attempt'] = current_attempt
|
||||
logdebug("%s attempts now %d" % (net, bans[net]['attempts']))
|
||||
|
||||
if bans[net]['attempts'] >= MAX_ATTEMPTS:
|
||||
cur_time = int(round(time.time()))
|
||||
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
|
||||
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))
|
||||
logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (
|
||||
MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
|
||||
|
||||
def unban(net):
|
||||
global lock
|
||||
|
||||
logdebug("Calling unban() with net=%s" % net)
|
||||
if not net in bans:
|
||||
logger.logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
return
|
||||
|
||||
logger.logInfo(
|
||||
'%s is not banned, skipping unban and deleting from queue (if any)' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
return
|
||||
logger.logInfo('Unbanning %s' % net)
|
||||
if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
|
||||
with lock:
|
||||
logdebug("Calling tables.unbanIPv4(%s)" % net)
|
||||
tables.unbanIPv4(net)
|
||||
else:
|
||||
with lock:
|
||||
logdebug("Calling tables.unbanIPv6(%s)" % net)
|
||||
tables.unbanIPv6(net)
|
||||
|
||||
r.hdel('F2B_ACTIVE_BANS', '%s' % net)
|
||||
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
|
||||
if net in bans:
|
||||
logdebug("Unban for %s, setting attempts=0, ban_counter+=1" % net)
|
||||
bans[net]['attempts'] = 0
|
||||
bans[net]['ban_counter'] += 1
|
||||
|
||||
@@ -237,30 +227,36 @@ def permBan(net, unban=False):
|
||||
|
||||
if is_unbanned:
|
||||
r.hdel('F2B_PERM_BANS', '%s' % net)
|
||||
logger.logCrit('Removed host/network %s from blacklist' % net)
|
||||
logger.logCrit('Removed host/network %s from denylist' % net)
|
||||
elif is_banned:
|
||||
r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
|
||||
logger.logCrit('Added host/network %s to blacklist' % net)
|
||||
logger.logCrit('Added host/network %s to denylist' % net)
|
||||
|
||||
def clear():
|
||||
global lock
|
||||
logger.logInfo('Clearing all bans')
|
||||
for net in bans.copy():
|
||||
logdebug("Unbanning net: %s" % net)
|
||||
unban(net)
|
||||
with lock:
|
||||
logdebug("Clearing IPv4/IPv6 table")
|
||||
tables.clearIPv4Table()
|
||||
tables.clearIPv6Table()
|
||||
r.delete('F2B_ACTIVE_BANS')
|
||||
r.delete('F2B_PERM_BANS')
|
||||
pubsub.unsubscribe()
|
||||
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():
|
||||
logger.logInfo('Watching Redis channel F2B_CHANNEL')
|
||||
pubsub.subscribe('F2B_CHANNEL')
|
||||
|
||||
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():
|
||||
@@ -280,6 +276,7 @@ def watch():
|
||||
ban(addr)
|
||||
except Exception as ex:
|
||||
logger.logWarn('Error reading log line from pubsub: %s' % ex)
|
||||
pubsub = None
|
||||
quit_now = True
|
||||
exit_code = 2
|
||||
|
||||
@@ -302,23 +299,36 @@ def snat6(snat_target):
|
||||
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()
|
||||
BAN_TIME = int(f2boptions['ban_time'])
|
||||
MAX_BAN_TIME = int(f2boptions['max_ban_time'])
|
||||
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
|
||||
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
|
||||
QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
|
||||
logdebug("QUEUE_UNBAN: %s" % QUEUE_UNBAN)
|
||||
if QUEUE_UNBAN:
|
||||
for net in QUEUE_UNBAN:
|
||||
logdebug("Autopurge: unbanning queued net: %s" % net)
|
||||
unban(str(net))
|
||||
for net in bans.copy():
|
||||
if bans[net]['attempts'] >= MAX_ATTEMPTS:
|
||||
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
|
||||
TIME_SINCE_LAST_ATTEMPT = time.time() - bans[net]['last_attempt']
|
||||
if TIME_SINCE_LAST_ATTEMPT > NET_BAN_TIME or TIME_SINCE_LAST_ATTEMPT > MAX_BAN_TIME:
|
||||
unban(net)
|
||||
# Only check expiry for actively banned IPs:
|
||||
active_bans = r.hgetall('F2B_ACTIVE_BANS')
|
||||
now = time.time()
|
||||
for net_str, expire_str in active_bans.items():
|
||||
logdebug("Checking ban expiry for (actively banned): %s" % net_str)
|
||||
# Defensive: always process if timer missing or expired
|
||||
try:
|
||||
expire = float(expire_str)
|
||||
except Exception:
|
||||
logdebug("Invalid expire time for %s; unbanning" % net_str)
|
||||
unban(net_str)
|
||||
continue
|
||||
time_left = expire - now
|
||||
logdebug("Time left for %s: %.1f seconds" % (net_str, time_left))
|
||||
if time_left <= 0:
|
||||
logdebug("Ban expired for %s" % net_str)
|
||||
unban(net_str)
|
||||
|
||||
def mailcowChainOrder():
|
||||
global lock
|
||||
@@ -331,6 +341,16 @@ def mailcowChainOrder():
|
||||
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)
|
||||
@@ -378,7 +398,7 @@ def whitelistUpdate():
|
||||
with lock:
|
||||
if Counter(new_whitelist) != Counter(WHITELIST):
|
||||
WHITELIST = new_whitelist
|
||||
logger.logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
|
||||
logger.logInfo('Allowlist was changed, it has %s entries' % len(WHITELIST))
|
||||
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
|
||||
|
||||
def blacklistUpdate():
|
||||
@@ -394,7 +414,7 @@ def blacklistUpdate():
|
||||
addban = set(new_blacklist).difference(BLACKLIST)
|
||||
delban = set(BLACKLIST).difference(new_blacklist)
|
||||
BLACKLIST = new_blacklist
|
||||
logger.logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
|
||||
logger.logInfo('Denylist was changed, it has %s entries' % len(BLACKLIST))
|
||||
if addban:
|
||||
for net in addban:
|
||||
permBan(net=net)
|
||||
@@ -403,21 +423,82 @@ def blacklistUpdate():
|
||||
permBan(net=net, unban=True)
|
||||
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
|
||||
|
||||
def quit(signum, frame):
|
||||
global quit_now
|
||||
quit_now = True
|
||||
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__':
|
||||
refreshF2boptions()
|
||||
# In case a previous session was killed without cleanup
|
||||
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()
|
||||
# Reinit MAILCOW chain
|
||||
# Is called before threads start, no locking
|
||||
logger.logInfo("Initializing mailcow netfilter chain")
|
||||
tables.initChainIPv4()
|
||||
tables.initChainIPv6()
|
||||
|
||||
if os.getenv("DISABLE_NETFILTER_ISOLATION_RULE", "").lower() in ("y", "yes"):
|
||||
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()
|
||||
@@ -427,7 +508,7 @@ if __name__ == '__main__':
|
||||
snat_ip = os.getenv('SNAT_TO_SOURCE')
|
||||
snat_ipo = ipaddress.ip_address(snat_ip)
|
||||
if type(snat_ipo) is ipaddress.IPv4Address:
|
||||
snat4_thread = Thread(target=snat4,args=(snat_ip,))
|
||||
snat4_thread = Thread(target=snat4, args=(snat_ip,))
|
||||
snat4_thread.daemon = True
|
||||
snat4_thread.start()
|
||||
except ValueError:
|
||||
@@ -460,10 +541,8 @@ if __name__ == '__main__':
|
||||
whitelistupdate_thread.daemon = True
|
||||
whitelistupdate_thread.start()
|
||||
|
||||
signal.signal(signal.SIGTERM, quit)
|
||||
atexit.register(clear)
|
||||
|
||||
while not quit_now:
|
||||
time.sleep(0.5)
|
||||
|
||||
sys.exit(exit_code)
|
||||
logdebug("Exiting with code %s" % exit_code)
|
||||
sys.exit(exit_code)
|
||||
@@ -1,5 +1,6 @@
|
||||
import iptc
|
||||
import time
|
||||
import os
|
||||
|
||||
class IPTables:
|
||||
def __init__(self, chain_name, logger):
|
||||
@@ -211,3 +212,41 @@ class IPTables:
|
||||
target = rule.create_target("SNAT")
|
||||
target.to_source = snat_target
|
||||
return rule
|
||||
|
||||
def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
|
||||
try:
|
||||
chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), self.chain_name)
|
||||
|
||||
# insert mailcow isolation rule
|
||||
rule = iptc.Rule()
|
||||
rule.in_interface = f'!{_interface}'
|
||||
rule.out_interface = _interface
|
||||
rule.protocol = 'tcp'
|
||||
rule.create_target("DROP")
|
||||
match = rule.create_match("multiport")
|
||||
match.dports = ','.join(map(str, _dports))
|
||||
|
||||
if rule in chain.rules:
|
||||
chain.delete_rule(rule)
|
||||
chain.insert_rule(rule, position=0)
|
||||
|
||||
# insert mailcow isolation exception rule
|
||||
if _allow != "":
|
||||
rule = iptc.Rule()
|
||||
rule.src = _allow
|
||||
rule.in_interface = f'!{_interface}'
|
||||
rule.out_interface = _interface
|
||||
rule.protocol = 'tcp'
|
||||
rule.create_target("ACCEPT")
|
||||
match = rule.create_match("multiport")
|
||||
match.dports = ','.join(map(str, _dports))
|
||||
|
||||
if rule in chain.rules:
|
||||
chain.delete_rule(rule)
|
||||
chain.insert_rule(rule, position=0)
|
||||
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.logCrit(f"Error adding {self.chain_name} isolation: {e}")
|
||||
return False
|
||||
@@ -1,17 +1,36 @@
|
||||
import time
|
||||
import json
|
||||
import datetime
|
||||
|
||||
class Logger:
|
||||
def __init__(self, redis):
|
||||
def __init__(self):
|
||||
self.r = None
|
||||
|
||||
def set_redis(self, redis):
|
||||
self.r = redis
|
||||
|
||||
def _format_timestamp(self):
|
||||
# Local time with milliseconds
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def log(self, priority, message):
|
||||
tolog = {}
|
||||
tolog['time'] = int(round(time.time()))
|
||||
tolog['priority'] = priority
|
||||
tolog['message'] = message
|
||||
self.r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
|
||||
print(message)
|
||||
# build redis-friendly dict
|
||||
tolog = {
|
||||
'time': int(round(time.time())), # keep raw timestamp for Redis
|
||||
'priority': priority,
|
||||
'message': message
|
||||
}
|
||||
|
||||
# print human-readable message with timestamp
|
||||
ts = self._format_timestamp()
|
||||
print(f"{ts} {priority.upper()}: {message}", flush=True)
|
||||
|
||||
# also push JSON to Redis if connected
|
||||
if self.r is not None:
|
||||
try:
|
||||
self.r.lpush('NETFILTER_LOG', json.dumps(tolog, ensure_ascii=False))
|
||||
except Exception as ex:
|
||||
print(f'{ts} WARN: Failed logging to redis: {ex}', flush=True)
|
||||
|
||||
def logWarn(self, message):
|
||||
self.log('warn', message)
|
||||
@@ -20,4 +39,4 @@ class Logger:
|
||||
self.log('crit', message)
|
||||
|
||||
def logInfo(self, message):
|
||||
self.log('info', message)
|
||||
self.log('info', message)
|
||||
@@ -1,5 +1,6 @@
|
||||
import nftables
|
||||
import ipaddress
|
||||
import os
|
||||
|
||||
class NFTables:
|
||||
def __init__(self, chain_name, logger):
|
||||
@@ -40,6 +41,7 @@ class NFTables:
|
||||
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
|
||||
@@ -266,6 +268,17 @@ class NFTables:
|
||||
|
||||
return self.nft_exec_dict(delete_command)
|
||||
|
||||
def delete_filter_rule(self, _family:str, _chain: str, _handle:str):
|
||||
delete_command = self.get_base_dict()
|
||||
_rule_opts = {'family': _family,
|
||||
'table': 'filter',
|
||||
'chain': _chain,
|
||||
'handle': _handle }
|
||||
_delete = {'delete': {'rule': _rule_opts} }
|
||||
delete_command["nftables"].append(_delete)
|
||||
|
||||
return self.nft_exec_dict(delete_command)
|
||||
|
||||
def snat_rule(self, _family: str, snat_target: str, source_address: str):
|
||||
chain_name = self.nft_chain_names[_family]['nat']['postrouting']
|
||||
|
||||
@@ -297,8 +310,8 @@ class NFTables:
|
||||
rule_handle = rule["handle"]
|
||||
break
|
||||
|
||||
dest_net = ipaddress.ip_network(source_address)
|
||||
target_net = ipaddress.ip_network(snat_target)
|
||||
dest_net = ipaddress.ip_network(source_address, strict=False)
|
||||
target_net = ipaddress.ip_network(snat_target, strict=False)
|
||||
|
||||
if rule_found:
|
||||
saddr_ip = rule["expr"][0]["match"]["right"]["prefix"]["addr"]
|
||||
@@ -309,9 +322,9 @@ class NFTables:
|
||||
|
||||
target_ip = rule["expr"][3]["snat"]["addr"]
|
||||
|
||||
saddr_net = ipaddress.ip_network(saddr_ip + '/' + str(saddr_len))
|
||||
daddr_net = ipaddress.ip_network(daddr_ip + '/' + str(daddr_len))
|
||||
current_target_net = ipaddress.ip_network(target_ip)
|
||||
saddr_net = ipaddress.ip_network(saddr_ip + '/' + str(saddr_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)
|
||||
|
||||
match = all((
|
||||
dest_net == saddr_net,
|
||||
@@ -381,7 +394,7 @@ class NFTables:
|
||||
break
|
||||
return chain_handle
|
||||
|
||||
def get_rules_handle(self, _family: str, _table: str, chain_name: str):
|
||||
def get_rules_handle(self, _family: str, _table: str, chain_name: str, _comment_filter = "mailcow"):
|
||||
rule_handle = []
|
||||
# Command: 'nft list chain {family} {table} {chain_name}'
|
||||
_chain_opts = {'family': _family, 'table': _table, 'name': chain_name}
|
||||
@@ -397,7 +410,7 @@ class NFTables:
|
||||
|
||||
rule = _object["rule"]
|
||||
if rule["family"] == _family and rule["table"] == _table and rule["chain"] == chain_name:
|
||||
if rule.get("comment") and rule["comment"] == "mailcow":
|
||||
if rule.get("comment") and rule["comment"] == _comment_filter:
|
||||
rule_handle.append(rule["handle"])
|
||||
return rule_handle
|
||||
|
||||
@@ -405,7 +418,7 @@ class NFTables:
|
||||
json_command = self.get_base_dict()
|
||||
|
||||
expr_opt = []
|
||||
ipaddr_net = ipaddress.ip_network(ipaddr)
|
||||
ipaddr_net = ipaddress.ip_network(ipaddr, strict=False)
|
||||
right_dict = {'prefix': {'addr': str(ipaddr_net.network_address), 'len': int(ipaddr_net.prefixlen) } }
|
||||
|
||||
left_dict = {'payload': {'protocol': _family, 'field': 'saddr'} }
|
||||
@@ -439,6 +452,8 @@ class NFTables:
|
||||
continue
|
||||
|
||||
rule = _object["rule"]["expr"][0]["match"]
|
||||
if not "payload" in rule["left"]:
|
||||
continue
|
||||
left_opt = rule["left"]["payload"]
|
||||
if not left_opt["protocol"] == _family:
|
||||
continue
|
||||
@@ -454,7 +469,7 @@ class NFTables:
|
||||
current_rule_net = ipaddress.ip_network(current_rule_ip)
|
||||
|
||||
# ip to ban
|
||||
candidate_net = ipaddress.ip_network(ipaddr)
|
||||
candidate_net = ipaddress.ip_network(ipaddr, strict=False)
|
||||
|
||||
if current_rule_net == candidate_net:
|
||||
rule_handle = _object["rule"]["handle"]
|
||||
@@ -493,3 +508,152 @@ class NFTables:
|
||||
position+=1
|
||||
|
||||
return position if rule_found else False
|
||||
|
||||
def create_mailcow_isolation_rule(self, _interface:str, _dports:list, _allow:str = ""):
|
||||
family = "ip"
|
||||
table = "filter"
|
||||
comment_filter_drop = "mailcow isolation"
|
||||
comment_filter_allow = "mailcow isolation allow"
|
||||
json_command = self.get_base_dict()
|
||||
|
||||
# Delete old mailcow isolation rules
|
||||
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")
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,31 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM nginx:alpine
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
|
||||
RUN apk add --no-cache nginx \
|
||||
python3 \
|
||||
py3-pip && \
|
||||
pip install --upgrade pip && \
|
||||
pip install Jinja2
|
||||
|
||||
RUN mkdir -p /etc/nginx/includes
|
||||
|
||||
COPY ./bootstrap.py /
|
||||
COPY ./docker-entrypoint.sh /
|
||||
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=nginx \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh nginx -g 'daemon off;'"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
@@ -0,0 +1,100 @@
|
||||
import os
|
||||
import subprocess
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
def includes_conf(env, template_vars):
|
||||
server_name = "server_name.active"
|
||||
listen_plain = "listen_plain.active"
|
||||
listen_ssl = "listen_ssl.active"
|
||||
|
||||
server_name_config = f"server_name {template_vars['MAILCOW_HOSTNAME']} autodiscover.* autoconfig.* {' '.join(template_vars['ADDITIONAL_SERVER_NAMES'])};"
|
||||
listen_plain_config = f"listen {template_vars['HTTP_PORT']};"
|
||||
listen_ssl_config = f"listen {template_vars['HTTPS_PORT']};"
|
||||
if template_vars['ENABLE_IPV6']:
|
||||
listen_plain_config += f"\nlisten [::]:{template_vars['HTTP_PORT']};"
|
||||
listen_ssl_config += f"\nlisten [::]:{template_vars['HTTPS_PORT']} ssl;"
|
||||
listen_ssl_config += "\nhttp2 on;"
|
||||
|
||||
with open(f"/etc/nginx/conf.d/{server_name}", "w") as f:
|
||||
f.write(server_name_config)
|
||||
|
||||
with open(f"/etc/nginx/conf.d/{listen_plain}", "w") as f:
|
||||
f.write(listen_plain_config)
|
||||
|
||||
with open(f"/etc/nginx/conf.d/{listen_ssl}", "w") as f:
|
||||
f.write(listen_ssl_config)
|
||||
|
||||
def sites_default_conf(env, template_vars):
|
||||
config_name = "sites-default.conf"
|
||||
template = env.get_template(f"{config_name}.j2")
|
||||
config = template.render(template_vars)
|
||||
|
||||
with open(f"/etc/nginx/includes/{config_name}", "w") as f:
|
||||
f.write(config)
|
||||
|
||||
def nginx_conf(env, template_vars):
|
||||
config_name = "nginx.conf"
|
||||
template = env.get_template(f"{config_name}.j2")
|
||||
config = template.render(template_vars)
|
||||
|
||||
with open(f"/etc/nginx/{config_name}", "w") as f:
|
||||
f.write(config)
|
||||
|
||||
def prepare_template_vars():
|
||||
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
|
||||
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
|
||||
trusted_proxies = os.getenv("TRUSTED_PROXIES", "")
|
||||
|
||||
template_vars = {
|
||||
'IPV4_NETWORK': ipv4_network,
|
||||
'TRUSTED_PROXIES': [item.strip() for item in trusted_proxies.split(",") if item.strip()],
|
||||
'SKIP_RSPAMD': os.getenv("SKIP_RSPAMD", "n").lower() in ("y", "yes"),
|
||||
'SKIP_SOGO': os.getenv("SKIP_SOGO", "n").lower() in ("y", "yes"),
|
||||
'NGINX_USE_PROXY_PROTOCOL': os.getenv("NGINX_USE_PROXY_PROTOCOL", "n").lower() in ("y", "yes"),
|
||||
'MAILCOW_HOSTNAME': os.getenv("MAILCOW_HOSTNAME", ""),
|
||||
'ADDITIONAL_SERVER_NAMES': [item.strip() for item in additional_server_names.split(",") if item.strip()],
|
||||
'HTTP_PORT': os.getenv("HTTP_PORT", "80"),
|
||||
'HTTPS_PORT': os.getenv("HTTPS_PORT", "443"),
|
||||
'SOGOHOST': os.getenv("SOGOHOST", ipv4_network + ".248"),
|
||||
'RSPAMDHOST': os.getenv("RSPAMDHOST", "rspamd-mailcow"),
|
||||
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
|
||||
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
|
||||
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
|
||||
}
|
||||
|
||||
ssl_dir = '/etc/ssl/mail/'
|
||||
template_vars['valid_cert_dirs'] = []
|
||||
for d in os.listdir(ssl_dir):
|
||||
full_path = os.path.join(ssl_dir, d)
|
||||
if not os.path.isdir(full_path):
|
||||
continue
|
||||
|
||||
cert_path = os.path.join(full_path, 'cert.pem')
|
||||
key_path = os.path.join(full_path, 'key.pem')
|
||||
domains_path = os.path.join(full_path, 'domains')
|
||||
|
||||
if os.path.isfile(cert_path) and os.path.isfile(key_path) and os.path.isfile(domains_path):
|
||||
with open(domains_path, 'r') as file:
|
||||
domains = file.read().strip()
|
||||
domains_list = domains.split()
|
||||
if domains_list and template_vars["MAILCOW_HOSTNAME"] not in domains_list:
|
||||
template_vars['valid_cert_dirs'].append({
|
||||
'cert_path': full_path + '/',
|
||||
'domains': domains
|
||||
})
|
||||
|
||||
return template_vars
|
||||
|
||||
def main():
|
||||
env = Environment(loader=FileSystemLoader('./etc/nginx/conf.d/templates'))
|
||||
|
||||
# Render config
|
||||
print("Render config")
|
||||
template_vars = prepare_template_vars()
|
||||
sites_default_conf(env, template_vars)
|
||||
nginx_conf(env, template_vars)
|
||||
includes_conf(env, template_vars)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
|
||||
PHPFPMHOST=${PHPFPMHOST:-"php-fpm-mailcow"}
|
||||
SOGOHOST=${SOGOHOST:-"$IPV4_NETWORK.248"}
|
||||
RSPAMDHOST=${RSPAMDHOST:-"rspamd-mailcow"}
|
||||
|
||||
until ping ${PHPFPMHOST} -c1 > /dev/null; do
|
||||
echo "Waiting for PHP..."
|
||||
sleep 1
|
||||
done
|
||||
if ! printf "%s\n" "${SKIP_SOGO}" | grep -E '^([yY][eE][sS]|[yY])+$' >/dev/null; then
|
||||
until ping ${SOGOHOST} -c1 > /dev/null; do
|
||||
echo "Waiting for SOGo..."
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
if ! printf "%s\n" "${SKIP_RSPAMD}" | grep -E '^([yY][eE][sS]|[yY])+$' >/dev/null; then
|
||||
until ping ${RSPAMDHOST} -c1 > /dev/null; do
|
||||
echo "Waiting for Rspamd..."
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
|
||||
python3 /bootstrap.py
|
||||
|
||||
exec "$@"
|
||||
@@ -1,5 +1,10 @@
|
||||
FROM alpine:3.19
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
WORKDIR /app
|
||||
@@ -17,6 +22,16 @@ ADD olefy.py /app/
|
||||
|
||||
RUN chown -R nobody:nobody /app /tmp
|
||||
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
USER nobody
|
||||
|
||||
CMD ["python3", "-u", "/app/olefy.py"]
|
||||
ENV MAILCOW_AGENT_SERVICE=olefy \
|
||||
MAILCOW_AGENT_MAIN_CMD="python3 -u /app/olefy.py"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -32,6 +32,13 @@ import time
|
||||
import magic
|
||||
import re
|
||||
|
||||
skip_olefy = os.getenv('SKIP_OLEFY', '')
|
||||
|
||||
if skip_olefy.lower() in ['yes', 'y']:
|
||||
print("SKIP_OLEFY=y, skipping Olefy...")
|
||||
time.sleep(365 * 24 * 60 * 60)
|
||||
sys.exit(0)
|
||||
|
||||
# merge variables from /etc/olefy.conf and the defaults
|
||||
olefy_listen_addr_string = os.getenv('OLEFY_BINDADDRESS', '127.0.0.1,::1')
|
||||
olefy_listen_port = int(os.getenv('OLEFY_BINDPORT', '10050'))
|
||||
@@ -113,7 +120,7 @@ def oletools( stream, tmp_file_name, lid ):
|
||||
out = bytes(out.decode('utf-8', 'ignore').replace(' ', ' ').replace('\t', '').replace('\n', '').replace('XLMMacroDeobfuscator: pywin32 is not installed (only is required if you want to use MS Excel)', ''), encoding="utf-8")
|
||||
failed = False
|
||||
if out.__len__() < 30:
|
||||
logger.error('{} olevba returned <30 chars - rc: {!r}, response: {!r}, error: {!r}'.format(lid,cmd_tmp.returncode,
|
||||
logger.error('{} olevba returned <30 chars - rc: {!r}, response: {!r}, error: {!r}'.format(lid,cmd_tmp.returncode,
|
||||
out.decode('utf-8', 'ignore'), err.decode('utf-8', 'ignore')))
|
||||
out = b'[ { "error": "Unhandled error - too short olevba response" } ]'
|
||||
failed = True
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
FROM php:8.2-fpm-alpine3.18
|
||||
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM php:8.2-fpm-alpine3.21
|
||||
|
||||
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
# renovate: datasource=github-tags depName=krakjoe/apcu versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG APCU_PECL_VERSION=5.1.23
|
||||
ARG APCU_PECL_VERSION=5.1.28
|
||||
# renovate: datasource=github-tags depName=Imagick/imagick versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG IMAGICK_PECL_VERSION=3.7.0
|
||||
ARG IMAGICK_PECL_VERSION=3.8.1
|
||||
# renovate: datasource=github-tags depName=php/pecl-mail-mailparse versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG MAILPARSE_PECL_VERSION=3.1.6
|
||||
ARG MAILPARSE_PECL_VERSION=3.1.9
|
||||
# renovate: datasource=github-tags depName=php-memcached-dev/php-memcached versioning=semver-coerced extractVersion=^v(?<version>.*)$
|
||||
ARG MEMCACHED_PECL_VERSION=3.2.0
|
||||
ARG MEMCACHED_PECL_VERSION=3.4.0
|
||||
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG REDIS_PECL_VERSION=6.0.2
|
||||
ARG REDIS_PECL_VERSION=6.3.0
|
||||
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
|
||||
ARG COMPOSER_VERSION=2.6.6
|
||||
ARG COMPOSER_VERSION=2.9.5
|
||||
|
||||
RUN apk add -U --no-cache autoconf \
|
||||
aspell-dev \
|
||||
@@ -71,12 +76,12 @@ RUN apk add -U --no-cache autoconf \
|
||||
&& pecl clear-cache \
|
||||
&& docker-php-ext-configure intl \
|
||||
&& docker-php-ext-configure exif \
|
||||
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
||||
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
|
||||
--with-jpeg=/usr/include/ \
|
||||
--with-webp \
|
||||
--with-xpm \
|
||||
--with-avif \
|
||||
&& docker-php-ext-install -j 4 exif gd gettext intl ldap opcache pcntl pdo pdo_mysql pspell soap sockets sysvsem zip bcmath gmp \
|
||||
&& docker-php-ext-install -j 4 exif gd gettext intl ldap opcache pcntl pdo pdo_mysql pspell soap sockets zip bcmath gmp \
|
||||
&& docker-php-ext-configure imap --with-imap --with-imap-ssl \
|
||||
&& docker-php-ext-install -j 4 imap \
|
||||
&& curl --silent --show-error https://getcomposer.org/installer | php -- --version=${COMPOSER_VERSION} \
|
||||
@@ -108,6 +113,14 @@ RUN apk add -U --no-cache autoconf \
|
||||
|
||||
COPY ./docker-entrypoint.sh /
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
CMD ["php-fpm"]
|
||||
ENV MAILCOW_AGENT_SERVICE=php-fpm \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh php-fpm"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -3,79 +3,61 @@
|
||||
function array_by_comma { local IFS=","; echo "$*"; }
|
||||
|
||||
# Wait for containers
|
||||
while ! mysqladmin status --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..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Do not attempt to write to slave
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT}"
|
||||
REDIS_HOST=$REDIS_SLAVEOF_IP
|
||||
REDIS_PORT=$REDIS_SLAVEOF_PORT
|
||||
else
|
||||
REDIS_CMDLINE="redis-cli -h redis -p 6379"
|
||||
REDIS_HOST="redis"
|
||||
REDIS_PORT="6379"
|
||||
fi
|
||||
REDIS_CMDLINE="redis-cli -h ${REDIS_HOST} -p ${REDIS_PORT} -a ${REDISPASS} --no-auth-warning"
|
||||
|
||||
until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
|
||||
echo "Waiting for Redis..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Check mysql_upgrade (master and slave)
|
||||
CONTAINER_ID=
|
||||
until [[ ! -z "${CONTAINER_ID}" ]] && [[ "${CONTAINER_ID}" =~ ^[[:alnum:]]*$ ]]; do
|
||||
CONTAINER_ID=$(curl --silent --insecure https://dockerapi/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"mysql-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
|
||||
sleep 2
|
||||
done
|
||||
echo "MySQL @ ${CONTAINER_ID}"
|
||||
SQL_LOOP_C=0
|
||||
SQL_CHANGED=0
|
||||
until [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; do
|
||||
if [ ${SQL_LOOP_C} -gt 4 ]; then
|
||||
echo "Tried to upgrade MySQL and failed, giving up after ${SQL_LOOP_C} retries and starting container (oops, not good)"
|
||||
# Set redis session store
|
||||
echo -n '
|
||||
session.save_handler = redis
|
||||
session.save_path = "tcp://'${REDIS_HOST}':'${REDIS_PORT}'?auth='${REDISPASS}'"
|
||||
' > /usr/local/etc/php/conf.d/session_store.ini
|
||||
|
||||
# Wait for MariaDB. The upstream mariadb image already runs mariadb-upgrade
|
||||
# itself on startup when needed
|
||||
echo "Waiting for MariaDB socket at /var/run/mysqld/mysqld.sock..."
|
||||
WAIT_C=0
|
||||
until mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} -e "SELECT 1" >/dev/null 2>&1; do
|
||||
WAIT_C=$((WAIT_C+1))
|
||||
if [ ${WAIT_C} -gt 60 ]; then
|
||||
echo "MariaDB did not respond after 60s — continuing anyway."
|
||||
break
|
||||
fi
|
||||
SQL_FULL_UPGRADE_RETURN=$(curl --silent --insecure -XPOST https://dockerapi/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_upgrade"}' --silent -H 'Content-type: application/json')
|
||||
SQL_UPGRADE_STATUS=$(echo ${SQL_FULL_UPGRADE_RETURN} | jq -r .type)
|
||||
SQL_LOOP_C=$((SQL_LOOP_C+1))
|
||||
echo "SQL upgrade iteration #${SQL_LOOP_C}"
|
||||
if [[ ${SQL_UPGRADE_STATUS} == 'warning' ]]; then
|
||||
SQL_CHANGED=1
|
||||
echo "MySQL applied an upgrade, debug output:"
|
||||
echo ${SQL_FULL_UPGRADE_RETURN}
|
||||
sleep 3
|
||||
while ! mysqladmin status --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
|
||||
echo "Waiting for SQL to return, please wait"
|
||||
sleep 2
|
||||
done
|
||||
continue
|
||||
elif [[ ${SQL_UPGRADE_STATUS} == 'success' ]]; then
|
||||
echo "MySQL is up-to-date - debug output:"
|
||||
echo ${SQL_FULL_UPGRADE_RETURN}
|
||||
else
|
||||
echo "No valid reponse for mysql_upgrade was received, debug output:"
|
||||
echo ${SQL_FULL_UPGRADE_RETURN}
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "MariaDB is ready."
|
||||
|
||||
# doing post-installation stuff, if SQL was upgraded (master and slave)
|
||||
if [ ${SQL_CHANGED} -eq 1 ]; then
|
||||
POSTFIX=$(curl --silent --insecure https://dockerapi/containers/json | jq -r ".[] | {name: .Config.Labels[\"com.docker.compose.service\"], project: .Config.Labels[\"com.docker.compose.project\"], id: .Id}" 2> /dev/null | jq -rc "select( .name | tostring | contains(\"postfix-mailcow\")) | select( .project | tostring | contains(\"${COMPOSE_PROJECT_NAME,,}\")) | .id" 2> /dev/null)
|
||||
if [[ -z "${POSTFIX}" ]] || ! [[ "${POSTFIX}" =~ ^[[:alnum:]]*$ ]]; then
|
||||
echo "Could not determine Postfix container ID, skipping Postfix restart."
|
||||
else
|
||||
echo "Restarting Postfix"
|
||||
curl -X POST --silent --insecure https://dockerapi/containers/${POSTFIX}/restart | jq -r '.msg'
|
||||
echo "Sleeping 5 seconds..."
|
||||
sleep 5
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check mysql tz import (master and slave)
|
||||
TZ_CHECK=$(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
|
||||
# Timezone tables — check if CONVERT_TZ works, import if it returns NULL.
|
||||
# Some Alpine builds drop mariadb-tzinfo-to-sql; fall back to a Python
|
||||
# emitter that produces the same INSERT statements from /usr/share/zoneinfo.
|
||||
TZ_CHECK=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT CONVERT_TZ('2019-11-02 23:33:00','Europe/Berlin','UTC') AS time;" -BN 2> /dev/null)
|
||||
if [[ -z ${TZ_CHECK} ]] || [[ "${TZ_CHECK}" == "NULL" ]]; then
|
||||
SQL_FULL_TZINFO_IMPORT_RETURN=$(curl --silent --insecure -XPOST https://dockerapi/containers/${CONTAINER_ID}/exec -d '{"cmd":"system", "task":"mysql_tzinfo_to_sql"}' --silent -H 'Content-type: application/json')
|
||||
echo "MySQL mysql_tzinfo_to_sql - debug output:"
|
||||
echo ${SQL_FULL_TZINFO_IMPORT_RETURN}
|
||||
echo "Importing timezone data into mysql.time_zone_* …"
|
||||
if command -v mariadb-tzinfo-to-sql >/dev/null 2>&1; then
|
||||
mariadb-tzinfo-to-sql /usr/share/zoneinfo 2>/dev/null \
|
||||
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
|
||||
elif command -v mysql_tzinfo_to_sql >/dev/null 2>&1; then
|
||||
mysql_tzinfo_to_sql /usr/share/zoneinfo 2>/dev/null \
|
||||
| mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -uroot -p${DBROOT} mysql
|
||||
else
|
||||
echo "No tzinfo-to-sql tool available — skipping timezone import."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
@@ -110,11 +92,11 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
while read line
|
||||
do
|
||||
DOMAIN_ARR+=("$line")
|
||||
done < <(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain" -Bs)
|
||||
done < <(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT domain FROM domain" -Bs)
|
||||
while read line
|
||||
do
|
||||
DOMAIN_ARR+=("$line")
|
||||
done < <(mysql --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT alias_domain FROM alias_domain" -Bs)
|
||||
done < <(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT alias_domain FROM alias_domain" -Bs)
|
||||
|
||||
if [[ ! -z ${DOMAIN_ARR} ]]; then
|
||||
for domain in "${DOMAIN_ARR[@]}"; do
|
||||
@@ -136,13 +118,13 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
VALIDATED_IPS=$(array_by_comma ${VALIDATED_API_ALLOW_FROM_ARR[*]})
|
||||
if [[ ! -z ${VALIDATED_IPS} ]]; then
|
||||
if [[ ${API_KEY} != "invalid" ]] && [[ ! -z ${API_KEY} ]]; then
|
||||
mysql --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
|
||||
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
|
||||
mysql --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
|
||||
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
|
||||
@@ -151,13 +133,13 @@ EOF
|
||||
fi
|
||||
|
||||
# Create events (master only, STATUS for event on slave will be SLAVESIDE_DISABLED)
|
||||
mysql --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
|
||||
DROP EVENT IF EXISTS clean_spamalias;
|
||||
DELIMITER //
|
||||
CREATE EVENT clean_spamalias
|
||||
ON SCHEDULE EVERY 1 DAY DO
|
||||
BEGIN
|
||||
DELETE FROM spamalias WHERE validity < UNIX_TIMESTAMP();
|
||||
DELETE FROM spamalias WHERE validity < UNIX_TIMESTAMP() AND permanent = 0;
|
||||
END;
|
||||
//
|
||||
DELIMITER ;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
WORKDIR /src
|
||||
|
||||
ENV CGO_ENABLED=0 \
|
||||
GO111MODULE=on \
|
||||
NOOPT=1 \
|
||||
VERSION=1.8.22
|
||||
|
||||
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
|
||||
cd /src/postfix-tlspol && \
|
||||
scripts/build.sh build-only
|
||||
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ENV LC_ALL=C
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
dirmngr \
|
||||
dnsutils \
|
||||
iputils-ping \
|
||||
sudo \
|
||||
supervisor \
|
||||
redis-tools \
|
||||
syslog-ng \
|
||||
syslog-ng-core \
|
||||
syslog-ng-mod-redis \
|
||||
tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& touch /etc/default/locale
|
||||
|
||||
COPY supervisord.conf /etc/supervisor/supervisord.conf
|
||||
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
||||
COPY postfix-tlspol.sh /opt/postfix-tlspol.sh
|
||||
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
COPY --from=builder /src/postfix-tlspol/build/postfix-tlspol /usr/local/bin/postfix-tlspol
|
||||
|
||||
RUN chmod +x /opt/postfix-tlspol.sh \
|
||||
/usr/local/sbin/stop-supervisor.sh \
|
||||
/docker-entrypoint.sh
|
||||
RUN rm -rf /tmp/* /var/tmp/*
|
||||
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
ENV MAILCOW_AGENT_SERVICE=postfix-tlspol \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
cp /etc/syslog-ng/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng.conf
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
#!/bin/bash
|
||||
|
||||
LOGLVL=info
|
||||
|
||||
if [ ${DEV_MODE} != "n" ]; then
|
||||
echo -e "\e[31mEnabling debug mode\e[0m"
|
||||
set -x
|
||||
LOGLVL=debug
|
||||
fi
|
||||
|
||||
[[ ! -d /etc/postfix-tlspol ]] && mkdir -p /etc/postfix-tlspol
|
||||
[[ ! -d /var/lib/postfix-tlspol ]] && mkdir -p /var/lib/postfix-tlspol
|
||||
|
||||
until dig +short mailcow.email > /dev/null; do
|
||||
echo "Waiting for DNS..."
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Do not attempt to write to slave
|
||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
export REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
|
||||
else
|
||||
export REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning"
|
||||
fi
|
||||
|
||||
until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do
|
||||
echo "Waiting for Redis..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Waiting for Postfix..."
|
||||
until ping postfix -c1 > /dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
echo "Postfix OK"
|
||||
|
||||
cat <<EOF > /etc/postfix-tlspol/config.yaml
|
||||
server:
|
||||
address: 0.0.0.0:8642
|
||||
|
||||
log-level: ${LOGLVL}
|
||||
|
||||
prefetch: true
|
||||
|
||||
cache-file: /var/lib/postfix-tlspol/cache.db
|
||||
|
||||
dns:
|
||||
# must support DNSSEC
|
||||
address: 127.0.0.11:53
|
||||
EOF
|
||||
|
||||
/usr/local/bin/postfix-tlspol -config /etc/postfix-tlspol/config.yaml
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
printf "READY\n";
|
||||
|
||||
while read line; do
|
||||
echo "Processing Event: $line" >&2;
|
||||
kill -3 $(cat "/var/run/supervisord.pid")
|
||||
done < /dev/stdin
|
||||
@@ -0,0 +1,25 @@
|
||||
[supervisord]
|
||||
pidfile=/var/run/supervisord.pid
|
||||
nodaemon=true
|
||||
user=root
|
||||
|
||||
[program:syslog-ng]
|
||||
command=/usr/sbin/syslog-ng --foreground --no-caps
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
autostart=true
|
||||
|
||||
[program:postfix-tlspol]
|
||||
startsecs=10
|
||||
autorestart=true
|
||||
command=/opt/postfix-tlspol.sh
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
[eventlistener:processes]
|
||||
command=/usr/local/sbin/stop-supervisor.sh
|
||||
events=PROCESS_STATE_STOPPED, PROCESS_STATE_EXITED, PROCESS_STATE_FATAL
|
||||
@@ -0,0 +1,45 @@
|
||||
@version: 3.38
|
||||
@include "scl.conf"
|
||||
options {
|
||||
chain_hostnames(off);
|
||||
flush_lines(0);
|
||||
use_dns(no);
|
||||
dns_cache(no);
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats_freq(0);
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_src {
|
||||
unix-stream("/dev/log");
|
||||
internal();
|
||||
};
|
||||
destination d_stdout { pipe("/dev/stdout"); };
|
||||
destination d_redis_ui_log {
|
||||
redis(
|
||||
host("`REDIS_SLAVEOF_IP`")
|
||||
persist-name("redis1")
|
||||
port(`REDIS_SLAVEOF_PORT`)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
filter f_mail { facility(mail); };
|
||||
# start
|
||||
# overriding warnings are still displayed when the entrypoint runs its initial check
|
||||
# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs
|
||||
# Some other warnings are ignored
|
||||
filter f_ignore {
|
||||
not match("overriding earlier entry" value("MESSAGE"));
|
||||
not match("TLS SNI from checks.mailcow.email" value("MESSAGE"));
|
||||
not match("no SASL support" value("MESSAGE"));
|
||||
not facility (local0, local1, local2, local3, local4, local5, local6, local7);
|
||||
};
|
||||
# end
|
||||
log {
|
||||
source(s_src);
|
||||
filter(f_ignore);
|
||||
destination(d_stdout);
|
||||
filter(f_mail);
|
||||
destination(d_redis_ui_log);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
@version: 3.38
|
||||
@include "scl.conf"
|
||||
options {
|
||||
chain_hostnames(off);
|
||||
flush_lines(0);
|
||||
use_dns(no);
|
||||
dns_cache(no);
|
||||
use_fqdn(no);
|
||||
owner("root"); group("adm"); perm(0640);
|
||||
stats_freq(0);
|
||||
bad_hostname("^gconfd$");
|
||||
};
|
||||
source s_src {
|
||||
unix-stream("/dev/log");
|
||||
internal();
|
||||
};
|
||||
destination d_stdout { pipe("/dev/stdout"); };
|
||||
destination d_redis_ui_log {
|
||||
redis(
|
||||
host("redis-mailcow")
|
||||
persist-name("redis1")
|
||||
port(6379)
|
||||
auth("`REDISPASS`")
|
||||
command("LPUSH" "POSTFIX_MAILLOG" "$(format-json node=\"$HOST\" time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n")
|
||||
);
|
||||
};
|
||||
filter f_mail { facility(mail); };
|
||||
# start
|
||||
# overriding warnings are still displayed when the entrypoint runs its initial check
|
||||
# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs
|
||||
# Some other warnings are ignored
|
||||
filter f_ignore {
|
||||
not match("overriding earlier entry" value("MESSAGE"));
|
||||
not match("TLS SNI from checks.mailcow.email" value("MESSAGE"));
|
||||
not match("no SASL support" value("MESSAGE"));
|
||||
not facility (local0, local1, local2, local3, local4, local5, local6, local7);
|
||||
};
|
||||
# end
|
||||
log {
|
||||
source(s_src);
|
||||
filter(f_ignore);
|
||||
destination(d_stdout);
|
||||
filter(f_mail);
|
||||
destination(d_redis_ui_log);
|
||||
};
|
||||
@@ -1,8 +1,13 @@
|
||||
FROM debian:bullseye-slim
|
||||
LABEL maintainer "The Infrastructure Company GmbH GmbH <info@servercow.de>"
|
||||
ARG AGENT_IMAGE=ghcr.io/mailcow/agent:1.00
|
||||
|
||||
FROM ${AGENT_IMAGE} AS mailcow-agent-src
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
ENV LC_ALL C
|
||||
ENV LC_ALL=C
|
||||
|
||||
RUN dpkg-divert --local --rename --add /sbin/initctl \
|
||||
&& ln -sf /bin/true /sbin/initctl \
|
||||
@@ -57,6 +62,14 @@ RUN rm -rf /tmp/* /var/tmp/*
|
||||
|
||||
EXPOSE 588
|
||||
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent /usr/local/bin/mailcow-agent
|
||||
COPY --from=mailcow-agent-src /out/mailcow-agent-cli /usr/local/bin/mailcow-agent-cli
|
||||
|
||||
CMD exec /usr/bin/supervisord -c /etc/supervisor/supervisord.conf
|
||||
ENV MAILCOW_AGENT_SERVICE=postfix \
|
||||
MAILCOW_AGENT_MAIN_CMD="/docker-entrypoint.sh /usr/bin/supervisord -c /etc/supervisor/supervisord.conf"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["/usr/local/bin/mailcow-agent", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/mailcow-agent"]
|
||||
CMD []
|
||||
|
||||
@@ -12,4 +12,15 @@ if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||
cp /etc/syslog-ng/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng.conf
|
||||
fi
|
||||
|
||||
# Fix OpenSSL 3.X TLS1.0, 1.1 support (https://community.mailcow.email/d/4062-hi-all/20)
|
||||
if grep -qE '\!SSLv2|\!SSLv3|>=TLSv1(\.[0-1])?$' /opt/postfix/conf/main.cf /opt/postfix/conf/extra.cf; then
|
||||
sed -i '/\[openssl_init\]/a ssl_conf = ssl_configuration' /etc/ssl/openssl.cnf
|
||||
|
||||
echo "[ssl_configuration]" >> /etc/ssl/openssl.cnf
|
||||
echo "system_default = tls_system_default" >> /etc/ssl/openssl.cnf
|
||||
echo "[tls_system_default]" >> /etc/ssl/openssl.cnf
|
||||
echo "MinProtocol = TLSv1" >> /etc/ssl/openssl.cnf
|
||||
echo "CipherString = DEFAULT@SECLEVEL=0" >> /etc/ssl/openssl.cnf
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -5,7 +5,7 @@ trap "postfix stop" EXIT
|
||||
[[ ! -d /opt/postfix/conf/sql/ ]] && mkdir -p /opt/postfix/conf/sql/
|
||||
|
||||
# Wait for MySQL to warm-up
|
||||
while ! mysqladmin status --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 database to come up..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -329,14 +329,17 @@ query = SELECT goto FROM alias
|
||||
SELECT id FROM alias
|
||||
WHERE address='%s'
|
||||
AND (active='1' OR active='2')
|
||||
AND sender_allowed='1'
|
||||
), (
|
||||
SELECT id FROM alias
|
||||
WHERE address='@%d'
|
||||
AND (active='1' OR active='2')
|
||||
AND sender_allowed='1'
|
||||
)
|
||||
)
|
||||
)
|
||||
AND active='1'
|
||||
AND sender_allowed='1'
|
||||
AND (domain IN
|
||||
(SELECT domain FROM domain
|
||||
WHERE domain='%d'
|
||||
@@ -390,12 +393,12 @@ hosts = unix:/var/run/mysqld/mysqld.sock
|
||||
dbname = ${DBNAME}
|
||||
query = SELECT goto FROM spamalias
|
||||
WHERE address='%s'
|
||||
AND validity >= UNIX_TIMESTAMP()
|
||||
AND (validity >= UNIX_TIMESTAMP() OR permanent != 0)
|
||||
EOF
|
||||
|
||||
if [ ! -f /opt/postfix/conf/dns_blocklists.cf ]; then
|
||||
cat <<EOF > /opt/postfix/conf/dns_blocklists.cf
|
||||
# This file can be edited.
|
||||
# This file can be edited.
|
||||
# Delete this file and restart postfix container to revert any changes.
|
||||
postscreen_dnsbl_sites = wl.mailspike.net=127.0.0.[18;19;20]*-2
|
||||
hostkarma.junkemailfilter.com=127.0.0.1*-2
|
||||
@@ -403,7 +406,6 @@ postscreen_dnsbl_sites = wl.mailspike.net=127.0.0.[18;19;20]*-2
|
||||
list.dnswl.org=127.0.[0..255].1*-4
|
||||
list.dnswl.org=127.0.[0..255].2*-6
|
||||
list.dnswl.org=127.0.[0..255].3*-8
|
||||
ix.dnsbl.manitu.net*2
|
||||
bl.spamcop.net*2
|
||||
bl.suomispam.net*2
|
||||
hostkarma.junkemailfilter.com=127.0.0.2*3
|
||||
@@ -415,14 +417,12 @@ postscreen_dnsbl_sites = wl.mailspike.net=127.0.0.[18;19;20]*-2
|
||||
b.barracudacentral.org=127.0.0.2*7
|
||||
bl.mailspike.net=127.0.0.2*5
|
||||
bl.mailspike.net=127.0.0.[10;11;12]*4
|
||||
dnsbl.sorbs.net=127.0.0.10*8
|
||||
dnsbl.sorbs.net=127.0.0.5*6
|
||||
dnsbl.sorbs.net=127.0.0.7*3
|
||||
dnsbl.sorbs.net=127.0.0.8*2
|
||||
dnsbl.sorbs.net=127.0.0.6*2
|
||||
dnsbl.sorbs.net=127.0.0.9*2
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Remove discontinued DNSBLs from existing dns_blocklists.cf
|
||||
sed -i '/ix\.dnsbl\.manitu\.net\*2/d' /opt/postfix/conf/dns_blocklists.cf # Nixspam
|
||||
|
||||
DNSBL_CONFIG=$(grep -v '^#' /opt/postfix/conf/dns_blocklists.cf | grep '\S')
|
||||
|
||||
if [ ! -z "$DNSBL_CONFIG" ]; then
|
||||
@@ -513,6 +513,11 @@ chgrp -R postdrop /var/spool/postfix/public
|
||||
chgrp -R postdrop /var/spool/postfix/maildrop
|
||||
postfix set-permissions
|
||||
|
||||
# Checking if there is a leftover of a crashed postfix container before starting a new one
|
||||
if [ -e /var/spool/postfix/pid/master.pid ]; then
|
||||
rm -rf /var/spool/postfix/pid/master.pid
|
||||
fi
|
||||
|
||||
# Check Postfix configuration
|
||||
postconf -c /opt/postfix/conf > /dev/null
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user